From 11d6488d24471ae14972389f05b190ad55113795 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 19:21:44 +0600 Subject: [PATCH 01/30] Lock migrations, name whisper's failures, and record who ran what 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) --- .env.production.example | 10 +- api/src/runs/runs.controller.ts | 13 ++- docs/DEPLOYING.md | 10 ++ lib/server/brains.ts | 79 +++++++++++++-- lib/server/db/client.ts | 97 +++++++++++++----- lib/server/db/schema.ts | 27 +++++ lib/server/http.ts | 15 ++- lib/server/repos/runs.ts | 13 ++- lib/server/repos/settings.ts | 28 ++++++ lib/server/services/ingest.ts | 168 ++++++++++++++++++++++++++++---- lib/server/services/runs.ts | 36 ++++++- 11 files changed, 437 insertions(+), 59 deletions(-) diff --git a/.env.production.example b/.env.production.example index 73055a4..d90d304 100644 --- a/.env.production.example +++ b/.env.production.example @@ -16,11 +16,13 @@ ACME_EMAIL=you@yourcompany.com COOKIE_DOMAIN=.content.yourcompany.com COOKIE_SAMESITE=lax -# Encrypts the API keys stored in the settings table. +# Encrypts the API keys stored in the settings table. REQUIRED in production — +# the API refuses to start without it. # -# MOVE THE EXISTING VALUE from your local .env. A different secret does not -# fail loudly — it makes every key already saved unreadable, which reads as -# "the key vanished". +# MOVE THE EXISTING VALUE from your local .env. Leaving this blank is caught at +# boot, but setting the WRONG value is not: a different secret does not fail +# loudly, it makes every key already saved unreadable, which reads on the page +# as "the key vanished". CONTENTOS_SECRET= # How the model is reached on a server. diff --git a/api/src/runs/runs.controller.ts b/api/src/runs/runs.controller.ts index 683162c..8411a4e 100644 --- a/api/src/runs/runs.controller.ts +++ b/api/src/runs/runs.controller.ts @@ -26,6 +26,8 @@ import { startRun, writeSection, } from "@/lib/server/services/runs"; +import type { User } from "@/lib/server/auth"; +import { CurrentUser } from "../common/user.decorator"; /** * Runs, and the sections written into them. @@ -43,9 +45,17 @@ export class RunsController { return listRuns(workspaceId || undefined); } - /** Create a run with every section queued. It does not start writing. */ + /** + * Create a run with every section queued. It does not start writing. + * + * The author comes off the session, never out of the body: the guard has + * already established who this is, and a `createdBy` a caller could type + * would let anyone charge a run to a colleague. Same rule as `brandVoice`, + * which the service reads from the workspace for the same reason. + */ @Post() create( + @CurrentUser() user: User, @Body() body: { workspaceId?: string; @@ -63,6 +73,7 @@ export class RunsController { packSlug: body.packSlug ?? "enbn-website-package", inputs: body.inputs ?? {}, title: body.title, + userId: user.id, }); } diff --git a/docs/DEPLOYING.md b/docs/DEPLOYING.md index dd8d4cd..4d0754e 100644 --- a/docs/DEPLOYING.md +++ b/docs/DEPLOYING.md @@ -28,6 +28,16 @@ VPS — Hetzner, DigitalOcean, anything with 2GB of RAM — is the shape this wa the settings table. Move the database without moving that value and every saved key becomes unreadable, which looks like the keys vanished. +In production it is now **required**: the API refuses to start without it, +rather than falling back to a key derived from `DATABASE_URL`. That fallback +was the quieter failure of the two — it works until the day somebody rotates +the database password, and then every key saved under the old one decrypts to +nothing and reads on the page as though it had never been pasted. An install +that has been running on the fallback needs `CONTENTOS_SECRET` set before it +will boot again, and the keys saved under the fallback have to be re-entered +once; there is no way to recover them, because the value that encrypted them +was never written down. + ## First deploy On a machine with Docker, pointed at by a domain's A record: diff --git a/lib/server/brains.ts b/lib/server/brains.ts index 92950a5..befcade 100644 --- a/lib/server/brains.ts +++ b/lib/server/brains.ts @@ -140,17 +140,30 @@ export async function brainStatuses(): Promise { const tool = def.command ? tools.find((t) => t.id === (def.command as never)) : undefined; - // Ollama has no CLI probe here — it is an HTTP server, and a reachable - // port is the only thing that means anything about it. - const cli = def.id === "ollama" ? await ollamaUp() : Boolean(tool?.present); const { key, from } = await keyFor(def); + /* + * Ollama's address, resolved once for this whole row. + * + * `keyFor` has already fetched the `ollama-url` secret — that is what the + * key IS for this brand — so the base comes off it rather than out of a + * second `getSecret`. This function runs on every Integrations poll and + * again inside `write()`; one round-trip is enough. + */ + const base = def.id === "ollama" ? ollamaBase(key) : ""; + // Ollama has no CLI probe here — it is an HTTP server, and a reachable + // port is the only thing that means anything about it. It is probed at + // the configured address, not at localhost: an Ollama on another box was + // marked unavailable and `write()` refused it before `viaOllama` — which + // reads the very same secret — was ever given the chance to reach it. + const cli = def.id === "ollama" ? await ollamaUp(base) : Boolean(tool?.present); const api = def.id === "ollama" ? false : Boolean(key); const available = cli || api; return { ...def, cli, - cliVersion: def.id === "ollama" ? (cli ? "localhost:11434" : "") : (tool?.version ?? ""), + cliVersion: + def.id === "ollama" ? (cli ? hostOf(base) : "") : (tool?.version ?? ""), // What will actually be asked for, which is not always `model`: a CLI // with no override uses whatever the signed-in account defaults to. using: @@ -164,16 +177,66 @@ export async function brainStatuses(): Promise { reason: available ? "" : def.id === "ollama" - ? "Nothing is listening on localhost:11434" + ? // The address that was actually tried, never a hard-coded + // localhost: someone pointing this at Ollama on another machine + // has to be able to see WHICH host did not answer. + `Nothing is listening on ${hostOf(base)}` : `Install the ${def.command} CLI, or paste a key in Settings → API keys`, }; }), ); } -async function ollamaUp() { +/** Where Ollama is when nobody has said otherwise. */ +const OLLAMA_DEFAULT = "http://localhost:11434"; + +/** + * The one place that decides where Ollama is. + * + * There used to be two: the probe went to a hard-coded localhost while the + * transport read the `ollama-url` secret. Point the secret at another box and + * the probe found nothing at home, `available` came back false, and `write()` + * threw "Nothing is listening on localhost:11434" before the configured + * address was tried even once. Everything that needs the address takes it from + * here, so the two can no longer disagree. + * + * The trailing slash is stripped here and nowhere else: a stored + * `http://box:11434/` and a path joined with `/api/tags` make + * `http://box:11434//api/tags`, which some proxies in front of Ollama answer + * with a 404 that reads exactly like Ollama being absent. + */ +const ollamaBase = (stored: string) => + (stored.trim() || OLLAMA_DEFAULT).replace(/\/+$/, ""); + +/** The same secret, for the paths that have not already read it. */ +const ollamaUrl = async () => ollamaBase(await getSecret("ollama-url")); + +/** + * Host and port, for saying which address answered or did not. + * + * A URL is what is stored and what is fetched; a person reading the + * Integrations page wants the machine, not the scheme. A base that will not + * parse is shown whole rather than swallowed — a typo in the setting is + * exactly what someone reading this line is trying to find. + */ +function hostOf(base: string) { + try { + return new URL(base).host; + } catch { + return base; + } +} + +/** + * A reachable Ollama, at the address it is actually configured at. + * + * The 1500ms cap stays: this runs on the Integrations page, which polls, and a + * host that is switched off does not refuse a connection — it drops it, and + * the fetch would sit there until the OS gave up and the whole page with it. + */ +async function ollamaUp(base: string) { try { - const res = await fetch("http://localhost:11434/api/tags", { + const res = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(1500), }); return res.ok; @@ -613,7 +676,7 @@ async function viaGemini(def: BrainDef, req: WriteRequest, key: string) { } async function viaOllama(def: BrainDef, req: WriteRequest) { - const base = (await getSecret("ollama-url")) || "http://localhost:11434"; + const base = await ollamaUrl(); const res = await fetch(`${base}/api/chat`, { method: "POST", headers: { "content-type": "application/json" }, diff --git a/lib/server/db/client.ts b/lib/server/db/client.ts index 2adda1b..9785b1f 100644 --- a/lib/server/db/client.ts +++ b/lib/server/db/client.ts @@ -56,38 +56,89 @@ export function ready(): Promise { return store.__contentosReady; } +/* + * The key the migration lock is taken on. + * + * Any fixed bigint works — Postgres advisory locks are just numbers, and this + * one only has to be a number nothing else in this database picks. Written as + * a literal in the SQL below rather than a parameter, because a bound + * parameter arrives as text and `pg_advisory_lock` would have to be told which + * of its two overloads (one bigint, or two ints) was meant. + */ +const MIGRATION_LOCK = 4021957301; + +/* + * Migrations, one process at a time. + * + * Two API workers booting together both read `migrations`, both see the same + * migration as un-applied, and both run it; the second one dies on the + * `migrations` primary key and takes the worker down at boot. With one replica + * that never happened, so the race was invisible until this was deployed with + * more than one. + * + * The lock is session-level and taken on `conn` — the client already checked + * out — on purpose. `pool().query` would hand the statement to whichever + * connection was free, so the lock would be held by a session that is not the + * one running the migrations, and released the moment that query returned. + * + * The second process blocks here rather than failing: by the time it gets the + * lock the first has committed, so its `SELECT name FROM migrations` sees the + * work as done and it applies nothing. + */ async function migrate() { const conn = await pool().connect(); try { - await conn.query(` - CREATE TABLE IF NOT EXISTS migrations ( - name TEXT PRIMARY KEY, - applied_at TIMESTAMPTZ NOT NULL DEFAULT now() - )`); - - const done = new Set( - (await conn.query<{ name: string }>("SELECT name FROM migrations")).rows.map( - (r) => r.name, - ), - ); - - for (const m of MIGRATIONS) { - if (done.has(m.name)) continue; - await conn.query("BEGIN"); - try { - await conn.query(m.sql); - await conn.query("INSERT INTO migrations (name) VALUES ($1)", [m.name]); - await conn.query("COMMIT"); - } catch (e) { - await conn.query("ROLLBACK"); - throw e; - } + await conn.query(`SELECT pg_advisory_lock(${MIGRATION_LOCK})`); + try { + await runMigrations(conn); + } finally { + /* + * A session-level lock is held until it is released or the connection + * ends, and this connection goes back into the pool rather than ending — + * so skipping the unlock would wedge every later boot against a lock + * nothing is going to give up. + * + * Its own failure is swallowed: it can only fail if the connection is + * already broken (in which case Postgres has dropped the lock with the + * session), and throwing here would replace the migration error that + * actually explains what went wrong. + */ + await conn + .query(`SELECT pg_advisory_unlock(${MIGRATION_LOCK})`) + .catch(() => {}); } } finally { conn.release(); } } +async function runMigrations(conn: PoolClient) { + await conn.query(` + CREATE TABLE IF NOT EXISTS migrations ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); + + const done = new Set( + (await conn.query<{ name: string }>("SELECT name FROM migrations")).rows.map( + (r) => r.name, + ), + ); + + for (const m of MIGRATIONS) { + if (done.has(m.name)) continue; + await conn.query("BEGIN"); + try { + await conn.query(m.sql); + await conn.query("INSERT INTO migrations (name) VALUES ($1)", [m.name]); + await conn.query("COMMIT"); + } catch (e) { + await conn.query("ROLLBACK"); + throw e; + } + } +} + /** One query. Migrations are guaranteed to have run before it. */ export async function q( text: string, diff --git a/lib/server/db/schema.ts b/lib/server/db/schema.ts index 13490c1..92a2ad0 100644 --- a/lib/server/db/schema.ts +++ b/lib/server/db/schema.ts @@ -463,4 +463,31 @@ export const MIGRATIONS: { name: string; sql: string }[] = [ ALTER TABLE sources ADD COLUMN researched_at TIMESTAMPTZ; `, }, + + { + /* + * Who started a run. + * + * Nothing recorded it. On one laptop that was the same person every time, + * so the column would have been a joke; on a server it is the only way to + * answer the question DEPLOYING.md poses — a run against an API key costs + * real money, and "who ran what" had no answer anywhere in the database. + * It is also the groundwork for several people on several machines driving + * runs against one of these. + * + * Nulled rather than cascaded, for the reason `topic_id` above is: the + * content outlives the person who asked for it, and an employee leaving + * must not take a month of scripts with them. `invites.created_by` is the + * same pattern for the same reason. + * + * Nullable with no backfill: every run that already exists was started by + * somebody the database cannot name, and guessing — the owner, the first + * user — would put a name on work as if it were known. + */ + name: "0014_run_author", + sql: ` + ALTER TABLE runs ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL; + CREATE INDEX runs_created_by ON runs(created_by); + `, + }, ]; diff --git a/lib/server/http.ts b/lib/server/http.ts index 806ec9a..740bff1 100644 --- a/lib/server/http.ts +++ b/lib/server/http.ts @@ -1,3 +1,4 @@ +import { assertSecret } from "@/lib/server/repos/settings"; import { seedIfEmpty } from "@/lib/server/services/seed"; /** @@ -16,7 +17,19 @@ const store = globalThis as unknown as { __contentosSeed?: Promise }; export function ready(): Promise { if (!store.__contentosSeed) { - store.__contentosSeed = seedIfEmpty().catch((e) => { + store.__contentosSeed = (async () => { + // Before anything writes: a production server without CONTENTOS_SECRET + // encrypts API keys under a value derived from DATABASE_URL, and loses + // every one of them the day that password is rotated. Checked here so a + // misconfigured deploy dies at boot with a sentence saying what to set, + // rather than looking healthy and dropping keys weeks later. + // + // Inside the async body rather than above it so a bad config comes back + // as a rejected promise like every other boot failure — and, like them, + // is not cached: fix the env var, restart, and the next call retries. + assertSecret(); + await seedIfEmpty(); + })().catch((e) => { // A failed seed must not be cached as done — the next request retries. store.__contentosSeed = undefined; throw e; diff --git a/lib/server/repos/runs.ts b/lib/server/repos/runs.ts index 49a5b7d..42528a4 100644 --- a/lib/server/repos/runs.ts +++ b/lib/server/repos/runs.ts @@ -35,6 +35,11 @@ export type Run = { sourceId: string | null; /** What you decided about the content: used it, ready to, ignored it. */ decision: Decision; + /** + * Who pressed Run. Null for the runs made before anyone was recorded, and + * for a run whose author has since been deleted — the work outlives them. + */ + createdBy: string | null; sections: RunSection[]; createdAt: string; updatedAt: string; @@ -70,6 +75,7 @@ const mapRun = (r: Row, sections: RunSection[]): Run => ({ brandVoice: String(r.brand_voice ?? ""), sourceId: r.source_id == null ? null : String(r.source_id), decision: asDecision(r.decision), + createdBy: r.created_by == null ? null : String(r.created_by), sections, createdAt: iso(r.created_at), updatedAt: iso(r.updated_at), @@ -128,6 +134,8 @@ export async function createRun(patch: { inputs: Record; brandVoice?: string; sourceId?: string | null; + /** Who asked for it. Omitted only where there is no session to ask. */ + createdBy?: string | null; sections: { id: string; title: string }[]; }): Promise { const rid = id("run"); @@ -136,8 +144,8 @@ export async function createRun(patch: { await c.query( `INSERT INTO runs (id, workspace_id, topic_id, pack_slug, title, inputs, brand_voice, - source_id) - VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)`, + source_id, created_by) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9)`, [ rid, patch.workspaceId, @@ -147,6 +155,7 @@ export async function createRun(patch: { JSON.stringify(patch.inputs), patch.brandVoice ?? "", patch.sourceId ?? null, + patch.createdBy ?? null, ], ); for (let i = 0; i < patch.sections.length; i += 1) { diff --git a/lib/server/repos/settings.ts b/lib/server/repos/settings.ts index eb2a8b6..0ae51bb 100644 --- a/lib/server/repos/settings.ts +++ b/lib/server/repos/settings.ts @@ -139,6 +139,34 @@ const SECRET = createHash("sha256") ) .digest(); +/** + * Refuses to run a server on the development fallback. + * + * Deriving the key from DATABASE_URL is fine on a laptop, where the + * connection string never changes. On a real deployment it is a trap: rotate + * the database password and every stored key is suddenly encrypted under a + * value nothing can reproduce. `decrypt` returns "" for anything it cannot + * open, so nothing errors — the keys simply stop being there, and it reads to + * whoever owns the box as "the console lost my keys" rather than "the secret + * changed". Better to refuse to boot while it is still one env var away from + * being right. + * + * Called from ready() in lib/server/http.ts, before the port opens. Only in + * production: `npm run dev` keeps the fallback and needs no configuration. + */ +export function assertSecret() { + if (process.env.NODE_ENV !== "production") return; + if (process.env.CONTENTOS_SECRET?.trim()) return; + throw new Error( + "CONTENTOS_SECRET is not set. Set it to a long random string and keep it " + + "constant for the life of this deployment — it is the key that encrypts " + + "stored API keys. Without it the key is derived from DATABASE_URL, so " + + "rotating the database password would make every saved key unreadable, " + + "silently. Note that keys saved under a different value cannot be read " + + "back with a new one and will have to be entered again.", + ); +} + const SECRET_PREFIX = "secret:"; function encrypt(plain: string) { diff --git a/lib/server/services/ingest.ts b/lib/server/services/ingest.ts index c0144ef..d1df494 100644 --- a/lib/server/services/ingest.ts +++ b/lib/server/services/ingest.ts @@ -46,6 +46,16 @@ const DATA = path.join(DATA_ROOT, "sources"); /** How many stills to take. Enough to see a flow, few enough to read. */ const FRAME_COUNT = 8; +/** + * How long whisper gets before it is killed. + * + * The base model on a CPU transcribes at roughly real time, so ten minutes + * covers a reel many times over and still ends rather than pinning a core all + * evening. Named rather than written at the call site because the sentence a + * person reads when it runs out has to quote the same number. + */ +const WHISPER_TIMEOUT_MS = 600_000; + /** * The only shapes a frame filename may have — the evenly spaced ones, and the * ones cut at a time somebody asked for. Anything else is a path traversal @@ -102,12 +112,17 @@ export async function ingest(input: { const video = await download(url, dir); const frames = video.file ? await cutFrames(video.file, dir) : []; - const transcript = video.file ? await transcribe(video.file, dir) : ""; + // Only when there is a file: with nothing downloaded there is no audio, + // and "whisper is switched off" underneath "the video is private" is a + // second sentence about a step that was never going to run. + const spoken = video.file + ? await transcribe(video.file, dir) + : { text: "", note: "", failed: false }; return (await updateSource(source.id, { state: "ready", frames, - transcript, + transcript: spoken.text, /* * The reason, in yt-dlp's own words. * @@ -115,8 +130,22 @@ export async function ingest(input: { * dead link and a platform refusing anonymous requests, and only one of * those is worth trying again. The tool already says which. */ + /* + * Whichever step fell short, in its own words. + * + * The download's reason wins when there is one: it is the larger loss, + * and it is why whisper never ran. Otherwise a whisper that FAILED gets + * the line — this path said nothing at all about the transcript before, + * so a whisper killed at the ten minute mark produced a source marked + * ready, with an empty transcript, and no hint anywhere that anything + * had gone wrong. A whisper deliberately switched off says nothing here: + * a link fetched for its caption is doing what was asked of it, and an + * error on every one of them is a banner people learn to stop reading. + */ error: video.file - ? "" + ? spoken.failed + ? spoken.note + : "" : `Metadata only — ${video.error}. The caption and title are still usable.`, }))!; } catch (e) { @@ -179,15 +208,19 @@ export async function ingestFile(input: { const seconds = await durationOf(file); const frames = await cutFrames(file, dir); - const transcript = await transcribe(file, dir); + const spoken = await transcribe(file, dir); return (await updateSource(source.id, { state: "ready", title: name, duration: seconds || null, frames, - transcript, - error: transcript ? "" : "No transcript — whisper is off or not installed.", + transcript: spoken.text, + // What actually happened, rather than the guess this used to print. "Off + // or not installed" was shown to people whose whisper was installed, + // switched on, and killed by the timeout — the one case of the four + // where there is something to be done about it. + error: spoken.note, }))!; } catch (e) { const message = e instanceof Error ? e.message : "The upload failed"; @@ -475,16 +508,44 @@ async function durationOf(video: string) { } /** - * The spoken audio, as text. + * What came back from the attempt to transcribe — and, when nothing did, why. * - * Whisper if it is installed. If it is not, this returns "" and the source is - * still useful — the caption and description usually carry the domain too. - * The alternative, failing the whole ingest over an optional tool, would make - * the feature unavailable to anyone without a Python toolchain. + * A bare "" said four different things at once: whisper is switched off, + * whisper is not installed, whisper ran for ten minutes and was killed, and + * whisper exited fine but wrote nothing. The first two are the fast path and + * the last two are failures, and the caller marked all four `state: "ready"` + * with an empty transcript — so a source whose transcription had timed out + * looked exactly like one nobody wanted transcribed, and the model later wrote + * a thinner section off the frames alone with nobody the wiser. */ -async function transcribe(video: string, dir: string): Promise { - if (!(await allowed("whisper"))) return ""; +type Transcribed = { + /** The spoken audio as text, or "" if there is none. */ + text: string; + /** + * Why there is no text, as the sentence the source's `error` field carries + * to the screen. Empty when there IS text, and only then. + */ + note: string; + /** + * Whether that note is a real failure or an expected absence. Switched off + * and not installed are decisions about this machine; timed out and crashed + * are things that went wrong and might go right on a retry. + */ + failed: boolean; +}; +/** + * The spoken audio, as text. + * + * Whisper if it is switched on and installed. If it is not, this returns no + * text and the source is still useful — the caption and description usually + * carry the domain too. The alternative, failing the whole ingest over an + * optional tool, would make the feature unavailable to anyone without a Python + * toolchain. A whisper that ran and failed degrades the same way, but says so: + * the transcript is the difference between a section written from what was + * said and one written from what could be seen. + */ +async function transcribe(video: string, dir: string): Promise { /* * However this machine can reach it. * @@ -493,9 +554,32 @@ async function transcribe(video: string, dir: string): Promise { * and records whichever answered. Running the recorded one means an install * the probe found is an install this can use. */ - const tool = await toolStatus("whisper"); + const [tool, settings] = await Promise.all([toolStatus("whisper"), getSettings()]); - const { code } = await run( + /* + * The two expected absences, told apart. + * + * `allowed()` collapses them into one boolean, which is the right answer for + * ffmpeg and the wrong one here: "switched off" is fixed by a toggle on the + * Integrations page and "not installed" by a pip install, and a person told + * the wrong one goes looking in the wrong place. + */ + if (!settings.enabled.includes("whisper")) { + return { + text: "", + note: "No transcript — whisper is switched off in Integrations.", + failed: false, + }; + } + if (!tool.present) { + return { + text: "", + note: `No transcript — whisper is not installed here. ${tool.install}`, + failed: false, + }; + } + + const { code, err } = await run( tool.command, [ ...tool.lead, @@ -509,14 +593,60 @@ async function transcribe(video: string, dir: string): Promise { "--fp16", "False", ], - { env: tool.env, timeout: 600_000 }, + { env: tool.env, timeout: WHISPER_TIMEOUT_MS }, ); - if (code !== 0) return ""; + + if (code !== 0) { + /* + * A timeout reads as its own thing, not as a crash. + * + * `run()` kills the child and returns -1 with "Timed out after ..." on + * stderr, and that is the likeliest real failure here: the base model on a + * CPU runs at roughly real time, so a long video on an ordinary laptop + * runs out of clock rather than crashing. The remedy is a shorter clip, + * not a reinstall, so the sentence must not say the same thing a crash + * would. + */ + if (/Timed out after/.test(err)) { + return { + text: "", + note: `No transcript — whisper ran out of time after ${Math.round( + WHISPER_TIMEOUT_MS / 60_000, + )} minutes on this video. A shorter clip will finish.`, + failed: true, + }; + } + return { + text: "", + note: `No transcript — whisper failed: ${firstUseful(err) || `exit ${code}`}`, + failed: true, + }; + } const files = await fs.readdir(dir).catch(() => []); const txt = files.find((f) => f.endsWith(".txt")); - if (!txt) return ""; - return (await fs.readFile(path.join(dir, txt), "utf8").catch(() => "")).trim(); + /* + * Exited clean and left nothing behind: a directory it could not write into, + * or a build that wrote some other format. There is no transcript either + * way, and it is not the fast path, so it does not get to look like one. + */ + if (!txt) { + return { + text: "", + note: "No transcript — whisper finished but wrote no text file.", + failed: true, + }; + } + + const text = (await fs.readFile(path.join(dir, txt), "utf8").catch(() => "")).trim(); + if (!text) { + return { + text: "", + note: "No transcript — whisper wrote an empty text file.", + failed: true, + }; + } + return { text, note: "", failed: false }; } /** One frame's bytes, for the API route that serves them. */ diff --git a/lib/server/services/runs.ts b/lib/server/services/runs.ts index 1349f9b..f12be85 100644 --- a/lib/server/services/runs.ts +++ b/lib/server/services/runs.ts @@ -39,6 +39,15 @@ export async function startRun(input: { inputs: Record; title?: string; sourceId?: string | null; + /* + * Who is asking, taken from the session by the controller. + * + * Not part of `inputs` and not read off the body for the same reason + * `brandVoice` is not: a caller who can name the author can name somebody + * else, and the one question this column exists to answer — who spent the + * API budget — is worthless if the answer is whatever the request said. + */ + userId?: string | null; }): Promise { const pack = await resolvePack(input.packSlug); if (!pack) throw new RunError(`No pack "${input.packSlug}"`, 400); @@ -94,6 +103,7 @@ export async function startRun(input: { brandVoice: workspace.brandVoice, inputs: input.inputs, sourceId: input.sourceId ?? null, + createdBy: input.userId ?? null, sections: pack.sections.map((s) => ({ id: s.id, title: s.title })), }); } @@ -105,9 +115,33 @@ export async function startRun(input: { * the same page, which is rude to the site and slow for you. Keyed by run and * URL together so a re-run after the URL is corrected does not serve the old * page back. Process-lifetime only: this is a cache, not a record. + * + * And bounded, because process lifetime is now measured in weeks: the API is a + * long-lived service, every run with a website_url leaves a whole page of text + * in here, and nothing ever took one out. A Map keeps insertion order, so the + * oldest key is the first one it yields — dropping that when the cap is passed + * is the whole eviction policy. Not LRU: a run's sections are written back to + * back and then the run is over, so age is what "no longer wanted" looks like + * here, and an access-ordered cache would only buy complexity. + * + * 200 is a couple of hundred pages — comfortably more than any burst of runs + * in flight at once, which is all this has to cover, and a few megabytes at + * worst rather than an unbounded number of them. */ +const SITE_CACHE_MAX = 200; const siteCache = new Map(); +const rememberSite = (key: string, block: string) => { + siteCache.set(key, block); + while (siteCache.size > SITE_CACHE_MAX) { + // `keys().next()` is the oldest insertion; a `while` rather than an `if` + // so a cap lowered in a later edit drains rather than leaking forever. + const oldest = siteCache.keys().next(); + if (oldest.done) break; + siteCache.delete(oldest.value); + } +}; + /** * What the model is allowed to answer from. * @@ -157,7 +191,7 @@ async function evidenceFor(run: Run): Promise { ].join("\n\n"); } - siteCache.set(key, block); + rememberSite(key, block); return block; } From 2b374d256b89265d947887d7040bad8db70a1436 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 19:21:56 +0600 Subject: [PATCH 02/30] Design the worker split, and settle the questions it raised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/WORKER.md | 660 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 docs/WORKER.md diff --git a/docs/WORKER.md b/docs/WORKER.md new file mode 100644 index 0000000..9ce88a2 --- /dev/null +++ b/docs/WORKER.md @@ -0,0 +1,660 @@ +# The worker + +Phase 1 design, not yet built. Nothing in this file exists in the tree — it is +here to be argued with before anybody writes it. + +## What moves, and why + +Today one process does everything. The NestJS API holds `driveRun`, and +`driveRun` calls `write()`, and `write()` spawns `claude` on the same machine +through `run()` in `lib/server/tools.ts` — as does the ingest, with `yt-dlp`, +`ffmpeg` and `whisper`. So the machine that answers HTTP must also have every +binary installed and a signed-in CLI on it. On a laptop that is exactly right. +On the VPS `docs/DEPLOYING.md` describes, it is why the model has to become an +API key, at real money per run, on a box with no GPU. + +The split: the **server** keeps the UI, the API, Postgres, and the frames and +transcripts — and keeps every decision. The **desktop app** (Tauri, one per +teammate's Windows machine) is a webview onto the hosted UI plus a Node +sidecar, the **worker**, which claims jobs over HTTPS and runs the local tools +under that person's own login and their own GPU. The worker opens outbound +connections only: no inbound port, no static IP, no hole in anyone's router. + +**The server builds the prompt and decides the order; the worker only +executes.** A job carries finished text and a command to run, never a pack, a +template, a rule or a dependency graph. That line is the whole point: business +logic that shipped to fifteen desktops would need fifteen updates to change a +prompt, and the prompt changes weekly. It also keeps `lib/server/prompt.ts`, +`lib/packs/` and the brand voice off machines that only need to run a binary. + +## What this does to the run driver + +`driveRun` is a loop that holds a process for the length of a run: pick the +next eligible section, `await writeSection`, repeat. `writeSection` blocks on +the model. That shape only works when the model is a child process of the +thing looping. + +It inverts. `driveRun` becomes **`advance(runId)`** — a short function that +looks at the run, finds every section whose dependencies are written, enqueues +a job for each that has none, and returns. It is called when Run is pressed and +again every time a job result lands. No process holds a run any more, so +nothing is lost when one restarts. + +Two consequences worth naming now: + +- `plan()` in `lib/server/prompt.ts` computes the dependency waves and has + never been called by anything. It becomes the real scheduler: everything in a + wave can be enqueued at once. Today's one-at-a-time was a property of the + model being one local process, not of the templates. +- Parallelism comes from **more workers**, not from a worker doing more. One + worker is one machine with one CLI login, so `workers.max_concurrency` + defaults to 1 (2 for a machine whose owner says so). Twelve sections across + four laptops is the win; twelve `claude` processes on one laptop is not. + +## The tables + +Style follows `lib/server/db/schema.ts` — an appended migration, text ids from +`id()`, `TIMESTAMPTZ ... DEFAULT now()`, jsonb where a join table would be +three files of plumbing for something nothing queries by. + +```sql + { + name: "0015_workers", + sql: ` + CREATE TABLE workers ( + id TEXT PRIMARY KEY, + -- Whose machine. Removing a person removes their sessions today; their + -- workers go the same way, because the CLI on that box is signed in as + -- them and the jobs it claims spend their subscription. + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- What the person calls it. "Shakhawat's desktop", not a hostname — + -- the machine picker is read by a human choosing where a run happens. + name TEXT NOT NULL, + -- sha256 of the enrolment token. The token itself is shown once, in the + -- desktop app, and is never in this table: a database dump must not be + -- a list of live credentials, the same reason API keys are encrypted. + token_hash TEXT NOT NULL UNIQUE, + platform TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL DEFAULT '', + -- What it can run: the ToolStatus array lib/server/tools.ts already + -- produces, whole. Probing is now the worker's job and this is the + -- report — the server has no machine to probe. + tools JSONB NOT NULL DEFAULT '[]'::jsonb, + -- The subset of those the owner has switched on. settings.enabled is a + -- global singleton today and cannot be: "ffmpeg is off" is a fact about + -- one machine. + enabled JSONB NOT NULL DEFAULT '[]'::jsonb, + -- Whether this machine's Claude CLI may be granted Read for frames. + -- The switch belongs to whoever owns the machine, not to the console. + can_read_frames BOOLEAN NOT NULL DEFAULT false, + max_concurrency INTEGER NOT NULL DEFAULT 1, + last_seen_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX workers_user ON workers(user_id); + CREATE INDEX workers_live ON workers(last_seen_at); + + CREATE TABLE jobs ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, -- write_section | ingest_source | test_brain + -- queued -> claimed -> done | failed | unroutable | cancelled + state TEXT NOT NULL DEFAULT 'queued', + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + -- What this job is FOR, so a result can be written back without the + -- payload having to be re-read. Nulled, never cascaded to the job's + -- deletion: a finished run's jobs are the only record of where the work + -- ran and how long it took. + run_id TEXT REFERENCES runs(id) ON DELETE CASCADE, + section_id TEXT, + source_id TEXT REFERENCES sources(id) ON DELETE CASCADE, + -- Tool ids, all of which a worker must advertise AND have enabled. + -- ["claude"] for a section; ["yt-dlp","ffmpeg","whisper"] for an ingest. + needs JSONB NOT NULL DEFAULT '[]'::jsonb, + -- Pin to one machine when the person chose one. Null means any worker + -- that qualifies. + wants_worker TEXT REFERENCES workers(id) ON DELETE SET NULL, + -- Everything the worker is told. Built by the server, opaque to the + -- worker beyond its own kind's shape. See "Job types". + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + -- What came back, kept for the same reason sources.research is kept: a + -- result that only ever reached a browser did not survive a reload. + result JSONB NOT NULL DEFAULT '{}'::jsonb, + error TEXT NOT NULL DEFAULT '', + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 2, + priority INTEGER NOT NULL DEFAULT 0, + worker_id TEXT REFERENCES workers(id) ON DELETE SET NULL, + -- The lease. A claimed job is this worker's until this passes, and the + -- reaper takes it back the moment it does. This column is what replaces + -- requeueOrphans. + lease_until TIMESTAMPTZ, + -- Past this, with nobody able to run it, the job fails with a sentence + -- naming the tool. Set at enqueue when a capable worker exists but is + -- offline; see "Capability matching". + wait_until TIMESTAMPTZ, + claimed_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + -- The claim query's index: state and priority, ordered by age. + CREATE INDEX jobs_claimable ON jobs(state, priority DESC, created_at) + WHERE state = 'queued'; + CREATE INDEX jobs_lease ON jobs(lease_until) WHERE state = 'claimed'; + CREATE INDEX jobs_run ON jobs(run_id); + -- One live job per section. A double Run, two tabs, or a stale poll must + -- not enqueue the same section twice and pay two subscriptions for it. + CREATE UNIQUE INDEX jobs_one_per_section ON jobs(run_id, section_id) + WHERE state IN ('queued', 'claimed'); + `, + }, +``` + +### The claim + +```sql +WITH claimed AS ( + SELECT id FROM jobs + WHERE state = 'queued' + AND (wants_worker IS NULL OR wants_worker = $1) + -- Everything this job needs, the worker has. $2 is the worker's enabled + -- tool ids as a jsonb array; ?& asks "are all of these keys present". + AND (SELECT bool_and(need IN (SELECT jsonb_array_elements_text($2::jsonb))) + FROM jsonb_array_elements_text(needs) AS need) + ORDER BY priority DESC, created_at + FOR UPDATE SKIP LOCKED + LIMIT $3 +) +UPDATE jobs j + SET state = 'claimed', + worker_id = $1, + attempts = attempts + 1, + claimed_at = now(), + lease_until = now() + ($4 || ' seconds')::interval, + updated_at = now() + FROM claimed c + WHERE j.id = c.id +RETURNING j.*; +``` + +`FOR UPDATE SKIP LOCKED` is doing one specific thing: it lets four laptops run +this query at the same millisecond and hand each of them different rows, +because a row another transaction has locked is skipped rather than waited for. +Without it, two workers select the same job and both write it. + +**What it replaces.** Two things in the current code, both of which are the +same bug — coordination held in one process's memory: + +- The `globalThis` Set in `lib/server/services/runs.ts`. `isDriving(runId)` is + the only thing stopping two drivers writing the same run, and it can only see + what this Node process is doing. It was correct when the model was one local + child process; with two API replicas, or an API and a worker, it is a lock + that does not lock. The unique partial index above and the lease are the + database saying the same thing where every process can hear it. +- `resumeOrphans()` in `api/src/main.ts`, which calls `requeueOrphans()` with + **no run id** — a global `UPDATE run_sections SET state = 'queued' WHERE + state = 'writing'`. Its comment reasons "every writing row belongs to a + driver that died with the last process", and today that is true, because + there is only ever one process. The moment work lives on somebody else's + machine it is false: restart the API — a deploy, a crash, `docker compose + up` — and it requeues a section a laptop is at that moment four minutes into + writing. The section gets written twice, the second result overwrites the + first, and both spent a subscription. With more than one API replica it is + worse: every boot steals every other replica's in-flight work. Boot must stop + touching `writing` rows at all. Expiry does it instead, per job, on evidence + (a lease that ran out) rather than on an assumption about processes. + +`requeueFailed` stays as it is. It answers a different question — "the person +pressed Retry" — and is still the right thing. + +## Job types + +Every payload is built by the server. A worker never sees a pack, a rule, a +brand voice or a dependency; it sees text and a command. + +### `write_section` + +The server has already done all of it: resolved the pack, checked the +dependencies, gathered the finished upstream sections, fetched the website or +the source brief through `evidenceFor()`, and run `systemPrompt()` and +`userPrompt()`. What travels is their output. + +```jsonc +// payload +{ + "brain": "claude-cli", // which BrainDef, chosen by the server + "transport": "cli", // decided by the server, not re-derived + "system": "…6–8k characters…", // systemPrompt(pack, inputs, brandVoice) + "user": "…evidence, inputs, ALREADY GENERATED blocks, the task…", + "tier": "high", // cheap | standard | high — drives effort + "timeoutMs": 600000, + "frames": [ // absent unless the section reads stills + { "url": "/api/sources/src_x/frames/frame-01.jpg", "at": 3.4 } + ], + "allowFrameRead": true // may Claude be granted Read for this call +} +``` + +```jsonc +// result +{ "text": "## Section title\n…", "ms": 42311, "using": "claude-opus-5" } +``` + +The worker downloads any frames, calls exactly the `viaClaudeCli` / +`viaCodexCli` / `viaGeminiCli` / `viaOllama` function that exists today — +moved, not rewritten — and returns the text. The server writes it with +`setSection`, then calls `advance(runId)`. + +Three things about that `transport` field, all of them load-bearing: + +- **The server decides, the worker obeys or refuses.** `write()` currently + re-derives the transport by calling `brainStatuses()`, which calls + `toolStatuses()`, which probes *this machine*. On the server that probe is + now meaningless. The worker reports its tools at registration; the server + picks from what it was told and says so in the payload. +- **A worker that cannot honour the payload fails the job.** It must never + quietly answer some other way. `docs/DECISIONS.md` already names this outcome + the one worse than an error: a website "identified" from a transcript, + presented as though the frames had been read. A silent CLI-to-API downgrade + on a worker is the same failure wearing a different hat. +- **`allowFrameRead` is policy, enforced at execution.** The server sends it + from `workers.can_read_frames`; the worker checks its own switch again before + granting Claude `Read`. Two checks because the grant happens on the worker's + filesystem and the person who owns that filesystem gets the last word. + +`ms` comes back from the worker. Today `writeSection` measures wall clock +around the model call; measured on the server it would now include queue wait, +so a section would report six minutes because a laptop was shut. + +### `ingest_source` + +```jsonc +// payload +{ "sourceId": "src_x", "url": "https://…", "refresh": false, + "frameCount": 8, "maxHeight": 720, "whisperModel": "base", + "uploadTo": "/api/workers/sources/src_x" } +``` + +```jsonc +// result +{ "title": "…", "uploader": "…", "description": "…", "duration": 41, + "thumbnail": "…", "meta": { /* yt-dlp's whole --dump-single-json */ }, + "transcript": "…", "frames": [{ "at": 2.6, "file": "frame-01.jpg" }], + "error": "Metadata only — …" } +``` + +`needs` is `["yt-dlp","ffmpeg","ffprobe","whisper"]` minus whatever the ingest +is willing to do without. The current ingest degrades rather than failing when +a tool is missing, and that should survive: `needs: ["yt-dlp"]` with the rest +as a soft preference, so a machine without whisper can still fetch a caption. +The frame JPEGs and the video go up before the result is posted (see +**Frames**); the result names files the server already has. + +An upload — `ingestFile` — is the one ingest that does **not** become a job in +Phase 1. The bytes are already arriving at the server from the browser, and +sending them back out to a laptop to cut frames doubles the transfer. It keeps +needing `ffmpeg` on the server, or it becomes a job in Phase 2 by having the +desktop app upload straight to a local worker. Say which; do not leave it +implied. + +### `test_brain` + +The smallest job, and the one that makes the whole thing legible: it is how a +person finds out whether *that machine* can write. + +```jsonc +// payload +{ "brain": "codex-cli", "transport": "cli", + "system": "You are a terse assistant. Answer in one short line, no preamble.", + "user": "Reply with exactly: BRAIN OK", "tier": "cheap", "timeoutMs": 150000 } +``` + +```jsonc +// result +{ "ok": true, "ms": 2140, "reply": "BRAIN OK", "error": "", "transport": "cli" } +``` + +`fixFor()` in the integrations controller stays on the server — it is a lookup +from an error string to a sentence — but every sentence it returns now needs a +machine in it. "Run `codex login` in a terminal" is still the right advice and +is now advice about a terminal on a machine the reader may not be sitting at. + +## Capability matching + +A worker advertises `tools` (probed) and `enabled` (its owner's switches). A +job declares `needs`. The claim query intersects them, so a job simply is not +visible to a worker that cannot run it. That much is easy. The hard part is the +job nobody can run, and it has three distinct cases which must not be one: + +1. **No worker has ever advertised this tool.** Fail the job at enqueue — + `state = 'unroutable'`, `error` naming the tool and what installs it (the + `install` string is already on `ToolStatus`). The section goes to `failed` + with that message, so it shows on the row as Failed and Retry is offered. + Not queued: a queue entry that can never be claimed is a run that reads + "Writing" forever, which is precisely the six-minutes-on-Writing-01 bug + `docs/DECISIONS.md` records, rebuilt at a larger scale. +2. **A capable worker exists but is not live.** Queue it, set `wait_until = + now() + 15 minutes`, and say so on the page: *waiting for Shakhawat's + desktop*. Somebody opening their laptop is the normal resolution and it + should just start. Past `wait_until` the reaper fails it, naming the machine + it was waiting for. +3. **A capable worker is live and busy.** Ordinary queueing. Nothing to say + beyond position. + +The rule underneath all three: **a job that cannot run must reach a terminal +state, visibly, and must never look successful.** `statusOf` derives a run's +status from its sections, so anything that leaves a section in `queued` forever +is invisible by construction. + +## The worker HTTP protocol + +All under `/api/workers`, all outbound from the worker, all authenticated with +`Authorization: Bearer ` and a `WorkerGuard` beside the existing +`SessionGuard` in `api/src/common/session.guard.ts`. The guard hashes the +bearer token, looks up the row, puts the worker on the request, and touches +`last_seen_at`. `@Public()` does not apply; a third decorator, `@WorkerRoute()`, +marks these and makes `SessionGuard` stand aside. + +**Why not the session cookie.** Four reasons, each on its own sufficient: + +- A cookie is a browser mechanism. The worker is a Node process; it would have + to scrape one out of the webview and forge a `Origin`/`SameSite` story that + the CORS config deliberately narrows to `WEB_ORIGIN`. +- Sessions expire and are deleted. Settings → Team removes someone by deleting + their sessions — correct for a browser, wrong as the only lever for a machine + (and `workers.user_id ... ON DELETE CASCADE` is the machine's version of the + same act). +- A session is a credential over the *entire console*: every run, every + template, every workspace, and the ability to spend model budget. A worker + needs six endpoints. A stolen worker token should not be able to read the + content library. +- A worker is long-lived and headless. It cannot be sent to `/login?next=…`, + which is what `lib/api-json.ts` does with every 401. + +The token is minted once, in Settings → Machines, shown once, stored as a +sha256 hash, and pasted into the desktop app on that machine. Revoking it is +deleting the row. + +| | | | +|---|---|---| +| **Register** | `POST /api/workers/register` | `{ name, platform, version, tools: ToolStatus[], enabled: string[], canReadFrames, maxConcurrency }` → `{ workerId, heartbeatMs, claimMs }`. Idempotent on the token: the same machine re-registering after an update replaces its tool report rather than creating a second row. Re-registration is also how a newly installed `whisper` becomes visible — this is what replaces `toolStatuses(force)`. | +| **Claim** | `POST /api/workers/claim` | `{ max: 1 }` → `{ jobs: [ { id, kind, payload, leaseUntil } ] }`. Long-polls: holds up to 25s if the queue is empty, then answers `{ jobs: [] }`. Long-poll rather than a socket because it is one code path, survives every proxy, and keeps latency to about a second without anything stateful in between. `202` with an empty list is not an error and must not be logged as one. | +| **Heartbeat** | `POST /api/workers/heartbeat` | `{ jobIds: ["job_x"], progress?: "…" }` → `{ keep: ["job_x"], drop: ["job_y"] }`. Extends the lease on each job it still holds. `drop` is how a worker learns a job was cancelled or reaped: it kills the child process rather than finishing work nobody wants. Every 15s. | +| **Result** | `POST /api/workers/jobs/:id/result` | `{ result }` → `{ ok: true }`. Refused with `409` if the job's `worker_id` is not this worker or its lease has expired — a laptop that wakes from sleep and posts a section that was reassigned twenty minutes ago must not overwrite the one that actually landed. | +| **Fail** | `POST /api/workers/jobs/:id/fail` | `{ error, retryable }` → `{ ok: true }`. `retryable: false` for a refusal, a bad payload, a missing tool; the server does not spend another attempt on those. | +| **Frames** | `POST /api/workers/sources/:id/frames` | `multipart`, one file per part, `file` field matching `FRAME_FILE`. Streamed to disk with the cap and the counter `writeCapped` already uses. `PUT /api/workers/sources/:id/transcript` takes the text. | +| **Download** | `GET /api/sources/:id/frames/:file` | Unchanged, and reachable with a worker token as well as a session — this is how a `write_section` job fetches the stills it was handed. | + +## Liveness and recovery + +- **Lease.** A claim sets `lease_until = now() + 90s`. A heartbeat every 15s + extends it. Ninety seconds is six missed heartbeats: long enough that a + Windows machine paging in a large ffmpeg does not lose its job, short enough + that a closed laptop is noticed before anyone reloads the page twice. +- **Reaper.** A `setInterval` in the API, every 15s: + `UPDATE jobs SET state = 'queued', worker_id = NULL, lease_until = NULL + WHERE state = 'claimed' AND lease_until < now()` — and the same pass fails + anything at `attempts >= max_attempts` or past `wait_until`. The query is + atomic and idempotent, so running it in two API replicas at once is harmless. + This is the only thing that ever moves a job out of `claimed`, and it does it + on evidence — a lease that ran out — rather than on the assumption `boot ⇒ + nothing is running` that `resumeOrphans` currently makes. +- **A laptop closed mid-section.** The heartbeat stops, the lease expires + inside 90 seconds, the job goes back to `queued`, another capable worker + claims it, `attempts` is now 2. The section stays `writing` throughout — it + is being written, just by somebody else now — and the page shows which + machine. Half a section is not saved; a `write_section` result arrives whole + or not at all. +- **Attempts.** `max_attempts` 2 by default. One retry covers the closed + laptop and the dropped connection, which are the failures that recur; a + prompt the model refuses will refuse it again, and the third attempt is money + spent to learn nothing. On exhaustion the section goes `failed` with the last + error, exactly as today, and Retry — `requeueFailed` — is the way back. +- **Boot.** `resumeOrphans()` is deleted from `api/src/main.ts`. Migration + `0015` should sweep existing `writing` rows to `queued` **once**, in the + migration, because those really are orphans of the old world. After that, + nothing at boot touches a section's state. + +## Frames + +The server is the single source of truth for frames and transcripts, and this +is not negotiable. Four reasons: + +- The browser renders frames from `GET /api/sources/:id/frames/:file`. If they + live on a laptop, the picker is blank whenever that laptop is shut. +- The worker that ingests and the worker that writes are usually different + machines, and the researcher's HISTORY panel re-opens an answer with the + frames it was read from already ticked — months later, from any machine. +- Ingest is expensive on purpose. `sources` exists so a second look does not + pay yt-dlp, ffmpeg and whisper twice. Per-machine copies mean paying per + machine. +- Desktops are not backed up. `sourcedata` on the server is. + +So: **ingest uploads, write downloads.** + +The ingesting worker runs the pipeline into a temp directory, posts each JPEG +to `POST /api/workers/sources/:id/frames` and the transcript to its endpoint, +then posts the result naming the files. The server re-checks every filename +against `FRAME_FILE` — those names now arrive over the network, and the regex +that was guarding a query string is now guarding a write. + +A `write_section` job whose payload carries `frames` downloads them, in order, +to a temp directory of its own, then hands the CLI **local absolute paths**, +because none of the three CLIs takes bytes and each takes a file differently: + +- **Claude** — the paths are listed in the prompt and `Read` is granted for + that one call, gated on `allowFrameRead`. +- **Codex** — `-i ` repeated per frame, one flag per picture, never a + list (a list swallows the `-` that says the prompt is on stdin). +- **Gemini** — `@` references inside the `-p` prompt, plus + `--include-directories` naming the temp directory so a path outside the + CLI's cwd is allowed at all. + +The temp directory goes in a `finally`, whether the call worked or not, and it +must not be `cliHome()` — that directory is deliberately empty so the CLI has +nothing to find, and filling it with JPEGs after the run undoes the reasoning +in `docs/DECISIONS.md`. + +**One thing to fix while doing this.** `sourceBrief()` ends the frames line +with "They are on disk under `.data/sources//`." On a worker that sentence +is false — the frames are in a temp directory under a different name, on a +different machine — and it is also the server's filesystem layout being handed +to a model on somebody's desktop. The prompt should say how many frames there +are and at what seconds, and the paths should reach the CLI only through the +per-transport mechanisms above, which already name them correctly. + +## What this breaks in the UI + +`IntegrationsView` and `api/src/integrations/integrations.controller.ts` are +built on a premise that stops being true: that "this machine" is a machine the +server can probe. `wiring()` calls `toolStatuses()` and `brainStatuses()`, +which spawn `claude --version` and `yt-dlp --version` **in the API process**. +On the server those return nothing installed, forever, and the page becomes a +list of red rows about a box nobody uses. + +The change: + +- **A machine picker at the top of the page.** "This machine" becomes "which + machine?", and the answer is a row from `workers`, with its owner, its + platform, and how long ago it was seen. The desktop app can default it to the + worker running beside it, which is the common case and makes the page feel + local again. +- **Tool status is read, not probed.** The rows render `workers.tools` — the + same `ToolStatus` shape, so the component barely changes — with a *last + seen* line instead of a live spinner. "Re-check" stops meaning "spawn five + processes" and starts meaning "ask that worker to probe again": enqueue a + probe, or simply have the worker re-register, which it does on a timer + anyway. `?recheck=1` and the 60s cache in `tools.ts` both belong to the + worker now. +- **Test brain becomes a `test_brain` job.** The button already runs for up to + 150 seconds and already renders "running"; what changes is that the result + arrives by polling the job rather than from the POST's own response, and the + panel names the machine that answered. A model that works on one teammate's + laptop and not another's is now a thing the page can say, and today it cannot + express the question. +- **A worker that is offline is not a broken tool.** The distinction the page + has to draw, in its own words: *`whisper` is not installed on that machine* + versus *that machine has not been seen since Tuesday*. One is an install + command, the other is "open your laptop". + +### `settings.brain` is a singleton and must be scoped + +`lib/server/repos/settings.ts` is a key/value table with one row per key and no +scope column at all. So `brain`, `enabled` and `cliCanReadFrames` are one value +for the whole console. On a laptop that was right — one console, one person, +one machine. On a server with four teammates it is three separate wrong things: + +- **`brain` → workspace.** Which model writes is an editorial decision about + the content, not about hardware: the workspace that writes Bangla scripts and + the one writing SEO copy may reasonably want different ones, and neither + should change under the other because somebody switched a global. Put it on + `workspaces` — it sits naturally beside `brand_voice`, which is scoped for + exactly this reason and read by `startRun` from the workspace rather than the + request. +- **`enabled` → worker.** "yt-dlp is switched off" is a statement about a + machine. It is `workers.enabled` above, and `allowed()` in `ingest.ts` — which + today ANDs `toolStatus(id).present` with the global `settings.enabled` — is + evaluated by the worker against its own row. +- **`cliCanReadFrames` → worker.** It grants the `Read` tool on one specific + filesystem. Whoever owns that filesystem owns the switch; a global that lets + a teammate grant file access on your desktop is not a setting, it is a hole. + Note it defaults **on** today, and `workers.can_read_frames` above defaults + **off**: enrolling a machine should not silently carry over a permission + granted on a different one. + +The rest of `Settings` — `quality`, `autoApprove`, `reduceMotion` — is fine +where it is, or wants to be per-user, which is a separate argument. + +## Decisions + +The six questions above, answered. Each is settled unless somebody argues it +down with a reason the answer does not already cover. + +### The API-key fallback does not happen by itself + +`viaAnthropic` / `viaOpenAI` / `viaGemini` stay on the server, and the server +never reaches for them on its own. A workspace carries `api_fallback BOOLEAN +NOT NULL DEFAULT false`; while it is false, a job with no capable worker takes +the routing above — `unroutable`, or queued behind `wait_until` — and says so. + +The reason is the whole project. This split exists so a run costs a +subscription somebody already pays for rather than money per token. A fallback +that fires when nobody's laptop is open would spend that money precisely when +nobody is watching, which is the failure `docs/DEPLOYING.md` warns about with +"the reason to keep an eye on who runs what". Off by default is not caution +here, it is the feature. + +It stays available because "everyone has gone home and this has to ship +tonight" is a real evening. Turning it on is a deliberate act, per workspace, +by someone who knows what it costs. + +`run_sections` gains `wrote_with TEXT NOT NULL DEFAULT ''` — the worker's name, +or `server:api`. Without it "which sections did we pay for" is unanswerable, +and `created_by` from migration `0014` only says who pressed the button. + +### Any worker may serve any workspace — but the column exists now + +`workers` gains `workspace_ids JSONB NOT NULL DEFAULT '[]'::jsonb`, where empty +means every workspace. Today it will be empty on every row. + +Narrowing it now would contradict a decision that is already made and written +down: there are no roles, and everyone who is in can read and change +everything. Building a permission the console has nowhere else would be one +team's answer imposed through a schema. + +But the question in the sketch is a real one — a client's unreleased footage +reaching a contractor's laptop is a different kind of event from a teammate +reading a template — and the day it matters the answer must not be a +migration, a backfill and a claim-query rewrite under time pressure. An empty +array costs nothing and turns that day into one UPDATE and one `AND` in the +claim. + +### Cancellation ships with Phase 1, server-side + +`heartbeat` already returns `drop`; the worker already has to handle it. On the +server a cancel is one `UPDATE jobs SET state = 'cancelled' WHERE run_id = $1 +AND state IN ('queued','claimed')`, and the next heartbeat tells whoever was +running it to kill the child. + +It ships because the alternative is worse than the work. Deleting a run whose +sections are queued would otherwise leave jobs pointing at a row that is gone, +and a job waiting on an offline machine has no way to stop before `wait_until`. +Both are states somebody hits in the first week. + +The UI is one Stop button on a run that is writing, and `deleteRun` cancels +first. Anything richer — cancelling a single section — waits. + +### Jobs are kept; their prompts are not + +The row lives forever. A day after `finished_at`, a sweep clears `payload` and +the bulky half of `result`, keeping `kind`, `worker_id`, `attempts`, the +timings, `result.ms` and `result.using`. + +A `write_section` payload is six to eight thousand characters of system prompt +that `systemPrompt()` will rebuild identically on demand, and its `result.text` +is already the section — `run_sections` holds the copy that is read. Keeping +both makes `jobs` several times the size of the content it describes, for no +question it can answer that something else cannot. + +What only `jobs` can answer is where work ran, how long it took, how often it +had to be retried and on whose machine — the operational record this console +has never had. That part is small and it stays. + +The sweep runs in the reaper's interval. One more `UPDATE` in a pass that is +already atomic and already idempotent. + +### The desktop app holds two credentials, on purpose + +The webview signs in as a person, with the session cookie, exactly as a browser +does. The worker holds its own bearer token. They are not the same principal +and must not be merged. + +Everything in **The worker HTTP protocol** above argues this: different +lifetime, different blast radius, different revocation. A single credential +that is both would be a session that survives Team → Remove, or a worker token +that can read the whole content library — one of the two, depending on which +side won. + +The confusion is a real cost and it is paid once. Settings → Machines mints the +token, shows it once, and the desktop app stores it in the OS keychain +(`tauri-plugin-stronghold`, or DPAPI on Windows). Nobody types it twice, and +nobody keeps it in a file next to the app. + +### Uploads stay on the server, and whisper stops needing the video + +`ingestFile` keeps running where the bytes already are. `ffmpeg` is a real +dependency of the API image — it already is, in `api/Dockerfile` — and cutting +eight stills out of a file that is already on local disk is cheap. + +`whisper` does **not** become a server dependency, because transcription splits +off into its own job: + +- **`ingest_source`** (worker): yt-dlp, the frames, and a 16 kHz mono WAV. + Uploads all three. +- **`transcribe_audio`** (worker, `needs: ["whisper"]`): downloads **the WAV**, + transcribes it, returns text. + +For an upload the server does the ffmpeg half itself — frames and the WAV — and +enqueues `transcribe_audio` like any other. + +This falls out of three things agreeing. Audio is roughly a thirtieth of the +video, so the round trip the sketch rejected stops being a round trip worth +rejecting: a megabyte a minute, not four hundred megabytes. Transcription is +the one step that wants a GPU and is therefore the one step most worth sending +to a desktop. And `whisper.cpp` — which is what a one-click installer can +actually put on a non-developer's Windows machine, where +`pip install openai-whisper` cannot — takes 16 kHz mono WAV as input anyway, so +the conversion is not overhead invented here, it is a step that had to happen +somewhere. + +It also means a machine with no whisper at all still ingests: `needs` on +`ingest_source` never includes it. + +## Still open + +- **Which model a `test_brain` job uses when the workspace names one the + machine cannot reach.** Probably: test what was asked for and report the + refusal, since that is the question being asked. Not settled. +- **Whether `quality`, `autoApprove` and `reduceMotion` become per-user.** A + separate argument from this one, and nothing here forces it. From 05c97af11f605b6e07b87309cdcf3aff51bedac7 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 19:30:25 +0600 Subject: [PATCH 03/30] Give the queue somewhere to live, and stop naming the server's disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//`. 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) --- api/tsconfig.json | 9 +- docs/WORKER.md | 254 +++++++++----- lib/server/db/schema.ts | 183 +++++++++++ lib/server/repos/jobs.ts | 601 ++++++++++++++++++++++++++++++++++ lib/server/repos/workers.ts | 305 +++++++++++++++++ lib/server/services/ingest.ts | 28 +- 6 files changed, 1288 insertions(+), 92 deletions(-) create mode 100644 lib/server/repos/jobs.ts create mode 100644 lib/server/repos/workers.ts diff --git a/api/tsconfig.json b/api/tsconfig.json index f8f2ebc..f50cfd4 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -1,6 +1,12 @@ { "compilerOptions": { - "module": "commonjs", + // "nodenext", not "node16": NestJS 12 is ESM-only ("type": "module", no + // require export). node16 rejects importing it from this CommonJS build + // (TS1479); nodenext models Node's require(esm) support, which is how it + // already works at runtime. The emit stays CommonJS — this package has no + // "type": "module" — so ts-node, nest and dist/ are unaffected. + "module": "nodenext", + "moduleResolution": "nodenext", "target": "es2022", "lib": ["es2023"], "types": ["node"], @@ -9,7 +15,6 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "moduleResolution": "node", "resolveJsonModule": true, "sourceMap": true, "baseUrl": "..", diff --git a/docs/WORKER.md b/docs/WORKER.md index 9ce88a2..e60bce7 100644 --- a/docs/WORKER.md +++ b/docs/WORKER.md @@ -61,92 +61,165 @@ three files of plumbing for something nothing queries by. { name: "0015_workers", sql: ` - CREATE TABLE workers ( - id TEXT PRIMARY KEY, - -- Whose machine. Removing a person removes their sessions today; their - -- workers go the same way, because the CLI on that box is signed in as - -- them and the jobs it claims spend their subscription. - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - -- What the person calls it. "Shakhawat's desktop", not a hostname — - -- the machine picker is read by a human choosing where a run happens. - name TEXT NOT NULL, - -- sha256 of the enrolment token. The token itself is shown once, in the - -- desktop app, and is never in this table: a database dump must not be - -- a list of live credentials, the same reason API keys are encrypted. - token_hash TEXT NOT NULL UNIQUE, - platform TEXT NOT NULL DEFAULT '', - version TEXT NOT NULL DEFAULT '', - -- What it can run: the ToolStatus array lib/server/tools.ts already - -- produces, whole. Probing is now the worker's job and this is the - -- report — the server has no machine to probe. - tools JSONB NOT NULL DEFAULT '[]'::jsonb, - -- The subset of those the owner has switched on. settings.enabled is a - -- global singleton today and cannot be: "ffmpeg is off" is a fact about - -- one machine. - enabled JSONB NOT NULL DEFAULT '[]'::jsonb, - -- Whether this machine's Claude CLI may be granted Read for frames. - -- The switch belongs to whoever owns the machine, not to the console. - can_read_frames BOOLEAN NOT NULL DEFAULT false, - max_concurrency INTEGER NOT NULL DEFAULT 1, - last_seen_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ); - CREATE INDEX workers_user ON workers(user_id); - CREATE INDEX workers_live ON workers(last_seen_at); - - CREATE TABLE jobs ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, -- write_section | ingest_source | test_brain - -- queued -> claimed -> done | failed | unroutable | cancelled - state TEXT NOT NULL DEFAULT 'queued', - workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, - -- What this job is FOR, so a result can be written back without the - -- payload having to be re-read. Nulled, never cascaded to the job's - -- deletion: a finished run's jobs are the only record of where the work - -- ran and how long it took. - run_id TEXT REFERENCES runs(id) ON DELETE CASCADE, - section_id TEXT, - source_id TEXT REFERENCES sources(id) ON DELETE CASCADE, - -- Tool ids, all of which a worker must advertise AND have enabled. - -- ["claude"] for a section; ["yt-dlp","ffmpeg","whisper"] for an ingest. - needs JSONB NOT NULL DEFAULT '[]'::jsonb, - -- Pin to one machine when the person chose one. Null means any worker - -- that qualifies. - wants_worker TEXT REFERENCES workers(id) ON DELETE SET NULL, - -- Everything the worker is told. Built by the server, opaque to the - -- worker beyond its own kind's shape. See "Job types". - payload JSONB NOT NULL DEFAULT '{}'::jsonb, - -- What came back, kept for the same reason sources.research is kept: a - -- result that only ever reached a browser did not survive a reload. - result JSONB NOT NULL DEFAULT '{}'::jsonb, - error TEXT NOT NULL DEFAULT '', - attempts INTEGER NOT NULL DEFAULT 0, - max_attempts INTEGER NOT NULL DEFAULT 2, - priority INTEGER NOT NULL DEFAULT 0, - worker_id TEXT REFERENCES workers(id) ON DELETE SET NULL, - -- The lease. A claimed job is this worker's until this passes, and the - -- reaper takes it back the moment it does. This column is what replaces - -- requeueOrphans. - lease_until TIMESTAMPTZ, - -- Past this, with nobody able to run it, the job fails with a sentence - -- naming the tool. Set at enqueue when a capable worker exists but is - -- offline; see "Capability matching". - wait_until TIMESTAMPTZ, - claimed_at TIMESTAMPTZ, - finished_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ); - -- The claim query's index: state and priority, ordered by age. - CREATE INDEX jobs_claimable ON jobs(state, priority DESC, created_at) - WHERE state = 'queued'; - CREATE INDEX jobs_lease ON jobs(lease_until) WHERE state = 'claimed'; - CREATE INDEX jobs_run ON jobs(run_id); - -- One live job per section. A double Run, two tabs, or a stale poll must - -- not enqueue the same section twice and pay two subscriptions for it. - CREATE UNIQUE INDEX jobs_one_per_section ON jobs(run_id, section_id) - WHERE state IN ('queued', 'claimed'); +CREATE TABLE workers ( + id TEXT PRIMARY KEY, + -- Whose machine. Removing a person removes their sessions today; their + -- workers go the same way, because the CLI on that box is signed in as + -- them and the jobs it claims spend their subscription. + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- What the person calls it. "Shakhawat's desktop", not a hostname — + -- the machine picker is read by a human choosing where a run happens. + name TEXT NOT NULL, + -- sha256 of the enrolment token. The token itself is shown once, in the + -- desktop app, and is never in this table: a database dump must not be + -- a list of live credentials, the same reason API keys are encrypted. + token_hash TEXT NOT NULL UNIQUE, + platform TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL DEFAULT '', + -- What it can run: the ToolStatus array lib/server/tools.ts already + -- produces, whole. Probing is now the worker's job and this is the + -- report — the server has no machine to probe. + tools JSONB NOT NULL DEFAULT '[]'::jsonb, + -- The subset of those the owner has switched on. settings.enabled is a + -- global singleton today and cannot be: "ffmpeg is off" is a fact about + -- one machine. + enabled JSONB NOT NULL DEFAULT '[]'::jsonb, + -- Which workspaces this machine may serve. EMPTY MEANS ALL, and today + -- it is empty on every row: there are no roles in this console, and + -- inventing one here would be one team's answer imposed through a + -- schema. The column exists anyway because the day a client's + -- unreleased footage must not reach a contractor's laptop, the answer + -- has to be one UPDATE and one AND in the claim query — not a + -- migration, a backfill and a rewrite of the most important query in + -- the system, under time pressure. + workspace_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + -- Whether this machine's Claude CLI may be granted Read for frames. + -- The switch belongs to whoever owns the machine, not to the console. + -- Note the global it replaces defaults ON and this defaults OFF: + -- enrolling a machine must not silently carry over file access somebody + -- granted on a different one. + can_read_frames BOOLEAN NOT NULL DEFAULT false, + -- One machine is one CLI login, so one job at a time. Parallelism is + -- meant to come from more laptops; twelve claude processes on one + -- laptop is not the win, twelve sections across four laptops is. + max_concurrency INTEGER NOT NULL DEFAULT 1, + last_seen_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX workers_user ON workers(user_id); +CREATE INDEX workers_live ON workers(last_seen_at); + +CREATE TABLE jobs ( + id TEXT PRIMARY KEY, + -- write_section | ingest_source | transcribe_audio | test_brain. + -- transcribe_audio is its own kind because whisper is the one step that + -- wants a GPU and the one dependency the server should not carry: an + -- upload's frames are cut where the bytes already are, and only the + -- 16 kHz WAV — a megabyte a minute, not four hundred — goes out. + kind TEXT NOT NULL, + -- queued -> claimed -> done | failed | unroutable | cancelled + state TEXT NOT NULL DEFAULT 'queued', + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + -- What this job is FOR, so a result can be written back without the + -- payload having to be re-read. + run_id TEXT REFERENCES runs(id) ON DELETE CASCADE, + section_id TEXT, + source_id TEXT REFERENCES sources(id) ON DELETE CASCADE, + -- Tool ids, all of which a worker must advertise AND have enabled. + -- ["claude"] for a section; ["yt-dlp"] for an ingest, which degrades + -- rather than failing when ffmpeg or whisper are missing. + needs JSONB NOT NULL DEFAULT '[]'::jsonb, + -- Pin to one machine when the person chose one. Null means any worker + -- that qualifies. + wants_worker TEXT REFERENCES workers(id) ON DELETE SET NULL, + -- Everything the worker is told. Built by the server, opaque to the + -- worker beyond its own kind's shape. Emptied by the payload sweep a + -- day after the job finishes; see sweepPayloads in repos/jobs.ts. + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + -- What came back, kept for the same reason sources.research is kept: a + -- result that only ever reached a browser did not survive a reload. + result JSONB NOT NULL DEFAULT '{}'::jsonb, + error TEXT NOT NULL DEFAULT '', + attempts INTEGER NOT NULL DEFAULT 0, + -- One retry, because one retry covers the closed laptop and the dropped + -- connection — the failures that recur — and a prompt the model refuses + -- will refuse it again, so a third attempt is money spent to learn + -- nothing. + max_attempts INTEGER NOT NULL DEFAULT 2, + priority INTEGER NOT NULL DEFAULT 0, + worker_id TEXT REFERENCES workers(id) ON DELETE SET NULL, + -- The lease. A claimed job is this worker's until this passes, and the + -- reaper takes it back the moment it does. This column is what replaces + -- requeueOrphans. + lease_until TIMESTAMPTZ, + -- Past this, with nobody having picked it up, the job fails with a + -- sentence naming the machine it was waiting for. Set at enqueue when a + -- capable worker exists but is not live; see "Capability matching". + wait_until TIMESTAMPTZ, + claimed_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + -- Stamped by the payload sweep, so a job it has already emptied is + -- cheap to skip and is distinguishable from one whose payload really + -- was {}. + swept_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +-- The claim query's index: state and priority, ordered by age. +CREATE INDEX jobs_claimable ON jobs(state, priority DESC, created_at) + WHERE state = 'queued'; +CREATE INDEX jobs_lease ON jobs(lease_until) WHERE state = 'claimed'; +CREATE INDEX jobs_run ON jobs(run_id); +-- The payload sweep runs inside the reaper's 15s interval, so it must not +-- be a sequential scan of every job this console has ever run. +CREATE INDEX jobs_sweep ON jobs(finished_at) + WHERE finished_at IS NOT NULL AND swept_at IS NULL; +-- One live job per section. A double Run, two tabs, or a stale poll must +-- not enqueue the same section twice and pay two subscriptions for it. +-- Postgres treats NULLs as distinct, so ingests and brain tests — which +-- have no section — are untouched by this. +CREATE UNIQUE INDEX jobs_one_per_section ON jobs(run_id, section_id) + WHERE state IN ('queued', 'claimed'); + +-- Which machine wrote each section, or 'server:api' when the workspace's +-- API-key fallback did it. created_by from 0014 only says who pressed the +-- button; without this, "which sections did we actually pay money for" +-- has no answer anywhere in the database. +ALTER TABLE run_sections ADD COLUMN wrote_with TEXT NOT NULL DEFAULT ''; + +-- The API-key fallback, off. viaAnthropic and friends stay on the server +-- and the server never reaches for them on its own: this whole split +-- exists so a run costs a subscription somebody already pays for, and a +-- fallback that fired when nobody's laptop was open would spend real +-- money precisely when nobody was watching. Turning it on is a deliberate +-- act, per workspace, by somebody who knows what it costs. +ALTER TABLE workspaces ADD COLUMN api_fallback BOOLEAN NOT NULL DEFAULT false; + +/* + * A ONE-TIME sweep, and the last one there will ever be. + * + * Every run_sections row that says 'writing' at this moment belongs to a + * driver that died with a process on this machine, because until this + * migration there was only ever one process and it held the run in its + * own memory. So these really are orphans of the old world, and putting + * them back in the queue is simply the truth about them. + * + * Nothing at boot may ever do this again. resumeOrphans() in + * api/src/main.ts made exactly this UPDATE on every start, reasoning that + * a 'writing' row must be dead because its driver was. Once the work runs + * on somebody else's laptop that reasoning is false: a deploy or a crash + * would requeue a section a machine is four minutes into writing, the + * section gets written twice, the second result overwrites the first, and + * both spent a subscription. With two API replicas it is worse — every + * boot steals the other replica's in-flight work. + * + * From here jobs.lease_until decides, per job, on evidence — a lease that + * ran out — instead of on the assumption that a boot means nothing is + * running. + */ +UPDATE run_sections SET state = 'queued', updated_at = now() + WHERE state = 'writing'; `, }, ``` @@ -160,8 +233,13 @@ WITH claimed AS ( AND (wants_worker IS NULL OR wants_worker = $1) -- Everything this job needs, the worker has. $2 is the worker's enabled -- tool ids as a jsonb array; ?& asks "are all of these keys present". - AND (SELECT bool_and(need IN (SELECT jsonb_array_elements_text($2::jsonb))) - FROM jsonb_array_elements_text(needs) AS need) + -- COALESCE because bool_and over zero rows is NULL, not true: without + -- it a job with an empty `needs` — a test_brain, an ingest that asks for + -- nothing — is invisible to every worker forever. + AND COALESCE( + (SELECT bool_and(need IN (SELECT jsonb_array_elements_text($2::jsonb))) + FROM jsonb_array_elements_text(needs) AS need), + true) ORDER BY priority DESC, created_at FOR UPDATE SKIP LOCKED LIMIT $3 diff --git a/lib/server/db/schema.ts b/lib/server/db/schema.ts index 92a2ad0..1feda17 100644 --- a/lib/server/db/schema.ts +++ b/lib/server/db/schema.ts @@ -490,4 +490,187 @@ export const MIGRATIONS: { name: string; sql: string }[] = [ CREATE INDEX runs_created_by ON runs(created_by); `, }, + + { + /* + * Workers, and the queue they claim from. + * + * Everything before this migration assumed one process: the box that + * answered HTTP was the box with `claude`, `yt-dlp` and `whisper` on it, + * so "is this section being written?" could be a Set in that process's + * memory. The moment the model runs on somebody's laptop that assumption + * is false, and the two things holding it together become bugs — a lock + * that only locks one replica, and a boot sweep that steals work a + * machine is four minutes into. `docs/WORKER.md` has the argument; these + * two tables are it, written down. + * + * A `workers` row is one machine with one signed-in CLI on it. A `jobs` + * row is one unit of work the server has already finished thinking about: + * finished text and a command, never a pack or a rule, because business + * logic shipped to fifteen desktops needs fifteen updates to change a + * prompt, and the prompt changes weekly. + */ + name: "0015_workers", + sql: ` + CREATE TABLE workers ( + id TEXT PRIMARY KEY, + -- Whose machine. Removing a person removes their sessions today; their + -- workers go the same way, because the CLI on that box is signed in as + -- them and the jobs it claims spend their subscription. + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- What the person calls it. "Shakhawat's desktop", not a hostname — + -- the machine picker is read by a human choosing where a run happens. + name TEXT NOT NULL, + -- sha256 of the enrolment token. The token itself is shown once, in the + -- desktop app, and is never in this table: a database dump must not be + -- a list of live credentials, the same reason API keys are encrypted. + token_hash TEXT NOT NULL UNIQUE, + platform TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL DEFAULT '', + -- What it can run: the ToolStatus array lib/server/tools.ts already + -- produces, whole. Probing is now the worker's job and this is the + -- report — the server has no machine to probe. + tools JSONB NOT NULL DEFAULT '[]'::jsonb, + -- The subset of those the owner has switched on. settings.enabled is a + -- global singleton today and cannot be: "ffmpeg is off" is a fact about + -- one machine. + enabled JSONB NOT NULL DEFAULT '[]'::jsonb, + -- Which workspaces this machine may serve. EMPTY MEANS ALL, and today + -- it is empty on every row: there are no roles in this console, and + -- inventing one here would be one team's answer imposed through a + -- schema. The column exists anyway because the day a client's + -- unreleased footage must not reach a contractor's laptop, the answer + -- has to be one UPDATE and one AND in the claim query — not a + -- migration, a backfill and a rewrite of the most important query in + -- the system, under time pressure. + workspace_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + -- Whether this machine's Claude CLI may be granted Read for frames. + -- The switch belongs to whoever owns the machine, not to the console. + -- Note the global it replaces defaults ON and this defaults OFF: + -- enrolling a machine must not silently carry over file access somebody + -- granted on a different one. + can_read_frames BOOLEAN NOT NULL DEFAULT false, + -- One machine is one CLI login, so one job at a time. Parallelism is + -- meant to come from more laptops; twelve claude processes on one + -- laptop is not the win, twelve sections across four laptops is. + max_concurrency INTEGER NOT NULL DEFAULT 1, + last_seen_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX workers_user ON workers(user_id); + CREATE INDEX workers_live ON workers(last_seen_at); + + CREATE TABLE jobs ( + id TEXT PRIMARY KEY, + -- write_section | ingest_source | transcribe_audio | test_brain. + -- transcribe_audio is its own kind because whisper is the one step that + -- wants a GPU and the one dependency the server should not carry: an + -- upload's frames are cut where the bytes already are, and only the + -- 16 kHz WAV — a megabyte a minute, not four hundred — goes out. + kind TEXT NOT NULL, + -- queued -> claimed -> done | failed | unroutable | cancelled + state TEXT NOT NULL DEFAULT 'queued', + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + -- What this job is FOR, so a result can be written back without the + -- payload having to be re-read. + run_id TEXT REFERENCES runs(id) ON DELETE CASCADE, + section_id TEXT, + source_id TEXT REFERENCES sources(id) ON DELETE CASCADE, + -- Tool ids, all of which a worker must advertise AND have enabled. + -- ["claude"] for a section; ["yt-dlp"] for an ingest, which degrades + -- rather than failing when ffmpeg or whisper are missing. + needs JSONB NOT NULL DEFAULT '[]'::jsonb, + -- Pin to one machine when the person chose one. Null means any worker + -- that qualifies. + wants_worker TEXT REFERENCES workers(id) ON DELETE SET NULL, + -- Everything the worker is told. Built by the server, opaque to the + -- worker beyond its own kind's shape. Emptied by the payload sweep a + -- day after the job finishes; see sweepPayloads in repos/jobs.ts. + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + -- What came back, kept for the same reason sources.research is kept: a + -- result that only ever reached a browser did not survive a reload. + result JSONB NOT NULL DEFAULT '{}'::jsonb, + error TEXT NOT NULL DEFAULT '', + attempts INTEGER NOT NULL DEFAULT 0, + -- One retry, because one retry covers the closed laptop and the dropped + -- connection — the failures that recur — and a prompt the model refuses + -- will refuse it again, so a third attempt is money spent to learn + -- nothing. + max_attempts INTEGER NOT NULL DEFAULT 2, + priority INTEGER NOT NULL DEFAULT 0, + worker_id TEXT REFERENCES workers(id) ON DELETE SET NULL, + -- The lease. A claimed job is this worker's until this passes, and the + -- reaper takes it back the moment it does. This column is what replaces + -- requeueOrphans. + lease_until TIMESTAMPTZ, + -- Past this, with nobody having picked it up, the job fails with a + -- sentence naming the machine it was waiting for. Set at enqueue when a + -- capable worker exists but is not live; see "Capability matching". + wait_until TIMESTAMPTZ, + claimed_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + -- Stamped by the payload sweep, so a job it has already emptied is + -- cheap to skip and is distinguishable from one whose payload really + -- was {}. + swept_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + -- The claim query's index: state and priority, ordered by age. + CREATE INDEX jobs_claimable ON jobs(state, priority DESC, created_at) + WHERE state = 'queued'; + CREATE INDEX jobs_lease ON jobs(lease_until) WHERE state = 'claimed'; + CREATE INDEX jobs_run ON jobs(run_id); + -- The payload sweep runs inside the reaper's 15s interval, so it must not + -- be a sequential scan of every job this console has ever run. + CREATE INDEX jobs_sweep ON jobs(finished_at) + WHERE finished_at IS NOT NULL AND swept_at IS NULL; + -- One live job per section. A double Run, two tabs, or a stale poll must + -- not enqueue the same section twice and pay two subscriptions for it. + -- Postgres treats NULLs as distinct, so ingests and brain tests — which + -- have no section — are untouched by this. + CREATE UNIQUE INDEX jobs_one_per_section ON jobs(run_id, section_id) + WHERE state IN ('queued', 'claimed'); + + -- Which machine wrote each section, or 'server:api' when the workspace's + -- API-key fallback did it. created_by from 0014 only says who pressed the + -- button; without this, "which sections did we actually pay money for" + -- has no answer anywhere in the database. + ALTER TABLE run_sections ADD COLUMN wrote_with TEXT NOT NULL DEFAULT ''; + + -- The API-key fallback, off. viaAnthropic and friends stay on the server + -- and the server never reaches for them on its own: this whole split + -- exists so a run costs a subscription somebody already pays for, and a + -- fallback that fired when nobody's laptop was open would spend real + -- money precisely when nobody was watching. Turning it on is a deliberate + -- act, per workspace, by somebody who knows what it costs. + ALTER TABLE workspaces ADD COLUMN api_fallback BOOLEAN NOT NULL DEFAULT false; + + /* + * A ONE-TIME sweep, and the last one there will ever be. + * + * Every run_sections row that says 'writing' at this moment belongs to a + * driver that died with a process on this machine, because until this + * migration there was only ever one process and it held the run in its + * own memory. So these really are orphans of the old world, and putting + * them back in the queue is simply the truth about them. + * + * Nothing at boot may ever do this again. resumeOrphans() in + * api/src/main.ts made exactly this UPDATE on every start, reasoning that + * a 'writing' row must be dead because its driver was. Once the work runs + * on somebody else's laptop that reasoning is false: a deploy or a crash + * would requeue a section a machine is four minutes into writing, the + * section gets written twice, the second result overwrites the first, and + * both spent a subscription. With two API replicas it is worse — every + * boot steals the other replica's in-flight work. + * + * From here jobs.lease_until decides, per job, on evidence — a lease that + * ran out — instead of on the assumption that a boot means nothing is + * running. + */ + UPDATE run_sections SET state = 'queued', updated_at = now() + WHERE state = 'writing'; + `, + }, ]; diff --git a/lib/server/repos/jobs.ts b/lib/server/repos/jobs.ts new file mode 100644 index 0000000..2920835 --- /dev/null +++ b/lib/server/repos/jobs.ts @@ -0,0 +1,601 @@ +import { id, iso, one, q, tx } from "@/lib/server/db/client"; + +/** + * The queue. + * + * This table is where the coordination that used to live in one process's + * memory now lives, where every process can see it. Two things it replaces, + * both the same bug wearing different clothes: + * + * - The `globalThis` Set in `services/runs.ts`. `isDriving(runId)` was the only + * thing stopping two drivers writing the same run, and it could only ever + * see what this Node process was doing. `jobs_one_per_section` says the same + * thing in the database, out loud. + * - `resumeOrphans()` at boot, which assumed a `writing` row must be dead + * because the process that started it was. `lease_until` decides that per + * job, on evidence — a lease that ran out — instead. + * + * Every function here is written so that two API replicas running it at the + * same millisecond is uninteresting. Nothing holds state between calls, no + * read-then-write is split across a round trip where it matters, and anything + * that could run twice is written to be harmless the second time. + */ + +export type JobKind = + "write_section" | "ingest_source" | "transcribe_audio" | "test_brain"; + +/** + * queued -> claimed -> done | failed | cancelled, or straight to unroutable. + * + * `unroutable` is deliberately not `failed`: it means no machine on this + * estate advertises a tool the job needs, which is an install command rather + * than something Retry can fix, and the page has to be able to say so. + */ +export type JobState = + "queued" | "claimed" | "done" | "failed" | "unroutable" | "cancelled"; + +export type Job = { + id: string; + kind: JobKind; + state: JobState; + workspaceId: string | null; + runId: string | null; + sectionId: string | null; + sourceId: string | null; + /** Tool ids a worker must both advertise and have switched on. */ + needs: string[]; + /** Pinned machine, or null for whoever qualifies first. */ + wantsWorker: string | null; + payload: Record; + result: Record; + error: string; + attempts: number; + maxAttempts: number; + priority: number; + workerId: string | null; + leaseUntil: string | null; + waitUntil: string | null; + claimedAt: string | null; + finishedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +/** A job with the machine attached, for the one question the run page asks. */ +export type JobWithWorker = Job & { + /** The machine that holds it, or the one it is pinned to. "" for neither. */ + workerName: string; + /** Whether that machine has been seen recently. False when there is none. */ + workerLive: boolean; +}; + +type Row = Record; + +const asObject = (v: unknown): Record => + v && typeof v === "object" && !Array.isArray(v) + ? (v as Record) + : {}; + +const nul = (v: unknown) => (v == null ? null : iso(v)); + +const mapJob = (r: Row): Job => ({ + id: String(r.id), + kind: String(r.kind) as JobKind, + state: String(r.state ?? "queued") as JobState, + workspaceId: r.workspace_id == null ? null : String(r.workspace_id), + runId: r.run_id == null ? null : String(r.run_id), + sectionId: r.section_id == null ? null : String(r.section_id), + sourceId: r.source_id == null ? null : String(r.source_id), + needs: Array.isArray(r.needs) ? r.needs.map(String) : [], + wantsWorker: r.wants_worker == null ? null : String(r.wants_worker), + payload: asObject(r.payload), + result: asObject(r.result), + error: String(r.error ?? ""), + attempts: Number(r.attempts ?? 0), + maxAttempts: Number(r.max_attempts ?? 2), + priority: Number(r.priority ?? 0), + workerId: r.worker_id == null ? null : String(r.worker_id), + leaseUntil: nul(r.lease_until), + waitUntil: nul(r.wait_until), + claimedAt: nul(r.claimed_at), + finishedAt: nul(r.finished_at), + createdAt: iso(r.created_at), + updatedAt: iso(r.updated_at), +}); + +/** + * How long a claim is good for, and how long a machine may be silent. + * + * Ninety seconds is six missed fifteen-second heartbeats: long enough that a + * Windows machine paging in a large ffmpeg does not lose the job it is part + * way through, short enough that a closed laptop is noticed before anybody has + * reloaded the page twice. + */ +export const LEASE_SECONDS = 90; + +/** + * Put a job in the queue, or find the one that is already there. + * + * `jobs_one_per_section` is a unique index over (run_id, section_id) for jobs + * that are queued or claimed, and this insert names it as the arbiter with + * DO NOTHING. So a double Run, two open tabs and a stale poll all converge on + * the SAME job rather than on two, which matters in money: two jobs for one + * section is two machines writing it, two subscriptions spent, and the second + * result overwriting the first. + * + * DO NOTHING rather than DO UPDATE because the existing job may already be + * claimed and four minutes into running, and quietly rewriting its payload + * underneath the machine executing it would be worse than either outcome. + * + * `created` is returned rather than hidden so the caller cannot mistake the + * two cases. It is the difference between "enqueued a section" and "that + * section was already being written", which is a different sentence on the + * page and a different decision in `advance()`. + */ +export async function enqueue(patch: { + kind: JobKind; + workspaceId?: string | null; + runId?: string | null; + sectionId?: string | null; + sourceId?: string | null; + needs?: string[]; + wantsWorker?: string | null; + payload?: Record; + priority?: number; + maxAttempts?: number; + /** + * Deadline for a job queued behind a machine that is not live. Past it the + * reaper fails the job naming what it was waiting for, so a run cannot sit + * on "Writing" forever because somebody's laptop stayed shut. + */ + waitUntil?: Date | string | null; + /** + * 'unroutable' at birth, for the case where no machine anywhere advertises + * a tool this job needs. Queueing it instead would be a run that reads + * "Writing" forever, which is exactly the bug DECISIONS.md records, rebuilt + * at a larger scale. `error` carries the sentence naming the tool. + * + * Note this state is outside the unique index's predicate on purpose: two + * Runs against an estate with no `whisper` do produce two unroutable rows. + * That is the honest record — the person pressed the button twice and was + * told no twice — and neither row can ever be claimed. + */ + state?: "queued" | "unroutable"; + error?: string; +}): Promise<{ job: Job; created: boolean }> { + const state = patch.state ?? "queued"; + const inserted = await one( + `INSERT INTO jobs + (id, kind, state, workspace_id, run_id, section_id, source_id, needs, + wants_worker, payload, priority, max_attempts, wait_until, error, + finished_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10::jsonb, $11, $12, + $13, $14, CASE WHEN $3 = 'unroutable' THEN now() ELSE NULL END) + ON CONFLICT (run_id, section_id) WHERE state IN ('queued', 'claimed') + DO NOTHING + RETURNING *`, + [ + id("job"), + patch.kind, + state, + patch.workspaceId ?? null, + patch.runId ?? null, + patch.sectionId ?? null, + patch.sourceId ?? null, + JSON.stringify(patch.needs ?? []), + patch.wantsWorker ?? null, + JSON.stringify(patch.payload ?? {}), + patch.priority ?? 0, + patch.maxAttempts ?? 2, + patch.waitUntil ?? null, + patch.error ?? "", + ], + ); + if (inserted) return { job: mapJob(inserted), created: true }; + + // DO NOTHING returns nothing, so the live job has to be read back. It is + // guaranteed to exist: the only way to get here is the arbiter index having + // matched a row that is queued or claimed. + const existing = await one( + `SELECT * FROM jobs + WHERE run_id = $1 AND section_id = $2 + AND state IN ('queued', 'claimed') + LIMIT 1`, + [patch.runId ?? null, patch.sectionId ?? null], + ); + return { job: mapJob(existing!), created: false }; +} + +/** + * Hand a worker up to `max` jobs it is able to run, and lease them to it. + * + * The single most important query in the system, and `FOR UPDATE SKIP LOCKED` + * is the reason it works. It lets four laptops run this at the same + * millisecond and get four disjoint sets of rows, because a row another + * transaction has locked is SKIPPED rather than waited for. Without it the + * two obvious alternatives both fail: plain `SELECT ... FOR UPDATE` serialises + * every worker behind the slowest one, and a bare `SELECT` then `UPDATE` lets + * two workers read the same row and both write the section. + * + * The capability test is the other half. `enabledTools` is the worker's own + * switched-on tool ids; a job is only visible if EVERY id in its `needs` is in + * that list, so a machine without `whisper` never sees a transcription and the + * queue does its own routing. The COALESCE matters more than it looks: + * `bool_and` over zero rows is NULL, not true, so a job with `needs: []` — a + * `test_brain`, or an ingest that asks for nothing — would be invisible to + * every worker forever without it. + * + * `attempts` increments here rather than on failure, because the failure this + * count exists to survive is the one where nothing ever comes back: a laptop + * that is closed mid-section never gets to tell anyone it failed. + * + * Concurrency is the caller's to enforce — pass `max` as the worker's + * `max_concurrency` minus what it already holds. The queue deliberately does + * not count the worker's live jobs itself, because that read would have to + * happen inside this transaction to mean anything, and the caller already knows + * the answer from the claim request. + * + * `workers.workspace_ids` is not consulted. It is empty on every row today and + * empty means all; the day it is not, this is where the `AND` goes. + */ +export async function claim( + workerId: string, + enabledTools: string[], + max = 1, + leaseSeconds = LEASE_SECONDS, +): Promise { + if (max <= 0) return []; + const rows = await q( + `WITH claimable AS ( + SELECT id FROM jobs + WHERE state = 'queued' + AND (wants_worker IS NULL OR wants_worker = $1) + AND COALESCE( + (SELECT bool_and(need IN (SELECT jsonb_array_elements_text($2::jsonb))) + FROM jsonb_array_elements_text(needs) AS need), + true) + ORDER BY priority DESC, created_at + FOR UPDATE SKIP LOCKED + LIMIT $3 + ) + UPDATE jobs j + SET state = 'claimed', + worker_id = $1, + attempts = j.attempts + 1, + claimed_at = now(), + lease_until = now() + ($4 || ' seconds')::interval, + updated_at = now() + FROM claimable c + WHERE j.id = c.id + RETURNING j.*`, + [workerId, JSON.stringify(enabledTools), max, String(leaseSeconds)], + ); + return rows.map(mapJob); +} + +/** + * Extend the lease on the jobs a worker still holds, and say which it does not. + * + * `drop` is how a worker finds out a job was cancelled, reaped from under it, + * or handed to somebody else while its machine was asleep — the answer is to + * kill the child process rather than spend another four minutes producing + * something nobody will accept. Everything not in `keep` is in `drop`, + * including ids this server has never heard of, because "I do not know that + * job" and "that job is not yours" call for the same action. + * + * The extension is scoped to `worker_id = $1` and to jobs still `claimed`, so + * a heartbeat can never take a job back from the machine that now owns it. + */ +export async function heartbeat( + workerId: string, + jobIds: string[], + leaseSeconds = LEASE_SECONDS, +): Promise<{ keep: string[]; drop: string[] }> { + if (!jobIds.length) return { keep: [], drop: [] }; + const rows = await q<{ id: string }>( + `UPDATE jobs + SET lease_until = now() + ($3 || ' seconds')::interval, + updated_at = now() + WHERE id = ANY($2::text[]) + AND worker_id = $1 + AND state = 'claimed' + RETURNING id`, + [workerId, jobIds, String(leaseSeconds)], + ); + const keep = rows.map((r) => r.id); + const kept = new Set(keep); + return { keep, drop: jobIds.filter((jid) => !kept.has(jid)) }; +} + +/** + * Accept a result, but only from the machine that currently holds the job. + * + * The `worker_id` and `lease_until` conditions are the whole point, and they + * are why this returns null rather than throwing: a laptop that wakes from + * sleep and posts a section that was reassigned twenty minutes ago is not an + * exception, it is an expected event with a correct outcome — the write is + * refused, the result already in `run_sections` stands, and the worker is told + * 409 so it stops. Throwing would make normal operation look like a fault in + * the logs, and the difference matters when somebody is deciding whether the + * queue is broken. + * + * Null therefore means exactly one thing to the caller: this result is not + * wanted, do not write it anywhere. + */ +export async function succeed( + jobId: string, + workerId: string, + result: Record, +): Promise { + const row = await one( + `UPDATE jobs + SET state = 'done', + result = $3::jsonb, + error = '', + lease_until = NULL, + finished_at = now(), + updated_at = now() + WHERE id = $1 AND worker_id = $2 AND state = 'claimed' + AND lease_until > now() + RETURNING *`, + [jobId, workerId, JSON.stringify(result)], + ); + return row ? mapJob(row) : null; +} + +/** + * Record a failure from the machine that holds the job, and decide what next. + * + * Same ownership and lease test as `succeed`, and null means the same thing: + * this report is stale, ignore it. A worker that lost its lease and then + * failed must not be able to burn an attempt on work somebody else is now + * doing. + * + * `retryable: false` skips straight to `failed` without spending another + * attempt. That covers the refusals, the bad payloads and the missing tools — + * a worker that cannot honour the transport it was handed must FAIL rather + * than quietly answer some other way, and retrying that on the same machine + * would only produce the same refusal more slowly. + * + * A retryable failure with attempts left goes back to `queued` with the worker + * cleared, so another machine can take it. The error is kept on the row even + * then: when the retry also fails, the person needs both sentences. + */ +export async function fail( + jobId: string, + workerId: string, + error: string, + retryable = true, +): Promise { + const row = await one( + `UPDATE jobs + SET state = CASE + WHEN $4 AND attempts < max_attempts THEN 'queued' + ELSE 'failed' END, + error = $3, + worker_id = CASE + WHEN $4 AND attempts < max_attempts THEN NULL + ELSE worker_id END, + lease_until = NULL, + finished_at = CASE + WHEN $4 AND attempts < max_attempts THEN NULL + ELSE now() END, + updated_at = now() + WHERE id = $1 AND worker_id = $2 AND state = 'claimed' + AND lease_until > now() + RETURNING *`, + [jobId, workerId, error, retryable], + ); + return row ? mapJob(row) : null; +} + +/** + * What the reaper's interval calls. The only thing that moves a job out of + * `claimed` without the worker's consent, and it does it on evidence. + * + * Three passes, in this order, and the order is load-bearing: + * + * 1. Expired leases go back to `queued`. A machine that has missed six + * heartbeats is shut, asleep or gone, and the job is nobody's. + * 2. Queued jobs that have used up their attempts fail. Scoped to `queued` on + * purpose: applied to `claimed` rows it would kill a machine that is at + * that moment three minutes into its final attempt, which is precisely the + * kind of stolen work this whole design exists to stop. + * 3. Queued jobs past `wait_until` fail, because the machine they were queued + * behind never opened. The error is left to the caller to have written at + * enqueue, since only the enqueuer knows which machine was meant. + * + * Every statement is a single UPDATE with its condition in the WHERE clause, + * so running this in two API replicas at the same moment is harmless: the + * second one matches no rows. It is idempotent for the same reason — a second + * pass over a job it has already moved finds it in a state the WHERE excludes. + */ +export async function reap(): Promise<{ + requeued: string[]; + failed: string[]; +}> { + return tx(async (c) => { + const expired = await c.query<{ id: string }>( + `UPDATE jobs + SET state = 'queued', worker_id = NULL, lease_until = NULL, + updated_at = now() + WHERE state = 'claimed' AND lease_until < now() + RETURNING id`, + ); + + const spent = await c.query<{ id: string }>( + `UPDATE jobs + SET state = 'failed', + error = CASE WHEN error = '' THEN + 'Gave up after ' || attempts || ' attempts with nothing back from any machine.' + ELSE error END, + lease_until = NULL, finished_at = now(), updated_at = now() + WHERE state = 'queued' AND attempts >= max_attempts + RETURNING id`, + ); + + const waited = await c.query<{ id: string }>( + `UPDATE jobs + SET state = 'failed', + error = CASE WHEN error = '' THEN + 'No machine picked this up in time.' ELSE error END, + lease_until = NULL, finished_at = now(), updated_at = now() + WHERE state = 'queued' AND wait_until IS NOT NULL AND wait_until < now() + RETURNING id`, + ); + + return { + requeued: expired.rows.map((r) => r.id), + failed: [...spent.rows, ...waited.rows].map((r) => r.id), + }; + }); +} + +/** + * Throw away the prompts, keep the record. + * + * A `write_section` payload is six to eight thousand characters of system + * prompt that `systemPrompt()` rebuilds identically on demand, and its + * `result.text` is already in `run_sections`, which is the copy anything + * actually reads. Keeping both would make `jobs` several times the size of the + * content it describes, to answer no question something else cannot. + * + * What only this table can say survives: which kind of work it was, which + * machine ran it, how many attempts it took, how long it took, and which model + * answered. That is the operational record this console has never had, and it + * is small. + * + * A day, not an hour, because that is long enough for somebody to come back to + * a failure the next morning with the payload still there to look at. + * + * `swept_at` makes it idempotent and cheap: an already-swept job is excluded + * by the WHERE and by the partial index behind it, so this can run in the + * reaper's fifteen-second pass forever without ever rewriting the same row + * twice. + */ +export async function sweepPayloads(afterHours = 24): Promise { + const rows = await q<{ id: string }>( + `UPDATE jobs + SET payload = '{}'::jsonb, + result = jsonb_strip_nulls(jsonb_build_object( + 'ms', result -> 'ms', + 'using', result -> 'using')), + swept_at = now(), + updated_at = now() + WHERE finished_at IS NOT NULL + AND swept_at IS NULL + AND finished_at < now() - ($1 || ' hours')::interval + RETURNING id`, + [String(afterHours)], + ); + return rows.length; +} + +/** + * Stop everything outstanding for a run. + * + * Queued jobs simply stop existing as work. A claimed one keeps running for up + * to fifteen seconds, until its next heartbeat puts it in `drop` and the + * worker kills the child process — there is no way to reach into somebody's + * laptop, and no need for one. + * + * This is what `deleteRun` has to call first. Without it, deleting a run whose + * sections are queued leaves jobs pointing at a row that is gone, and a job + * waiting on a machine that never opens has no way to stop before `wait_until`. + * + * Returns the ids so the caller can say how much it stopped. + */ +export async function cancel(runId: string): Promise { + const rows = await q<{ id: string }>( + `UPDATE jobs + SET state = 'cancelled', lease_until = NULL, finished_at = now(), + updated_at = now() + WHERE run_id = $1 AND state IN ('queued', 'claimed') + RETURNING id`, + [runId], + ); + return rows.map((r) => r.id); +} + +/** One job, for the result and fail endpoints to look at before they act. */ +export async function getJob(jid: string): Promise { + const row = await one("SELECT * FROM jobs WHERE id = $1", [jid]); + return row ? mapJob(row) : null; +} + +/* + * The machine attached to a job, and whether it is awake. + * + * `LIVE_WINDOW_MS` from repos/workers.ts as a Postgres interval. Duplicated as + * a literal here rather than interpolated, because it is inside SQL that is + * also read by whoever is tuning the queue; if one moves the other has to, and + * a comment saying so is more reliable than an import that hides it. + */ +const WITH_WORKER = ` + SELECT j.*, + COALESCE(w.name, p.name, '') AS worker_name, + COALESCE( + COALESCE(w.last_seen_at, p.last_seen_at) > now() - interval '2 minutes', + false) AS worker_live + FROM jobs j + LEFT JOIN workers w ON w.id = j.worker_id + LEFT JOIN workers p ON p.id = j.wants_worker`; + +const mapWithWorker = (r: Row): JobWithWorker => ({ + ...mapJob(r), + workerName: String(r.worker_name ?? ""), + workerLive: r.worker_live === true, +}); + +/** + * Every job a run has ever had, newest last, with its machine. + * + * This is what answers the two sentences the run page has to be able to say + * and cannot today: *writing on Shakhawat's desktop*, and *waiting for + * Shakhawat's desktop, which has not been seen since Tuesday*. The join covers + * both — `worker_id` for a job in flight, `wants_worker` for one queued behind + * a specific machine — because from the reader's point of view they are the + * same question. + */ +export async function jobsForRun(runId: string): Promise { + const rows = await q( + `${WITH_WORKER} WHERE j.run_id = $1 ORDER BY j.created_at`, + [runId], + ); + return rows.map(mapWithWorker); +} + +/** + * The live jobs across every run, for the queue view and for deciding whether + * a run is actually moving. + * + * Queued and claimed only. A finished job is history and belongs to + * `jobsForRun`; this answers "what is the estate doing right now", which is + * the question a person asks when a page has said Writing for a while. + */ +export async function activeJobs(): Promise { + const rows = await q( + `${WITH_WORKER} + WHERE j.state IN ('queued', 'claimed') + ORDER BY j.priority DESC, j.created_at`, + ); + return rows.map(mapWithWorker); +} + +/** + * How many jobs a machine is holding. + * + * The number `claim`'s `max` is derived from, and the reason `claim` does not + * work it out itself: the worker's own request already says how many it wants, + * and a count taken outside the claim transaction is a hint rather than a + * limit. It is a hint that is good enough — the cost of being wrong by one is + * a laptop running two sections instead of one. + */ +export async function heldBy(workerId: string): Promise { + const row = await one<{ n: string }>( + `SELECT count(*) AS n FROM jobs + WHERE worker_id = $1 AND state = 'claimed' AND lease_until > now()`, + [workerId], + ); + return Number(row?.n ?? 0); +} diff --git a/lib/server/repos/workers.ts b/lib/server/repos/workers.ts new file mode 100644 index 0000000..84fc7dc --- /dev/null +++ b/lib/server/repos/workers.ts @@ -0,0 +1,305 @@ +import { createHash } from "node:crypto"; + +import type { ToolStatus } from "@/lib/server/tools"; +import { id, iso, one, q } from "@/lib/server/db/client"; + +/** + * The machines. + * + * One row is one desktop with one signed-in CLI on it. Everything the server + * used to learn by spawning `claude --version` in its own process it now reads + * out of this table, because the box that answers HTTP is no longer the box + * with the binaries: `toolStatuses()` on a VPS returns "nothing installed", + * forever, and an Integrations page built on it is a list of red rows about a + * machine nobody uses. + * + * A worker is a credential as well as a fact. The token is minted by the + * caller, shown to a person once, and never stored — only its sha256 lives + * here, so a database dump is not a list of live logins. Revoking is deleting + * the row. + */ + +/** + * How long since `last_seen_at` before a machine reads as offline. + * + * The worker re-registers and heartbeats on a timer measured in seconds, so + * two minutes is many missed beats rather than one slow one. It is a display + * notion only — nothing routes on it — because "that laptop is shut" is a + * sentence for a person, and the claim query already handles the machine that + * simply never asks for work. + */ +export const LIVE_WINDOW_MS = 120_000; + +export type Worker = { + id: string; + userId: string; + name: string; + platform: string; + version: string; + /** What the machine probed, whole, in the shape the Integrations page draws. */ + tools: ToolStatus[]; + /** The subset its owner has switched on. Tool ids. */ + enabled: string[]; + /** Empty means every workspace. See migration 0015. */ + workspaceIds: string[]; + canReadFrames: boolean; + maxConcurrency: number; + lastSeenAt: string | null; + /** Derived, not stored: seen inside LIVE_WINDOW_MS. */ + live: boolean; + createdAt: string; + updatedAt: string; +}; + +type Row = Record; + +const asArray = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []); + +const mapWorker = (r: Row): Worker => { + const lastSeenAt = r.last_seen_at == null ? null : iso(r.last_seen_at); + return { + id: String(r.id), + userId: String(r.user_id), + name: String(r.name), + platform: String(r.platform ?? ""), + version: String(r.version ?? ""), + tools: asArray(r.tools), + enabled: asArray(r.enabled).map(String), + workspaceIds: asArray(r.workspace_ids).map(String), + canReadFrames: r.can_read_frames === true, + maxConcurrency: Number(r.max_concurrency ?? 1), + lastSeenAt, + live: lastSeenAt + ? Date.now() - new Date(lastSeenAt).getTime() < LIVE_WINDOW_MS + : false, + createdAt: iso(r.created_at), + updatedAt: iso(r.updated_at), + }; +}; + +/** + * The one place a worker token is turned into what the database stores. + * + * Exported, and the only hashing in the codebase, so that no controller can + * ever put a raw token in a column by writing its own `createHash` call and + * getting the encoding subtly different — or by forgetting. `token_hash` is + * UNIQUE, so a caller that stored the raw token would also silently break + * every later lookup, which is the kind of bug that shows up as "enrolment + * worked and then the machine could never claim anything". + * + * sha256 with no salt and no work factor on purpose: this is a 256-bit random + * token, not a password. There is nothing to guess and nothing to slow down, + * and the lookup has to be a single indexed equality on every request the + * worker makes. + */ +export function hashToken(token: string) { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +/** + * Enrol a machine, or update the one already holding this token. + * + * Idempotent on the token hash, which is what makes re-registration the way a + * worker reports itself rather than a second row: the desktop app registers at + * every start and after every update, and a newly installed `whisper` becomes + * visible by the machine saying so again. This is what replaces + * `toolStatuses(force)` and the 60s cache in `tools.ts` — both of which + * belonged to a server that could probe itself. + * + * `canReadFrames`, `maxConcurrency` and `workspaceIds` are deliberately NOT + * overwritten on re-registration. They are the owner's settings, set in the + * console; a worker restarting with its defaults must not silently hand itself + * back the file-read permission somebody switched off. + */ +export async function registerWorker(patch: { + userId: string; + name: string; + /** Raw token. Hashed here; never stored. */ + token: string; + platform?: string; + version?: string; + tools?: ToolStatus[]; + enabled?: string[]; +}): Promise { + const hash = hashToken(patch.token); + const row = await one( + `INSERT INTO workers + (id, user_id, name, token_hash, platform, version, tools, enabled, + last_seen_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, now()) + ON CONFLICT (token_hash) DO UPDATE SET + name = EXCLUDED.name, + platform = EXCLUDED.platform, + version = EXCLUDED.version, + tools = EXCLUDED.tools, + enabled = EXCLUDED.enabled, + last_seen_at = now(), + updated_at = now() + RETURNING *`, + [ + id("wrk"), + patch.userId, + patch.name, + hash, + patch.platform ?? "", + patch.version ?? "", + JSON.stringify(patch.tools ?? []), + JSON.stringify(patch.enabled ?? []), + ], + ); + return mapWorker(row!); +} + +/** + * The guard's lookup: bearer token in, machine out. + * + * Takes the raw token rather than a hash so that the hashing stays on this + * side of the boundary and a caller cannot pass the wrong thing that happens + * to typecheck. + */ +export async function workerByToken(token: string): Promise { + if (!token) return null; + const row = await one("SELECT * FROM workers WHERE token_hash = $1", [ + hashToken(token), + ]); + return row ? mapWorker(row) : null; +} + +export async function getWorker(wid: string): Promise { + const row = await one("SELECT * FROM workers WHERE id = $1", [wid]); + return row ? mapWorker(row) : null; +} + +/** + * Every machine, most recently seen first. + * + * What the machine picker reads. Sorted by liveness rather than by name + * because the question the page is answering is "where can this run right + * now", and a laptop that has been shut since Tuesday is not an answer to it. + * NULLS LAST keeps a machine that has never checked in at the bottom instead + * of the top, where Postgres would otherwise put it for a DESC sort. + */ +export async function listWorkers(userId?: string): Promise { + const rows = userId + ? await q( + `SELECT * FROM workers WHERE user_id = $1 + ORDER BY last_seen_at DESC NULLS LAST, created_at`, + [userId], + ) + : await q( + `SELECT * FROM workers + ORDER BY last_seen_at DESC NULLS LAST, created_at`, + ); + return rows.map(mapWorker); +} + +/** + * Mark a machine as having just spoken. + * + * Called by the guard on every worker request, so it is one indexed UPDATE and + * nothing else — no RETURNING, no row mapping. Liveness is the only thing that + * reads it, and liveness is a display notion, so the write does not need to be + * awaited on a path where latency matters. + */ +export async function touchWorker(wid: string) { + await q("UPDATE workers SET last_seen_at = now() WHERE id = $1", [wid]); +} + +/** + * The switches that belong to whoever owns the machine. + * + * Separate from `registerWorker` because these are the console's writes and + * that is the worker's. Nothing here can be set by the machine reporting + * itself: `can_read_frames` grants the `Read` tool on one specific person's + * filesystem, and a worker that could set it for itself would make the switch + * meaningless. `tools` is absent for the mirror-image reason — a person + * cannot type a binary into existence. + * + * Returns null when the row is gone, so a caller can tell "nothing changed" + * from "no such machine" without a second query. + */ +export async function updateWorker( + wid: string, + patch: Partial< + Pick< + Worker, + "name" | "enabled" | "canReadFrames" | "maxConcurrency" | "workspaceIds" + > + >, +): Promise { + const sets: string[] = []; + const values: unknown[] = []; + const set = (col: string, value: unknown) => { + values.push(value); + sets.push(`${col} = $${values.length}`); + }; + + if (patch.name !== undefined) set("name", patch.name); + if (patch.enabled !== undefined) + set("enabled", JSON.stringify(patch.enabled)); + if (patch.canReadFrames !== undefined) + set("can_read_frames", patch.canReadFrames); + // One machine is one CLI login. Clamped rather than rejected because this + // arrives from a number input and the useful range is two values wide. + if (patch.maxConcurrency !== undefined) + set("max_concurrency", Math.max(1, Math.min(8, patch.maxConcurrency))); + if (patch.workspaceIds !== undefined) + set("workspace_ids", JSON.stringify(patch.workspaceIds)); + + if (!sets.length) return getWorker(wid); + + sets.push("updated_at = now()"); + values.push(wid); + const row = await one( + `UPDATE workers SET ${sets.join(", ")} + WHERE id = $${values.length} + RETURNING *`, + values, + ); + return row ? mapWorker(row) : null; +} + +/** + * Revoke a machine. + * + * Deleting the row IS revoking the token — there is no separate revoked flag, + * because a hash that is not in the table cannot authenticate and a row that + * exists but is refused is a second state to keep in sync with the first. + * Jobs the machine held survive it: `jobs.worker_id` is ON DELETE SET NULL, + * and a claimed job whose worker vanished simply loses its lease and is + * reaped back to `queued` like any other. + */ +export async function deleteWorker(wid: string) { + const rows = await q("DELETE FROM workers WHERE id = $1 RETURNING id", [wid]); + return rows.length > 0; +} + +/** + * Can anything on this estate run a job that needs these tools? + * + * The question behind the three cases in "Capability matching": a tool no + * machine has ever advertised means the job is unroutable and must fail at + * enqueue with a sentence naming the tool, while a tool only offline machines + * have means queue it and say who we are waiting for. Answered here rather + * than in the queue because it is a fact about the machines. + * + * `capable` counts only machines with the tool present AND switched on, since + * the claim query intersects against `enabled` and a job that matches only a + * disabled tool would queue forever. + */ +export async function routeFor(needs: string[]): Promise<{ + /** Machines that could run it, offline ones included. */ + capable: Worker[]; + /** Of those, the ones seen recently enough to be expected to pick it up. */ + live: Worker[]; + /** Needed tools no machine anywhere advertises with it switched on. */ + missing: string[]; +}> { + const all = await listWorkers(); + const has = (w: Worker, need: string) => + w.enabled.includes(need) && w.tools.some((t) => t.id === need && t.present); + + const capable = all.filter((w) => needs.every((need) => has(w, need))); + const missing = needs.filter((need) => !all.some((w) => has(w, need))); + return { capable, live: capable.filter((w) => w.live), missing }; +} diff --git a/lib/server/services/ingest.ts b/lib/server/services/ingest.ts index d1df494..db5b247 100644 --- a/lib/server/services/ingest.ts +++ b/lib/server/services/ingest.ts @@ -671,10 +671,34 @@ export async function sourceBrief(sourceId: string) { source.duration ? `DURATION: ${source.duration}s` : "", source.description ? `CAPTION / DESCRIPTION:\n${source.description}` : "", source.transcript ? `TRANSCRIPT:\n${source.transcript}` : "", + /* + * How many stills, and at what seconds — and deliberately NOT where they + * are. + * + * This line used to end with "They are on disk under + * .data/sources//", which is the server's own filesystem layout. Once + * a section is written by a worker on somebody's desktop that sentence is + * simply false: the frames were downloaded for that one call into a temp + * directory, under a different name, on a different machine. A model told + * to open a path that is not there does the thing `CLI_GUARD` in + * lib/server/brains.ts exists to stop — it goes hunting, finds nothing, + * and writes a paragraph about the failure into the script. + * + * The paths belong to the transport, not to the evidence. `viaClaudeCli` + * lists them and is granted Read for the one call, `viaCodexCli` attaches + * them with -i, `viaGeminiCli` resolves them as @ references; each already + * names them correctly for itself, and nothing else has any business + * naming them. Do not helpfully put a path back here. + * + * The wording says what was taken from the video, not what the model is + * looking at: `write()` refuses a request with pictures a transport cannot + * show, and the Ollama path never sees any, so a source brief that + * promised "here are your frames" would be lying on every one of those. + */ source.frames.length - ? `FRAMES: ${source.frames.length} stills were taken, at ${source.frames + ? `FRAMES: ${source.frames.length} stills were taken from the video, at ${source.frames .map((f) => `${f.at}s`) - .join(", ")}. They are on disk under .data/sources/${source.id}/.` + .join(", ")}.` : "", ].filter(Boolean); From aaaf52a9270ef25cdf8eaf7938e173df8f551dfa Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 19:31:17 +0600 Subject: [PATCH 04/30] Put the API's module resolution back 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) --- api/package.json | 6 +++--- api/register-paths.cjs | 18 ++++++++++++++++++ api/tsconfig.json | 15 ++++++--------- 3 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 api/register-paths.cjs diff --git a/api/package.json b/api/package.json index ce4f013..f05425b 100644 --- a/api/package.json +++ b/api/package.json @@ -4,10 +4,10 @@ "private": true, "description": "The Content OS backend, as a NestJS service. Shares lib/server with the repo root.", "scripts": { - "dev": "cross-env TS_NODE_TRANSPILE_ONLY=1 CONTENTOS_DATA_DIR=../.data ts-node -r tsconfig-paths/register src/main.ts", + "dev": "cross-env TS_NODE_TRANSPILE_ONLY=1 CONTENTOS_DATA_DIR=../.data ts-node -r ./register-paths.cjs src/main.ts", "build": "tsc -p tsconfig.json", - "start": "cross-env TS_NODE_BASEURL=./dist node -r tsconfig-paths/register dist/api/src/main.js", - "start:local": "cross-env TS_NODE_BASEURL=./dist CONTENTOS_DATA_DIR=../.data node -r tsconfig-paths/register dist/api/src/main.js", + "start": "cross-env CONTENTOS_MODULE_ROOT=./dist node -r ./register-paths.cjs dist/api/src/main.js", + "start:local": "cross-env CONTENTOS_MODULE_ROOT=./dist CONTENTOS_DATA_DIR=../.data node -r ./register-paths.cjs dist/api/src/main.js", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/api/register-paths.cjs b/api/register-paths.cjs new file mode 100644 index 0000000..7ab50f5 --- /dev/null +++ b/api/register-paths.cjs @@ -0,0 +1,18 @@ +// Resolves the `@/...` imports at runtime, for ts-node in development and for +// the compiled output in production. +// +// tsconfig-paths normally reads this out of tsconfig.json, but it reads it out +// of `baseUrl` — an option TypeScript 7 removes, so tsconfig no longer has one +// and its `paths` are written relative to the config file instead. The mapping +// therefore lives here. +// +// `@/` is the repo root: the sources when running TypeScript, and `dist/` when +// running the build, since `rootDir: ".."` makes dist a mirror of the root +// (dist/api/src, dist/lib). CONTENTOS_MODULE_ROOT, resolved against api/, is +// how the start scripts and the Dockerfile say which of the two it is. +const path = require("node:path"); +const { register } = require("tsconfig-paths"); + +const root = path.resolve(__dirname, process.env.CONTENTOS_MODULE_ROOT || ".."); + +register({ baseUrl: root, paths: { "@/*": ["./*"] } }); diff --git a/api/tsconfig.json b/api/tsconfig.json index f50cfd4..585b059 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -1,12 +1,6 @@ { "compilerOptions": { - // "nodenext", not "node16": NestJS 12 is ESM-only ("type": "module", no - // require export). node16 rejects importing it from this CommonJS build - // (TS1479); nodenext models Node's require(esm) support, which is how it - // already works at runtime. The emit stays CommonJS — this package has no - // "type": "module" — so ts-node, nest and dist/ are unaffected. - "module": "nodenext", - "moduleResolution": "nodenext", + "module": "commonjs", "target": "es2022", "lib": ["es2023"], "types": ["node"], @@ -15,10 +9,13 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "moduleResolution": "node", "resolveJsonModule": true, "sourceMap": true, - "baseUrl": "..", - "paths": { "@/*": ["./*"] }, + // No baseUrl — TypeScript 7 removes it. Path mappings without one are + // resolved relative to this file, so "@/" is the repo root, one level up. + // register-paths.cjs carries the same mapping for the runtime. + "paths": { "@/*": ["../*"] }, "rootDir": "..", "outDir": "dist" }, From db377708c105a4b838083284d54e637495209ce5 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 19:32:52 +0600 Subject: [PATCH 05/30] Undo the API path-resolution rewrite nobody asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/package.json | 6 +++--- api/register-paths.cjs | 18 ------------------ api/tsconfig.json | 6 ++---- 3 files changed, 5 insertions(+), 25 deletions(-) delete mode 100644 api/register-paths.cjs diff --git a/api/package.json b/api/package.json index f05425b..ce4f013 100644 --- a/api/package.json +++ b/api/package.json @@ -4,10 +4,10 @@ "private": true, "description": "The Content OS backend, as a NestJS service. Shares lib/server with the repo root.", "scripts": { - "dev": "cross-env TS_NODE_TRANSPILE_ONLY=1 CONTENTOS_DATA_DIR=../.data ts-node -r ./register-paths.cjs src/main.ts", + "dev": "cross-env TS_NODE_TRANSPILE_ONLY=1 CONTENTOS_DATA_DIR=../.data ts-node -r tsconfig-paths/register src/main.ts", "build": "tsc -p tsconfig.json", - "start": "cross-env CONTENTOS_MODULE_ROOT=./dist node -r ./register-paths.cjs dist/api/src/main.js", - "start:local": "cross-env CONTENTOS_MODULE_ROOT=./dist CONTENTOS_DATA_DIR=../.data node -r ./register-paths.cjs dist/api/src/main.js", + "start": "cross-env TS_NODE_BASEURL=./dist node -r tsconfig-paths/register dist/api/src/main.js", + "start:local": "cross-env TS_NODE_BASEURL=./dist CONTENTOS_DATA_DIR=../.data node -r tsconfig-paths/register dist/api/src/main.js", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/api/register-paths.cjs b/api/register-paths.cjs deleted file mode 100644 index 7ab50f5..0000000 --- a/api/register-paths.cjs +++ /dev/null @@ -1,18 +0,0 @@ -// Resolves the `@/...` imports at runtime, for ts-node in development and for -// the compiled output in production. -// -// tsconfig-paths normally reads this out of tsconfig.json, but it reads it out -// of `baseUrl` — an option TypeScript 7 removes, so tsconfig no longer has one -// and its `paths` are written relative to the config file instead. The mapping -// therefore lives here. -// -// `@/` is the repo root: the sources when running TypeScript, and `dist/` when -// running the build, since `rootDir: ".."` makes dist a mirror of the root -// (dist/api/src, dist/lib). CONTENTOS_MODULE_ROOT, resolved against api/, is -// how the start scripts and the Dockerfile say which of the two it is. -const path = require("node:path"); -const { register } = require("tsconfig-paths"); - -const root = path.resolve(__dirname, process.env.CONTENTOS_MODULE_ROOT || ".."); - -register({ baseUrl: root, paths: { "@/*": ["./*"] } }); diff --git a/api/tsconfig.json b/api/tsconfig.json index 585b059..f8f2ebc 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -12,10 +12,8 @@ "moduleResolution": "node", "resolveJsonModule": true, "sourceMap": true, - // No baseUrl — TypeScript 7 removes it. Path mappings without one are - // resolved relative to this file, so "@/" is the repo root, one level up. - // register-paths.cjs carries the same mapping for the runtime. - "paths": { "@/*": ["../*"] }, + "baseUrl": "..", + "paths": { "@/*": ["./*"] }, "rootDir": "..", "outDir": "dist" }, From fc3398d054e227cdf56ac67333a40ef0b225eb24 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 19:45:16 +0600 Subject: [PATCH 06/30] Drive runs from the queue, and let machines ask for work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .vscode/settings.json | 9 + api/src/app.module.ts | 12 +- api/src/common/worker.guard.ts | 96 ++++ api/src/main.ts | 24 +- api/src/runs/runs.controller.ts | 79 ++- api/src/workers/reaper.ts | 139 +++++ api/src/workers/workers.controller.ts | 447 +++++++++++++++ lib/server/repos/runs.ts | 68 ++- lib/server/services/ingest.ts | 2 +- lib/server/services/runs.ts | 767 +++++++++++++++++++------- 10 files changed, 1405 insertions(+), 238 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 api/src/common/worker.guard.ts create mode 100644 api/src/workers/reaper.ts create mode 100644 api/src/workers/workers.controller.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..434e3cb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + // Use the TypeScript in node_modules (5.9.3) for IntelliSense, not the one + // bundled with the editor. A newer bundled compiler reports options this + // repo's compiler still accepts — `moduleResolution: node` and `baseUrl` in + // api/tsconfig.json are deprecated in TypeScript 7 — as errors in files that + // build and typecheck clean. Editor and CI now agree on one compiler. + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} diff --git a/api/src/app.module.ts b/api/src/app.module.ts index e647f3f..b07f054 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -15,6 +15,8 @@ import { IntegrationsController } from "./integrations/integrations.controller"; import { RunsController } from "./runs/runs.controller"; import { SourcesController } from "./sources/sources.controller"; import { ResearchController } from "./research/research.controller"; +import { WorkersController } from "./workers/workers.controller"; +import { Reaper } from "./workers/reaper"; /** * One module, because there is one database and one set of rules. @@ -43,7 +45,15 @@ import { ResearchController } from "./research/research.controller"; RunsController, SourcesController, ResearchController, + WorkersController, ], - providers: [{ provide: APP_GUARD, useClass: SessionGuard }], + /* + * The reaper is a provider rather than something started in `main`, so that + * its interval is owned by the application: Nest constructs it once and, + * with shutdown hooks on, clears it on the way out. A `setInterval` in + * `main` would survive `app.close()` and hold a database pool open forever + * in tests and on a dev reload. + */ + providers: [{ provide: APP_GUARD, useClass: SessionGuard }, Reaper], }) export class AppModule {} diff --git a/api/src/common/worker.guard.ts b/api/src/common/worker.guard.ts new file mode 100644 index 0000000..75a156f --- /dev/null +++ b/api/src/common/worker.guard.ts @@ -0,0 +1,96 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + SetMetadata, + UnauthorizedException, + UseGuards, + applyDecorators, +} from "@nestjs/common"; +import type { Request } from "express"; + +import { PUBLIC } from "./session.guard"; +import { touchWorker, workerByToken, type Worker } from "@/lib/server/repos/workers"; + +/** Marks a handler as belonging to the worker protocol rather than the console. */ +export const WORKER_ROUTE = "contentos:worker"; + +/** The request, once the guard has run: the machine rides on it. */ +export type WorkerRequest = Request & { + worker?: Worker; + /** The raw bearer token, kept only for register, which re-hashes it. */ + workerToken?: string; +}; + +/** + * The lock on the six worker endpoints. + * + * A bearer token, not the session cookie, and the reasons are in + * `docs/WORKER.md`: a cookie is a browser mechanism the worker would have to + * scrape out of a webview; sessions are deleted by Settings → Team, which is + * the wrong lever for a machine; a session can read the entire console while a + * worker needs six routes; and a headless process cannot be sent to + * `/login?next=…`, which is what every 401 does to a browser. + * + * Hashing goes through `hashToken` inside `workerByToken` — the only + * `createHash` in the codebase — so no controller can put a raw token in a + * column, or hash it with a different encoding and produce a machine that + * enrols and then can never claim anything. + * + * `last_seen_at` is touched here rather than in each handler because liveness + * is a fact about the connection, not about which endpoint was called: a + * machine that is heartbeating is awake whether or not it is holding work. The + * write is deliberately not awaited. Liveness is a display notion, the claim + * long-poll holds for twenty-five seconds either way, and an UPDATE on the + * hot path of every request should not add a round trip to it. + */ +@Injectable() +export class WorkerGuard implements CanActivate { + async canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest(); + + const header = String(req.headers.authorization ?? ""); + const token = /^Bearer\s+(.+)$/i.exec(header.trim())?.[1]?.trim() ?? ""; + // A session cookie must never satisfy a worker route. There is no fallback + // here on purpose: the two credentials are different principals with + // different lifetimes and different blast radii, and a guard that accepted + // either would quietly merge them. + if (!token) throw new UnauthorizedException("Worker token required"); + + const worker = await workerByToken(token); + if (!worker) throw new UnauthorizedException("Unknown worker token"); + + req.worker = worker; + req.workerToken = token; + void touchWorker(worker.id).catch(() => {}); + return true; + } +} + +/** + * The third decorator, beside `@Public()`. + * + * It does two things at once, and both are needed: it hangs `WorkerGuard` on + * the handler, and it marks the route `PUBLIC` so the global `SessionGuard` + * stands aside instead of refusing a request that carries no cookie. Standing + * aside is all that means — `SessionGuard` still puts a session on the request + * if one happens to be there, and `WorkerGuard` still demands the bearer + * token, so a signed-in browser cannot reach these routes and a worker token + * cannot reach anything else. + * + * Written as one decorator rather than the pair, because the pair is a thing + * somebody would half-apply: `@Public()` without the guard is an open route, + * and that is exactly the class of hole the global guard exists to close. + */ +export const WorkerRoute = () => + applyDecorators( + SetMetadata(WORKER_ROUTE, true), + SetMetadata(PUBLIC, true), + UseGuards(WorkerGuard), + ); + +/** The machine on the request. Never undefined inside a `@WorkerRoute()`. */ +export const workerOf = (req: WorkerRequest): Worker => { + if (!req.worker) throw new UnauthorizedException("Worker token required"); + return req.worker; +}; diff --git a/api/src/main.ts b/api/src/main.ts index b7ea029..a9b33c7 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -5,7 +5,6 @@ import type { NestExpressApplication } from "@nestjs/platform-express"; import cookieParser from "cookie-parser"; import { ready } from "@/lib/server/http"; -import { resumeOrphans } from "@/lib/server/services/runs"; import { AppModule } from "./app.module"; import { ErrorsFilter } from "./common/errors.filter"; @@ -56,11 +55,24 @@ async function main() { */ await ready(); - // Whatever the last process was writing when it stopped, this one finishes. - const resumed = await resumeOrphans(); - if (resumed.length) { - console.log(`Resuming ${resumed.length} run(s) left mid-write: ${resumed.join(", ")}`); - } + /* + * Nothing at boot touches a section's state. This is where `resumeOrphans()` + * used to run, requeueing every `writing` row on the reasoning that its + * driver died with the last process. That was true while there was one + * process and the model was its own child; once the writing happens on + * somebody's laptop it is false, and a deploy would requeue a section a + * machine was four minutes into — written twice, paid for twice, the second + * answer overwriting the first. With two API replicas every boot would steal + * the other's in-flight work. + * + * Migration 0015 swept the genuine orphans of the old world once, and that + * was the last time. From here `jobs.lease_until` decides, per job, on + * evidence — a lease that ran out — and the reaper acts on it. + */ + + // So the reaper's interval is cleared on SIGTERM rather than left holding a + // database pool open while the container waits to be killed. + app.enableShutdownHooks(); const port = Number(process.env.PORT ?? 4000); await app.listen(port); diff --git a/api/src/runs/runs.controller.ts b/api/src/runs/runs.controller.ts index 8411a4e..11814c8 100644 --- a/api/src/runs/runs.controller.ts +++ b/api/src/runs/runs.controller.ts @@ -13,18 +13,19 @@ import { } from "@nestjs/common"; import { - deleteRun, getRun, listRuns, requeueFailed, setRunDecision, } from "@/lib/server/repos/runs"; import { - driveRun, + advance, editSection, - isDriving, + isRunning, + queueSection, + removeRun, startRun, - writeSection, + stopRun, } from "@/lib/server/services/runs"; import type { User } from "@/lib/server/auth"; import { CurrentUser } from "../common/user.decorator"; @@ -32,11 +33,17 @@ import { CurrentUser } from "../common/user.decorator"; /** * Runs, and the sections written into them. * - * The driver is `driveRun` in lib/server/services — a loop inside THIS - * process, one section at a time, carrying on whether or not anybody is - * watching. That is the reason the API is its own long-lived service and not - * a serverless function: a run takes minutes and shells out to a local - * model, and both need somewhere to stand. + * Every route here returns as soon as the database says so. The driver used to + * be `driveRun` — a loop inside THIS process holding one run for its whole + * length, one section at a time, because the model was a child process of the + * loop. It is `advance` now: it puts jobs on the queue for whatever is ready + * and stops, and the machines that hold the CLIs pick them up. So a run is not + * something a request or a process owns, and restarting the API mid-run loses + * nothing. + * + * These are the person-facing routes, authenticated by session. What a worker + * calls lives under /api/workers with its own credential — see docs/WORKER.md + * for why the two are deliberately different principals. */ @Controller("runs") export class RunsController { @@ -94,36 +101,70 @@ export class RunsController { return { id, decision: d }; } + /** + * Delete a run, having first stopped whatever is writing it. + * + * `removeRun` cancels the jobs before the row goes. Deleting first would + * leave a machine minutes into a section for a run that no longer exists. + */ @Delete(":id") async remove(@Param("id") id: string) { - return { ok: await deleteRun(id) }; + return { ok: await removeRun(id) }; } /** - * Write everything that is left, on the server, and return at once. + * Queue everything that is ready, and return at once. * - * Pressing this on a run that failed is a retry: the failures go back in - * the queue first, so the loop tries them again rather than skipping them. + * Pressing this on a run that failed is a retry: the failures go back to + * queued first, so `advance` finds them eligible again rather than skipping + * them. Pressing it twice is harmless — the queue's one-live-job-per-section + * index makes both presses converge on the same job instead of paying two + * subscriptions to write one section. */ @Post(":id/start") @HttpCode(200) async start(@Param("id") id: string) { if (!(await getRun(id))) throw new NotFoundException("No such run"); const retried = await requeueFailed(id); - return { ...driveRun(id), retried, running: true }; + const queued = await advance(id); + return { + started: queued.enqueued.length > 0, + retried, + running: await isRunning(id), + ...queued, + }; } - /** Is this run being written right now, by anyone? */ + /** Is this run being written right now, by anyone, on any machine? */ @Get(":id/start") - running(@Param("id") id: string) { - return { running: isDriving(id) }; + async running(@Param("id") id: string) { + return { running: await isRunning(id) }; + } + + /** + * Stop a run that is writing. + * + * The jobs are cancelled here; a machine already running one finds out at its + * next heartbeat, within fifteen seconds, and kills the child process. + */ + @Post(":id/stop") + @HttpCode(200) + async stop(@Param("id") id: string) { + if (!(await getRun(id))) throw new NotFoundException("No such run"); + return { ok: true, ...(await stopRun(id)) }; } - /** Write one section, now, and answer with the whole run. */ + /** + * Queue one section, and answer with the whole run. + * + * It no longer writes it here and waits: the request would be holding open + * for however long a laptop takes. The row comes back saying `writing`, and + * the page reads the result from the run like every other section. + */ @Post(":id/sections/:sectionId") @HttpCode(200) write(@Param("id") id: string, @Param("sectionId") sectionId: string) { - return writeSection(id, sectionId); + return queueSection(id, sectionId); } /** Replace a section's text by hand. It counts as finished from then on. */ diff --git a/api/src/workers/reaper.ts b/api/src/workers/reaper.ts new file mode 100644 index 0000000..ca9bf16 --- /dev/null +++ b/api/src/workers/reaper.ts @@ -0,0 +1,139 @@ +import { + Injectable, + type OnApplicationShutdown, + type OnModuleInit, +} from "@nestjs/common"; + +import { getJob, reap, sweepPayloads } from "@/lib/server/repos/jobs"; +import { sectionFailed } from "@/lib/server/services/runs"; + +/** + * How often the queue is looked at, and how often the prompts are thrown away. + * + * Fifteen seconds is a quarter of the ninety-second lease, so a job whose + * machine has gone quiet is back in the queue within six missed heartbeats + * plus a tick — noticed before anybody has reloaded the page twice. + * + * The payload sweep runs on its own, much slower clock. It only ever matches + * jobs that finished a day ago, so running it every fifteen seconds would be + * the same UPDATE finding nothing hundreds of times an hour; five minutes is + * often enough that a row is never carrying a prompt for long after its day, + * and rare enough to stay invisible. + */ +const REAP_MS = 15_000; +const SWEEP_EVERY = 20; // ticks — five minutes + +/** + * The one thing that moves a job out of `claimed` without the worker's say-so. + * + * It does it on evidence — a lease that ran out — rather than on the + * assumption `resumeOrphans()` used to make at boot, that a `writing` row must + * be dead because the process that started it was. That reasoning held while + * there was one process and the model was its own child; the moment the work + * runs on somebody's laptop it is false, and a deploy would requeue a section + * a machine was four minutes into writing, pay for it twice, and let the + * second answer overwrite the first. + * + * `reap` and `sweepPayloads` are both single UPDATEs with their conditions in + * the WHERE clause, so two API replicas running this interval at the same + * millisecond is uninteresting: the second one matches no rows. That is the + * property that lets this be an unguarded `setInterval` rather than a leader + * election. + * + * Three things the lifecycle has to get right, and each has a failure behind + * it: + * + * - Registered once. `onModuleInit` runs once per application, and the handle + * is kept so a second call cannot stack a second interval on top of the + * first — two reapers is not a correctness problem but it is a doubled load + * on the one query that must stay fast. + * - Cleared on shutdown. Without `onApplicationShutdown` a test or a dev + * reload leaves a timer holding a database pool open, and the process never + * exits. + * - A tick that throws must not take the process with it. `setInterval` with + * an async callback produces an unhandled rejection when the database blinks + * — a restart, a failover, `docker compose down` — and an unhandled + * rejection ends Node. Catching inside the tick means a blink costs one + * pass, not the API. `running` is the same guard applied to time: a pass + * that is somehow still going when the next tick fires is skipped rather + * than overlapped. + */ +@Injectable() +export class Reaper implements OnModuleInit, OnApplicationShutdown { + private timer: NodeJS.Timeout | null = null; + private running = false; + private ticks = 0; + + onModuleInit() { + if (this.timer) return; + this.timer = setInterval(() => void this.tick(), REAP_MS); + // The interval is maintenance, not work anybody is waiting on: it must + // never be the reason the process stays alive. + this.timer.unref?.(); + } + + onApplicationShutdown() { + if (!this.timer) return; + clearInterval(this.timer); + this.timer = null; + } + + private async tick() { + if (this.running) return; + this.running = true; + try { + const { requeued, failed } = await reap(); + // Said out loud because these are the two events nobody witnessed: a + // machine that went quiet mid-section, and a job that ran out of + // attempts or of patience. Silence otherwise — a pass that moves nothing + // is what almost every pass does. + if (requeued.length) { + console.log(`Reaper: ${requeued.length} job(s) back in the queue after a lost lease`); + } + if (failed.length) { + console.log(`Reaper: ${failed.length} job(s) failed — out of attempts or past their wait`); + } + + /* + * Tell the run about it, or the reaping is invisible. + * + * `reap()` ends a job on evidence the worker never reports: a lease that + * ran out because a laptop was shut, attempts spent, or a wait that + * nobody came back for. Nothing else is going to arrive and say so — the + * machine that had the job is by definition not talking — so a section + * left at `writing` or `queued` would stay there with an empty queue + * behind it, which is exactly the "Writing for five days" row this + * design is built to prevent. + * + * Each is done on its own rather than in one pass: a run whose topic + * cannot be updated must not stop the next job's section being marked. + */ + for (const jid of failed) { + try { + const job = await getJob(jid); + if (job?.kind === "write_section") { + await sectionFailed(job, job.error || "No machine finished this."); + } + } catch (e) { + console.error( + `Reaper: job ${jid} ended but its section could not be marked:`, + e instanceof Error ? e.message : e, + ); + } + } + + this.ticks += 1; + if (this.ticks % SWEEP_EVERY === 0) { + const swept = await sweepPayloads(); + if (swept) console.log(`Reaper: cleared the prompts on ${swept} finished job(s)`); + } + } catch (e) { + // One pass, not the API. The next tick tries again in fifteen seconds, + // and both statements are idempotent, so nothing is owed from the pass + // that failed. + console.error("Reaper pass failed:", e instanceof Error ? e.message : e); + } finally { + this.running = false; + } + } +} diff --git a/api/src/workers/workers.controller.ts b/api/src/workers/workers.controller.ts new file mode 100644 index 0000000..29c1312 --- /dev/null +++ b/api/src/workers/workers.controller.ts @@ -0,0 +1,447 @@ +import { + BadRequestException, + Body, + ConflictException, + Controller, + Get, + NotFoundException, + Param, + Post, + Put, + Req, + Res, +} from "@nestjs/common"; +import busboy from "busboy"; +import type { Response } from "express"; +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +import { + LEASE_SECONDS, + claim, + fail, + getJob, + heartbeat, + heldBy, + succeed, +} from "@/lib/server/repos/jobs"; +import { registerWorker } from "@/lib/server/repos/workers"; +import { getSource, updateSource } from "@/lib/server/repos/sources"; +import { + DATA_ROOT, + FRAME_FILE, + MAX_UPLOAD_BYTES, + frameFile, +} from "@/lib/server/services/ingest"; +import { sectionFailed, sectionResult } from "@/lib/server/services/runs"; +import type { ToolStatus } from "@/lib/server/tools"; + +import { WorkerRoute, workerOf, type WorkerRequest } from "../common/worker.guard"; + +/** + * How long a claim holds when there is nothing to hand out, and how often the + * worker is told to speak. + * + * Twenty-five seconds is under every proxy's thirty-second idle default, which + * is the only number that actually constrains it: a long-poll that outlives an + * intermediary comes back as a dropped connection the worker cannot tell from + * a server that has gone away. A second between polls keeps latency to about a + * second without anything stateful in between — the reason this is a long-poll + * and not a socket. + */ +const CLAIM_HOLD_MS = 25_000; +const CLAIM_POLL_MS = 1_000; +const HEARTBEAT_MS = 15_000; + +/** + * The six endpoints a machine speaks, and nothing else. + * + * Every route here is `@WorkerRoute()`: a bearer token, no session, no cookie. + * The server decides and the worker executes — a job carries finished text and + * a command, never a pack, a template, a rule or a dependency graph — so there + * is deliberately nothing on this controller that reads the content library. + * A stolen worker token buys the queue and nothing more. + */ +@Controller("workers") +export class WorkersController { + /** + * Enrol a machine, or re-report the one already holding this token. + * + * Idempotent on the token because `registerWorker` makes it so: the desktop + * app registers at every start and after every update, and a newly installed + * `whisper` becomes visible by the machine saying so again. This is what + * replaces `toolStatuses(force)` and the 60s probe cache, both of which + * belonged to a server that could probe itself. + * + * The owner's switches — `canReadFrames`, `maxConcurrency`, `workspaceIds` — + * are not accepted here even though the protocol table lists them, because + * `registerWorker` refuses to overwrite them and it is right to: a worker + * restarting with its defaults must not hand itself back file-read + * permission somebody switched off in the console. They are the console's + * writes; this is the machine's report. + */ + @Post("register") + @WorkerRoute() + async register( + @Req() req: WorkerRequest, + @Body() + body: { + name?: string; + platform?: string; + version?: string; + tools?: ToolStatus[]; + enabled?: string[]; + }, + ) { + const worker = workerOf(req); + const saved = await registerWorker({ + userId: worker.userId, + name: body.name?.trim() || worker.name, + // The raw token off the header, hashed again inside the repo. The row is + // found by its hash, so this is the same machine by definition. + token: req.workerToken ?? "", + platform: body.platform ?? "", + version: body.version ?? "", + tools: Array.isArray(body.tools) ? body.tools : [], + enabled: Array.isArray(body.enabled) ? body.enabled.map(String) : [], + }); + return { + workerId: saved.id, + heartbeatMs: HEARTBEAT_MS, + claimMs: CLAIM_HOLD_MS, + }; + } + + /** + * Hand out work, waiting up to twenty-five seconds for some to appear. + * + * An empty answer is the normal answer. Most of the day there is nothing + * queued, and `{ jobs: [] }` after a quiet twenty-five seconds is a machine + * doing exactly what it should — so nothing here logs, throws or counts it + * as a failure. The alternative, a 500 or a warn per worker per half minute, + * would bury the one line that matters on the day something is actually + * wrong. + * + * The loop is written so it cannot outlive its own deadline or the + * connection. It re-checks the clock and the socket before every wait and + * again after it, and the timer it waits on is `unref`ed, so a worker that + * disconnects mid-poll leaves nothing behind holding the process open. There + * is no shared state between requests, which is what lets two API replicas + * serve claims for the same estate without knowing about each other — the + * queue's `FOR UPDATE SKIP LOCKED` is the only coordination there is. + */ + @Post("claim") + @WorkerRoute() + async claim(@Req() req: WorkerRequest, @Body() body: { max?: number }) { + const worker = workerOf(req); + const asked = Math.max(1, Math.min(8, Number(body?.max ?? 1) || 1)); + + let open = true; + const closed = () => { + open = false; + }; + req.on("close", closed); + + try { + const deadline = Date.now() + CLAIM_HOLD_MS; + for (;;) { + /* + * Recomputed every pass, not once. A worker holding one job can post + * its result on another connection while this one is waiting, and the + * next second of the same poll should then be allowed to hand it more + * work. `heldBy` is one indexed count; the claim itself is where the + * decision is actually made atomic. + */ + const room = worker.maxConcurrency - (await heldBy(worker.id)); + const jobs = room > 0 ? await claim(worker.id, worker.enabled, Math.min(asked, room)) : []; + if (jobs.length) { + return { + jobs: jobs.map((j) => ({ + id: j.id, + kind: j.kind, + payload: j.payload, + leaseUntil: j.leaseUntil, + })), + }; + } + if (!open || Date.now() + CLAIM_POLL_MS >= deadline) return { jobs: [] }; + await sleep(CLAIM_POLL_MS); + if (!open) return { jobs: [] }; + } + } finally { + req.off("close", closed); + } + } + + /** + * Extend the leases a machine still holds, and tell it what it has lost. + * + * `drop` is the whole reason this returns anything: it is how a worker + * learns a job was cancelled by a Stop button, or reaped out from under it + * while its laptop was asleep. The answer on the worker's side is to kill + * the child process rather than spend another four minutes producing + * something the server will refuse. Everything not kept is dropped, ids this + * server has never heard of included, because "I do not know that job" and + * "that job is not yours any more" call for the same action. + */ + @Post("heartbeat") + @WorkerRoute() + async heartbeat( + @Req() req: WorkerRequest, + @Body() body: { jobIds?: string[]; progress?: string }, + ) { + const worker = workerOf(req); + const ids = Array.isArray(body?.jobIds) ? body.jobIds.map(String) : []; + return heartbeat(worker.id, ids, LEASE_SECONDS); + } + + /** + * A finished job, from the machine that still holds it. + * + * `jobs.succeed` returns null for exactly one situation and it is not an + * error: the job is not this worker's, or its lease ran out. A laptop that + * wakes from sleep and posts a section that was reassigned twenty minutes + * ago must not overwrite the one that actually landed, so the write is + * refused, the result already in `run_sections` stands, and the worker is + * told 409 so that it stops rather than retrying into the same wall. + * + * A `write_section` result is handed to the runs service, which records the + * text, the machine that wrote it and the milliseconds it took, and then + * advances the run. Nothing here writes `run_sections` itself: the ordering, + * the dependency waves and what "written" means to a run all belong on that + * side, and a second implementation of them living in a controller is how + * the two quietly stop agreeing. + */ + @Post("jobs/:id/result") + @WorkerRoute() + async result( + @Req() req: WorkerRequest, + @Param("id") jobId: string, + @Body() body: { result?: Record }, + ) { + const worker = workerOf(req); + const result = body?.result ?? {}; + const job = await succeed(jobId, worker.id, result); + if (!job) throw new ConflictException(staleJob(jobId)); + + if (job.kind === "write_section") await sectionResult(job, result, worker.name); + return { ok: true }; + } + + /** + * A failure, from the machine that still holds the job. + * + * Same ownership and lease test, same 409, for the same reason. `retryable: + * false` is the worker saying "this will fail identically on any machine" — + * a refusal, a bad payload, a transport it cannot honour — and the server + * does not spend another attempt on those. A worker that cannot do what the + * payload asked must fail rather than quietly answer some other way; a + * silent downgrade is the outcome `docs/DECISIONS.md` calls worse than an + * error. + */ + @Post("jobs/:id/fail") + @WorkerRoute() + async failJob( + @Req() req: WorkerRequest, + @Param("id") jobId: string, + @Body() body: { error?: string; retryable?: boolean }, + ) { + const worker = workerOf(req); + const message = String(body?.error ?? "").trim() || "The machine did not say why."; + const job = await fail(jobId, worker.id, message, body?.retryable !== false); + if (!job) throw new ConflictException(staleJob(jobId)); + + /* + * A job that is finished failing has to say so on the section. + * + * `fail()` either puts the job back in the queue for another attempt or + * ends it, and only the ending is news to the run: a retryable failure is + * about to be tried on another machine, and marking the section failed in + * between would flash "Failed" on a row that is still being written. + * + * Without this the row keeps whatever `advance` set — `writing` — with + * nothing left in the queue that will ever move it. That is the run that + * sat on "Writing" for five days, which `docs/DECISIONS.md` records and + * this whole design exists to make impossible. + */ + if (job.state === "failed") await sectionFailed(job, message); + return { ok: true }; + } + + /** + * Frames from an ingesting machine, streamed to the server's disk. + * + * The server is the single source of truth for frames: the browser renders + * them, the worker that ingests and the worker that writes are usually + * different machines, and desktops are not backed up. So ingest uploads and + * write downloads, and this is the uploading half. + * + * Every filename is re-checked against `FRAME_FILE`. That regex has until + * now been guarding a query string; these names arrive over the network from + * a process the server does not control, and it is now guarding a write — + * `frame-01.jpg` is a file in this source's directory, `../../../id_rsa` is + * not. Nothing else about the name is trusted: the part's own path + * components are discarded before the test, not after. + * + * Read straight off the request with busboy and counted as it passes, the + * way `writeCapped` does, because a parser buffers and a cap taken from the + * content-length header is a number the client chose. + */ + @Post("sources/:id/frames") + @WorkerRoute() + async frames(@Req() req: WorkerRequest, @Param("id") sourceId: string) { + const source = await getSource(sourceId); + if (!source) throw new NotFoundException("No such source"); + + const dir = path.join(DATA_ROOT, "sources", sourceId); + await fs.mkdir(dir, { recursive: true }); + + return new Promise<{ files: string[] }>((resolve, reject) => { + const bb = busboy({ headers: req.headers }); + const files: string[] = []; + const writes: Promise[] = []; + let bad: Error | null = null; + + bb.on("file", (field, file, info) => { + if (field !== "file") { + file.resume(); + return; + } + const name = path.basename(String(info.filename ?? "")); + if (!FRAME_FILE.test(name)) { + bad ??= new BadRequestException(`Not a frame filename: ${name}`); + file.resume(); + return; + } + files.push(name); + writes.push(writeCappedFile(file, path.join(dir, name))); + }); + + bb.on("error", reject); + bb.on("finish", () => { + Promise.all(writes) + .then(() => { + if (bad) throw bad; + if (!files.length) throw new BadRequestException("No frame in that request"); + resolve({ files }); + }) + .catch(reject); + }); + + req.pipe(bb); + }); + } + + /** + * The transcript, as text. + * + * A PUT because it is the whole transcript or none of it — a transcriber + * either finished or it did not, and half a transcript on the row would be + * read by a prompt as if it were the video. + */ + @Put("sources/:id/transcript") + @WorkerRoute() + async transcript( + @Param("id") sourceId: string, + @Body() body: { text?: string } | string, + ) { + const text = typeof body === "string" ? body : String(body?.text ?? ""); + const source = await updateSource(sourceId, { transcript: text }); + if (!source) throw new NotFoundException("No such source"); + return { ok: true, length: text.length }; + } + + /** + * A frame, back down to the machine that is about to look at it. + * + * The browser's copy of this lives on `sources.controller.ts` behind a + * session, and it stays there. This is a second door rather than a widened + * one: making the existing route accept either credential would mean a + * stolen worker token could walk the content library through the same + * handler, and the whole argument for a separate token is that it reaches + * six endpoints and no more. + * + * `write_section` payloads name frames by this path. None of the three CLIs + * takes bytes and each takes a file differently, so the worker writes them + * to a temp directory of its own and hands over local paths — which is why + * the server can stay the only place the frames actually live. + */ + @Get("sources/:id/frames/:file") + @WorkerRoute() + async frame( + @Param("id") sourceId: string, + @Param("file") file: string, + @Res() res: Response, + ) { + // The same name check the browser's route makes, for a stronger reason: + // this one is reached by a process, not a person, and `..` in a path + // segment is what it would be reached with. + const bytes = await frameFile(sourceId, path.basename(file)); + if (!bytes) throw new NotFoundException("No such frame"); + res + .set({ + "content-type": "image/jpeg", + "cache-control": "private, max-age=31536000, immutable", + }) + .send(bytes); + } +} + +/** + * The sentence a worker gets with its 409. + * + * Written as prose rather than a code, because the machine reading it logs it + * and a person reads that log: "409" on a laptop at midnight says nothing, + * and this says the one thing that is true and reassuring — the work was not + * lost, it was somebody else's by then. + */ +function staleJob(jobId: string) { + return `Job ${jobId} is not yours any more — its lease ran out or it was reassigned. Nothing was written.`; +} + +/** + * A wait that cannot outlive the process. + * + * `unref` so a claim held mid-poll never keeps Node alive through a shutdown, + * and a plain resolving timer rather than an abortable one because the caller + * re-checks the socket on the other side of it; there is nothing to cancel. + */ +function sleep(ms: number) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); +} + +/** + * One part, to one file, counted as it goes. + * + * The same shape as `writeCapped` in services/ingest.ts — a counting Transform + * between the stream and the file, and the partial file removed on the way out + * — because the failure it prevents is the same one: a sender that declares + * ten megabytes and sends ten gigabytes, and a half-written JPEG left on disk + * that the picker will happily try to render. + */ +async function writeCappedFile(file: NodeJS.ReadableStream, dest: string) { + let seen = 0; + const cap = new Transform({ + transform(chunk: Buffer, _enc, done) { + seen += chunk.length; + if (seen > MAX_UPLOAD_BYTES) { + done(new BadRequestException("That upload is over the size cap.")); + return; + } + done(null, chunk); + }, + }); + + try { + await pipeline(file, cap, createWriteStream(dest)); + } catch (e) { + await fs.rm(dest, { force: true }).catch(() => {}); + throw e; + } +} diff --git a/lib/server/repos/runs.ts b/lib/server/repos/runs.ts index 42528a4..2280019 100644 --- a/lib/server/repos/runs.ts +++ b/lib/server/repos/runs.ts @@ -18,6 +18,15 @@ export type RunSection = { content: string; error: string; ms: number | null; + /** + * Which machine wrote it — a worker's name, or `server:api` when the + * workspace's API-key fallback did it. Empty until something writes it. + * + * `createdBy` on the run only says who pressed the button. Once the writing + * happens on somebody else's laptop, "which sections did we actually spend a + * subscription on, and whose" has no answer anywhere else in the database. + */ + wroteWith: string; /** When a person last changed this by hand. Null means the model's words. */ editedAt: string | null; updatedAt: string; @@ -58,6 +67,7 @@ const mapSection = (r: Row): RunSection => ({ content: String(r.content ?? ""), error: String(r.error ?? ""), ms: r.ms == null ? null : Number(r.ms), + wroteWith: String(r.wrote_with ?? ""), editedAt: r.edited_at == null ? null : iso(r.edited_at), updatedAt: iso(r.updated_at), }); @@ -174,7 +184,9 @@ export async function createRun(patch: { export async function setSection( runId: string, sectionId: string, - patch: Partial> & { + patch: Partial< + Pick + > & { /** true stamps it as hand-edited; false clears the stamp. */ edited?: boolean; }, @@ -190,6 +202,10 @@ export async function setSection( if (patch.content !== undefined) set("content", patch.content); if (patch.error !== undefined) set("error", patch.error); if (patch.ms !== undefined) set("ms", patch.ms); + // Written by the result handler from the job's own worker, never from + // anything the caller of an HTTP route could type: this column is the only + // record of whose subscription paid for the section. + if (patch.wroteWith !== undefined) set("wrote_with", patch.wroteWith); // Regenerating clears it: the words are the model's again, so saying a // person wrote them would be a lie the reader repeats on every visit. if (patch.edited === true) sets.push("edited_at = now()"); @@ -234,32 +250,36 @@ export async function requeueFailed(rid: string) { } /** - * Sections left in "writing" with nobody writing them. + * Put one run's in-flight sections back in the queue, because a person said + * stop. * - * A row says "writing" from the moment a section is handed to the model to - * the moment it lands. If the process dies in between — a restart, a deploy, - * a killed terminal — the row keeps saying it, and the driver's own memory of - * what it was doing is gone with the process. So a run sat on "Writing 01" - * for six minutes with nothing behind it, and neither Retry (which only takes - * failed rows) nor Write the rest (which skips rows still "writing") could - * touch it. + * The run id is required, and that is the whole change here. This used to take + * an optional id, and with none it swept EVERY `writing` row in the database — + * which `resumeOrphans()` called at boot, reasoning that a writing row must be + * dead because the process that started it was. That reasoning was true only + * while there was exactly one process and the model was its child. It is false + * the moment a section is being written on somebody's laptop: a deploy or a + * crash would requeue a section a machine is four minutes into, the section + * gets written twice, the second result overwrites the first, and both spent a + * subscription. With two API replicas every boot steals the other's work. * - * Back to queued: the truth is that it has not been written. Scoped to one - * run when the driver starts on it, or to every run at boot, when no driver - * can possibly exist. Returns the run ids touched so boot can pick them up. + * Migration 0015 swept the old `writing` rows once, and that was the last time + * anything may do it on an assumption. What decides now is `jobs.lease_until`, + * per job, on evidence — a lease that ran out. This function survives for the + * one case that is not an assumption at all: `stopRun` has just cancelled this + * run's jobs, so nothing is writing these sections any more, and saying they + * are queued is simply the truth about them. */ -export async function requeueOrphans(runId?: string) { - const rows = runId - ? await q<{ run_id: string }>( - `UPDATE run_sections SET state = 'queued', updated_at = now() - WHERE run_id = $1 AND state = 'writing' RETURNING run_id`, - [runId], - ) - : await q<{ run_id: string }>( - `UPDATE run_sections SET state = 'queued', updated_at = now() - WHERE state = 'writing' RETURNING run_id`, - ); - return [...new Set(rows.map((r) => r.run_id))]; +export async function requeueWriting(runId: string) { + const rows = await q<{ section_id: string }>( + `UPDATE run_sections SET state = 'queued', updated_at = now() + WHERE run_id = $1 AND state = 'writing' RETURNING section_id`, + [runId], + ); + if (rows.length) { + await q("UPDATE runs SET updated_at = now() WHERE id = $1", [runId]); + } + return rows.length; } /** diff --git a/lib/server/services/ingest.ts b/lib/server/services/ingest.ts index db5b247..b3256b3 100644 --- a/lib/server/services/ingest.ts +++ b/lib/server/services/ingest.ts @@ -61,7 +61,7 @@ const WHISPER_TIMEOUT_MS = 600_000; * ones cut at a time somebody asked for. Anything else is a path traversal * being tried on a query string. */ -const FRAME_FILE = /^frame-(\d{2}|at-\d+)\.jpg$/; +export const FRAME_FILE = /^frame-(\d{2}|at-\d+)\.jpg$/; export class IngestError extends Error { constructor( diff --git a/lib/server/services/runs.ts b/lib/server/services/runs.ts index f12be85..c0091c6 100644 --- a/lib/server/services/runs.ts +++ b/lib/server/services/runs.ts @@ -1,26 +1,39 @@ import { markUsed, resolve as resolvePack } from "@/lib/server/repos/packs"; -import { write } from "@/lib/server/brains"; +import { findBrain, type Transport } from "@/lib/server/brains"; import { getSettings } from "@/lib/server/repos/settings"; import { sourceBrief } from "@/lib/server/services/ingest"; import { fetchSource } from "@/lib/server/services/webfetch"; -import { systemPrompt, userPrompt } from "@/lib/server/prompt"; +import { plan, systemPrompt, userPrompt } from "@/lib/server/prompt"; import { getWorkspace } from "@/lib/server/repos/workspaces"; import { getTopic, updateTopic } from "@/lib/server/repos/series"; +import { getSource } from "@/lib/server/repos/sources"; +import { + cancel as cancelJobs, + enqueue, + jobsForRun, + type Job, +} from "@/lib/server/repos/jobs"; +import { listWorkers, routeFor } from "@/lib/server/repos/workers"; import { createRun as insertRun, + deleteRun as deleteRunRow, getRun, - requeueFailed, - requeueOrphans, + requeueWriting, setSection, type Run, } from "@/lib/server/repos/runs"; +import type { Pack, PackSectionDef } from "@/lib/packs/enbn-website"; /** - * Starting runs, and writing their sections. + * Starting runs, and getting their sections written. * * The rules that are not the database's job and not the route's: which inputs a * pack requires, what a section is allowed to run before, and what finishing a * section means for the topic that started it. + * + * Nothing here writes a section itself any more. The server decides — the + * order, the prompt, the model, the transport — and a job carries that decision + * to whichever machine has the CLI on it. See docs/WORKER.md. */ export class RunError extends Error { @@ -196,170 +209,524 @@ async function evidenceFor(run: Run): Promise { } /** - * Replace a section's text with what a person typed. + * How long one section may take on a machine before the worker gives up. * - * Its own function rather than a flag on writeSection: nothing is generated, - * nothing is charged, and the dependency checks that guard a real run would be - * meaningless here — you are allowed to fix a sentence in section 9 without - * section 8 being finished. + * The same ten minutes every transport in `brains.ts` already defaults to, + * named here because the server is now the thing that says it: the worker is + * told the timeout rather than choosing one, so a section cannot take longer + * on one teammate's laptop than on another's because of a different default. */ +const SECTION_TIMEOUT_MS = 600_000; + +/** + * How long a job may sit waiting for a machine that is capable but shut. + * + * Somebody opening their laptop is the normal resolution and it should just + * start. Past this the reaper fails the job with the sentence written at + * enqueue, which names the machine — because a run that reads "waiting" all + * night, with nobody told why, is the six-minutes-on-Writing-01 bug wearing a + * bigger hat. + */ +const WAIT_FOR_MACHINE_MS = 15 * 60_000; + /** - * Runs being driven to completion, by run id. + * What a section needs on the machine that writes it, and how it is reached. + * + * The transport is decided HERE and travels in the payload. `write()` used to + * re-derive it by calling `brainStatuses()`, which calls `toolStatuses()`, + * which probes whichever machine is asking — meaningless on a server with no + * CLI on it, and forever answering "nothing installed". The worker reported + * its tools when it registered; the server picks from what it was told and + * says so, and a worker that cannot honour what it was told must FAIL rather + * than quietly answer some other way. A silent CLI-to-API downgrade is the one + * outcome docs/DECISIONS.md calls worse than an error: an answer produced + * without the evidence, presented as though it had been read. * - * On `globalThis` because Next re-evaluates modules on every edit in dev, and - * a module-level map would forget what is in flight — starting a second loop - * over sections the first one is already writing, against a model that is one - * local process. + * `needs` is the tool id the claim query intersects against a worker's own + * switched-on list, so a machine without that CLI never even sees the job. + * Ollama has no command to install — it is an HTTP server on the machine that + * runs it — so it needs nothing and is reached over its own local API. */ -const driving = globalThis as unknown as { __contentosDriving?: Set }; -const inFlight = () => (driving.__contentosDriving ??= new Set()); +function transportFor(brainId: string): { needs: string[]; transport: Transport } { + const def = findBrain(brainId); + return def?.command + ? { needs: [def.command], transport: "cli" } + : { needs: [], transport: "api" }; +} -export const isDriving = (runId: string) => inFlight().has(runId); +/** + * How to install a tool nothing on this estate has. + * + * Read off any machine that has ever reported the tool, present or not: a + * `ToolStatus` carries its own `install` string, so the sentence a person is + * shown comes from the same table the probe wrote rather than from a second + * copy of the install commands kept here and left to drift. When no machine + * has ever heard of it there is nothing honest to say beyond its name. + */ +async function installHint(need: string): Promise { + const workers = await listWorkers(); + for (const w of workers) { + const tool = w.tools.find((t) => t.id === need); + if (tool?.install) return tool.install; + } + return ""; +} /** - * Write everything that is left, in dependency order, without being asked - * twelve times. + * The stills a section is allowed to look at, as URLs on the server. * - * Pressing Run used to create a queue and stop. What wrote the sections was a - * loop in the browser tab, so it needed a second press to start, and closing - * the tab — or a hot reload, or a navigation — stopped it wherever it was. - * The work belongs on the server: one section at a time, because the model - * behind it is a single local process, and it carries on whether or not - * anybody is watching the page. + * Only sections with no dependencies get them, and that is a decision about + * money rather than about capability: a section with dependencies is handed + * the finished text of everything upstream, which is the evidence already read + * and turned into words, so downloading eight JPEGs to a laptop again per + * section would pay the transfer twelve times to learn nothing new. The + * first-wave sections are the ones written for somebody who has watched the + * reel. * - * Returns immediately. Progress is read from the run itself, which is where - * every section's state has always been recorded. + * URLs, not paths. `sourceBrief()` deliberately stopped naming + * `.data/sources//` because that is the server's filesystem and the model + * is on somebody's desktop; the worker downloads these into a temp directory + * of its own and hands its CLI local absolute paths, each in the way that CLI + * takes a file. */ +async function framesFor(run: Run, def: PackSectionDef) { + if (!run.sourceId || def.dependsOn.length) return undefined; + const source = await getSource(run.sourceId); + if (!source?.frames.length) return undefined; + return source.frames.map((f) => ({ + // The worker's door, not the browser's. `/api/sources/...` is the same + // bytes behind a session cookie, which a headless machine holding a bearer + // token does not have and must not be given — the argument for a separate + // worker credential is that it reaches the queue and nothing else. + url: `/api/workers/sources/${source.id}/frames/${f.file}`, + at: f.at, + })); +} + /** - * Pick up whatever a previous process left mid-write. + * Everything the machine writing this section is told, and nothing else. * - * Called once, as the API starts: every "writing" row belongs to a driver that - * died with the last process, so each goes back to queued and its run is - * driven again. A run that was being written when the server restarted - * carries on, which is what "carries on whether or not anybody is watching" - * has to mean across a deploy. + * The server has already resolved the pack, checked the dependencies, + * gathered the finished upstream text, read the website or the source brief, + * and run `systemPrompt()` and `userPrompt()`. What travels is their output: + * text and a command. No pack, no rules, no brand voice, no dependency graph — + * business logic shipped to fifteen desktops would need fifteen updates to + * change a prompt, and the prompt changes weekly. */ -export async function resumeOrphans() { - const runs = await requeueOrphans(); - for (const id of runs) driveRun(id); - return runs; +async function payloadFor( + run: Run, + pack: Pack, + def: PackSectionDef, + done: Record, + brief: string, + brainId: string, + transport: Transport, + allowFrameRead: boolean, +) { + const frames = await framesFor(run, def); + return { + brain: brainId, + transport, + system: systemPrompt(pack, run.inputs, run.brandVoice), + user: userPrompt(def, pack, run.inputs, done, brief), + tier: def.tier, + timeoutMs: SECTION_TIMEOUT_MS, + ...(frames ? { frames } : {}), + allowFrameRead, + }; } -export function driveRun(runId: string): { started: boolean } { - if (isDriving(runId)) return { started: false }; - inFlight().add(runId); - - void (async () => { - try { - /* - * Pressing Run again is a retry. - * - * Failures are skipped for the rest of the drive they happen in, by the - * rule below. Carrying that skip into the NEXT drive would make Run do - * nothing at all on a run that failed — the one time you are most likely - * to press it — so the failures go back in the queue here, once, at the - * point somebody asks for the run again. - */ - await requeueFailed(runId); - /* - * And anything left "writing" by a driver that is no longer here. This - * driver is the only one for this run — isDriving said so a moment ago — - * so a writing row is an orphan, not a colleague. - */ - await requeueOrphans(runId); - - for (;;) { - const run = await getRun(runId); - if (!run) return; - - const pack = await resolvePack(run.packSlug); - if (!pack) return; - - const done = new Set( - run.sections.filter((x) => x.state === "done").map((x) => x.id), - ); - - /* - * The next one whose dependencies are all written. - * - * A failed section is skipped rather than retried: it has already had - * its turn, and looping on it would spend the model's time forever on - * the one thing that does not work. Anything depending on it simply - * never becomes eligible, and the run ends with those still queued — - * which is the truth, and visible on the page. - */ - const next = pack.sections.find((def) => { - const row = run.sections.find((x) => x.id === def.id); - return ( - row && - row.state !== "done" && - row.state !== "failed" && - def.dependsOn.every((d) => done.has(d)) - ); - }); - if (!next) return; - - try { - await writeSection(runId, next.id); - } catch { - // writeSection has already recorded the failure on the row; the loop - // moves to whatever else is ready rather than stopping the lot. - } - } - } finally { - inFlight().delete(runId); +/** + * Enqueue everything this run is ready to write, and return. + * + * What replaced `driveRun`, and the shape of the replacement is the point. + * `driveRun` was a `for(;;)` that picked the next eligible section, awaited the + * model, and went round again — so one Node process held the run for its whole + * length, and a `globalThis` Set was the only thing stopping a second loop + * starting over sections the first was already writing. That worked exactly as + * long as the model was a child process of the loop. It cannot survive the + * split: the model now runs on somebody's laptop, the process that would be + * holding the run has nothing to hold, a deploy or a crash loses whatever the + * loop remembered, and a Set in one replica's memory is a lock the other + * replica cannot see. + * + * So nothing holds a run. This is called when Run is pressed and again every + * time a result lands, it looks at the database, and it stops. Restart the API + * mid-run and nothing is lost, because nothing was in memory to lose. + * + * Everything in a wave is enqueued at once. `plan()` has computed the + * dependency waves since the beginning and has never been called by anything — + * it becomes the real scheduler here. One-at-a-time was a property of the model + * being one local process, not of the templates, and the parallelism that + * replaces it comes from more machines rather than from one machine doing more: + * per-machine limits are `workers.max_concurrency`, enforced at the claim, and + * are none of this function's business. + * + * Enqueueing twice is safe and is expected — two tabs, a double Run, a result + * landing while somebody presses it. `jobs_one_per_section` is a unique index + * over the live states, so both attempts converge on the same job rather than + * paying two subscriptions to write one section. + */ +export async function advance(runId: string): Promise<{ + /** Sections that now have a job on the queue. */ + enqueued: string[]; + /** Sections queued behind a machine that is not awake, with its name. */ + waiting: { sectionId: string; machine: string }[]; + /** Sections nothing on this estate can ever write. Already failed. */ + unroutable: string[]; +}> { + const out = { + enqueued: [] as string[], + waiting: [] as { sectionId: string; machine: string }[], + unroutable: [] as string[], + }; + + const run = await getRun(runId); + if (!run) return out; + const pack = await resolvePack(run.packSlug); + if (!pack) return out; + + const rows = new Map(run.sections.map((s) => [s.id, s])); + const done: Record = {}; + for (const s of run.sections) { + if (s.state === "done" && s.content) done[s.id] = s.content; + } + + /* + * One live job per section, read once. + * + * The unique index would refuse a duplicate anyway, but a job already + * claimed by a machine four minutes into writing must not even be considered + * eligible: the payload would be rebuilt, the evidence fetched again, and + * `advance` would report having started something it did not. + */ + const live = new Set( + (await jobsForRun(runId)) + .filter((j) => j.state === "queued" || j.state === "claimed") + .map((j) => j.sectionId ?? ""), + ); + + /* + * The waves, and the sections in them that are ready. + * + * A section is ready when every dependency is `done` — not merely enqueued. + * `plan()` gives the order; the run's own rows say how far it has actually + * got, which is the half `plan()` cannot know. A failed section is left + * alone: it has had its turn, and anything depending on it never becomes + * eligible, so the run ends with those still queued, which is the truth and + * visible on the page. Retry is `requeueFailed`. + */ + const waves = plan(pack); + const scheduled = new Set(waves.flat()); + const ready: PackSectionDef[] = []; + for (const wave of waves) { + for (const sid of wave) { + const def = pack.sections.find((s) => s.id === sid); + const row = rows.get(sid); + if (!def || !row) continue; + if (row.state !== "queued") continue; + if (live.has(sid)) continue; + if (!def.dependsOn.every((d) => done[d])) continue; + ready.push(def); } - })(); + } - return { started: true }; + /* + * A section `plan()` never placed depends on something that does not exist, + * so no amount of waiting will make it eligible. Failing it says so; leaving + * it queued would be a row that reads "waiting" until somebody deletes the + * run. + */ + for (const row of run.sections) { + if (row.state === "queued" && !scheduled.has(row.id)) { + await setSection(runId, row.id, { + state: "failed", + error: + "This section depends on something the template does not contain, so it can never become ready. Edit the template's dependencies.", + }); + } + } + + if (!ready.length) return out; + + const settings = await getSettings(); + const brainId = settings.brain; + const { needs, transport } = transportFor(brainId); + + /* + * Where this could run, asked once for the whole wave. + * + * Every section in a run wants the same brain and therefore the same tool, + * so one question answers all of them, and the alternative is a scan of the + * machines per section. + */ + const route = await routeFor(needs); + + /* + * May the machine grant its CLI `Read` for the frames? + * + * The server sends what it knows and the worker checks its own switch again + * before granting anything, because the grant happens on somebody's own + * filesystem and its owner gets the last word. Which machine will claim the + * job is not known here, so this says "no machine that could take this + * objects" rather than naming one — a worker with the switch off refuses on + * its own behalf, which is exactly the arrangement docs/WORKER.md asks for. + */ + const allowFrameRead = route.capable.some((w) => w.canReadFrames); + + // One fetch of the evidence for the whole wave, not one per section: it is + // the same website and the same reel for every section in a run. + const brief = await evidenceFor(run); + + for (const def of ready) { + const payload = await payloadFor( + run, + pack, + def, + done, + brief, + brainId, + transport, + allowFrameRead, + ); + + /* + * Case 1: nothing on this estate has ever advertised the tool. + * + * Unroutable at birth, and the section fails with the sentence naming what + * to install. Not queued: a queue entry no machine can ever claim is a run + * that reads "Writing" forever, and forever is not a state a person can + * act on. `unroutable` is deliberately not `failed` on the job, because an + * install command is a different answer from "the model refused". + */ + if (route.missing.length) { + const need = route.missing[0]; + const install = await installHint(need); + const message = install + ? `No machine here has ${need}. Install it on one — ${install} — then press Retry.` + : `No machine here has ${need}, and none has ever reported how to install it.`; + await enqueue({ + kind: "write_section", + workspaceId: run.workspaceId, + runId, + sectionId: def.id, + needs, + payload, + state: "unroutable", + error: message, + }); + await setSection(runId, def.id, { state: "failed", error: message }); + out.unroutable.push(def.id); + continue; + } + + /* + * Case 2: a machine could run it, but none of them is awake. + * + * Queue it with a deadline and a sentence that names the machine, so that + * somebody opening their laptop simply starts it — the normal resolution — + * and a laptop that stays shut ends as a stated failure rather than as a + * run that never moved. The section stays `queued`: it is not being + * written, and the page reads which machine it is waiting for off the job. + */ + const asleep = route.live.length === 0; + const { created } = await enqueue({ + kind: "write_section", + workspaceId: run.workspaceId, + runId, + sectionId: def.id, + needs, + payload, + ...(asleep + ? { + waitUntil: new Date(Date.now() + WAIT_FOR_MACHINE_MS), + error: `Waiting for ${route.capable + .map((w) => w.name) + .join(" or ")}, which has not been seen recently.`, + } + : {}), + }); + + if (!created) continue; + + if (asleep) { + out.waiting.push({ + sectionId: def.id, + machine: route.capable.map((w) => w.name).join(" or "), + }); + continue; + } + + /* + * Case 3: ordinary queueing, and the row says writing. + * + * `writing` the moment the job exists rather than when a machine claims + * it: the queue is the writing now, the wait is normally about a second, + * and a section that read "queued" while a job was already claimed would + * make `statusOf` call a moving run idle. The failure that used to hide + * behind this word — a row saying writing with nothing behind it — is + * covered instead by the lease, which fails the job on evidence. + */ + await setSection(runId, def.id, { state: "writing", error: "" }); + out.enqueued.push(def.id); + } + + // The topic stops being an idea the moment something is being written for + // it. After the enqueues, not before: a topic marked generating for a run + // that turned out to be entirely unroutable would be stuck on a state + // nothing takes it out of. + if (run.topicId && out.enqueued.length) { + await updateTopic(run.topicId, { status: "generating" }); + } + + return out; } -export async function editSection( - runId: string, - sectionId: string, - content: string, -): Promise { +/** + * A `write_section` result has come back from a machine. Write it, then look + * for what that unblocks. + * + * **This is the function the worker-facing controller calls**, once + * `jobs.succeed()` has accepted the result — that is, once the queue has + * confirmed the job still belonged to the machine posting it. Do not call this + * on a result `succeed()` returned null for: null means a laptop woke up and + * posted a section that was reassigned twenty minutes ago, and the copy in + * `run_sections` is the one that stands. + * + * `ms` comes from the worker, and must. Measured on the server it would + * include queue wait, so a section would report six minutes because somebody's + * laptop was shut — a number that reads as "the model was slow" and is not + * about the model at all. + * + * `wroteWith` is the machine's name, from the job's worker rather than from + * anything in the request body. It is the only record of whose subscription + * paid for this section. + * + * Returns the run as it now stands, and has already called `advance` — so the + * next wave is on the queue by the time this resolves. + */ +export async function sectionResult( + /** The job the queue just accepted the result for. */ + job: Pick, + /** What the machine posted: `{ text, ms, using }`. */ + result: Record, + /** The machine's name, from the authenticated worker — never from the body. */ + workerName: string, +): Promise { + if (job.kind !== "write_section" || !job.runId || !job.sectionId) return null; + const runId = job.runId; + const sectionId = job.sectionId; + const run = await getRun(runId); - if (!run) throw new RunError("No such run", 404); - if (!run.sections.some((s) => s.id === sectionId)) { - throw new RunError("No such section on this run", 404); + if (!run) return null; + + const text = typeof result.text === "string" ? result.text : ""; + const ms = typeof result.ms === "number" ? result.ms : undefined; + + /* + * Nothing back is a failure, not an empty section. + * + * A `write_section` result arrives whole or not at all, so a job that + * "succeeded" with no text did not write anything — and saving that as done + * would put an empty, approved-looking section on the page and let every + * section downstream be written against it. + */ + if (!text.trim()) { + return sectionFailed(job, "The machine answered with no text.", ms); } await setSection(runId, sectionId, { - content, - // Saved text is finished text, even into a section that had failed: what - // is on screen is now what a person put there. state: "done", + content: text, error: "", - edited: true, + ...(ms === undefined ? {} : { ms }), + wroteWith: workerName, + // These are the model's words again — whatever a person had typed here has + // just been replaced, so the "edited by hand" mark goes with it. + edited: false, }); - return (await getRun(runId))!; + + const after = await getRun(runId); + // Finished means every section landed — which is a fact about the run, so the + // topic learns it from the run rather than from whoever answered last. + if (run.topicId && after?.sections.every((s) => s.state === "done")) { + await updateTopic(run.topicId, { status: "done" }); + } + + await advance(runId); + return await getRun(runId); +} + +/** + * A `write_section` job failed on the machine that held it. + * + * Also for the worker-facing controller, and the counterpart to the one above. + * Call it only when `jobs.fail()` has come back with a job in a TERMINAL state: + * a retryable failure with attempts left goes back to `queued` for another + * machine, and the section is still being written — marking it failed then + * would put "Failed" on a row somebody is about to write successfully. + * + * `advance` is called either way, because a section that is genuinely finished + * with may still have left siblings in its wave that are now ready. + */ +export async function sectionFailed( + job: Pick, + error: string, + ms?: number, +): Promise { + if (job.kind !== "write_section" || !job.runId || !job.sectionId) return null; + const runId = job.runId; + + const run = await getRun(runId); + if (!run) return null; + + await setSection(runId, job.sectionId, { + state: "failed", + error: error || "The model did not answer", + ...(ms === undefined ? {} : { ms }), + }); + + /* + * Back to an idea, not left mid-write. + * + * The run keeps the failure — the section says so, and the row on Content + * reads "Failed" — while the topic returns to the pile it can be started from + * again. A topic stuck on "generating" after a run died is the state nothing + * could get it out of. + */ + if (run.topicId) await updateTopic(run.topicId, { status: "idea" }); + + await advance(runId); + return await getRun(runId); } /** - * Write one section. + * Write one section, now, because a person asked for that one. * - * Dependencies are checked here rather than trusted: the client drives the - * order, so nothing stops it asking for the Bangla script first, and the model + * The same enqueue as `advance`, scoped to a section somebody pointed at, and + * the dependency check is kept rather than trusted: the client chooses the + * order, so nothing stops it asking for the Bangla script first, and a model * handed an empty "ALREADY GENERATED" block would invent the English script it * was meant to be translating — which reads fine and is wrong. + * + * It goes through `advance` rather than building its own payload so that there + * is exactly one place that decides transport, routing and frames. Putting the + * row back to `queued` first is what makes the section eligible there, and is + * also what "write this one again" means for a section that is already done. */ -export async function writeSection(runId: string, sectionId: string): Promise { +export async function queueSection(runId: string, sectionId: string): Promise { const run = await getRun(runId); if (!run) throw new RunError("No such run", 404); const pack = await resolvePack(run.packSlug); const def = pack?.sections.find((s) => s.id === sectionId); - if (!pack || !def || !run.sections.some((s) => s.id === sectionId)) { - throw new RunError("No such section", 404); - } + const row = run.sections.find((s) => s.id === sectionId); + if (!pack || !def || !row) throw new RunError("No such section", 404); - const done: Record = {}; - for (const s of run.sections) { - if (s.state === "done" && s.content) done[s.id] = s.content; - } - - const waiting = def.dependsOn.filter((d) => !done[d]); + const done = new Set( + run.sections.filter((s) => s.state === "done" && s.content).map((s) => s.id), + ); + const waiting = def.dependsOn.filter((d) => !done.has(d)); if (waiting.length) { const names = waiting.map( (d) => pack.sections.find((s) => s.id === d)?.title ?? d, @@ -367,67 +734,93 @@ export async function writeSection(runId: string, sectionId: string): Promise { + const stopped = (await cancelJobs(runId)).length; + const requeued = await requeueWriting(runId); + const run = await getRun(runId); + // Back on the pile it can be started from, the same as a failure: a topic + // left on "generating" after somebody pressed Stop has nothing to move it. + if (run?.topicId && stopped) await updateTopic(run.topicId, { status: "idea" }); + return { stopped, requeued }; +} - const text = await write(settings.brain, { - system: systemPrompt(pack, run.inputs, run.brandVoice), - user: userPrompt(def, pack, run.inputs, done, brief), - tier: def.tier, - }); +/** + * Is anything actually working on this run? + * + * Read from the queue, not from a Set in this process's memory. `isDriving` + * could only ever see what one Node process was doing, so with two API + * replicas — or an API and a worker — it answered "no" about work that was + * very much happening. + */ +export async function isRunning(runId: string): Promise { + const jobs = await jobsForRun(runId); + return jobs.some((j) => j.state === "queued" || j.state === "claimed"); +} - await setSection(runId, sectionId, { - state: "done", - content: text, - error: "", - ms: Date.now() - started, - // These are the model's words again — whatever a person had typed here - // has just been replaced, so the "edited by hand" mark goes with it. - edited: false, - }); +/** + * Delete a run, and stop its work first. + * + * The order matters. `runs` cascades to `jobs`, so deleting the row first + * would leave a machine four minutes into writing a section for a run that no + * longer exists, posting a result the server can only throw away. Cancelling + * first means its next heartbeat tells it to stop. + */ +export async function removeRun(runId: string): Promise { + await cancelJobs(runId); + return deleteRunRow(runId); +} - const after = (await getRun(runId))!; - // Finished means every section landed — which is a fact about the run, so - // the topic learns it from the run rather than from whoever clicked last. - if (run.topicId && after.sections.every((s) => s.state === "done")) { - await updateTopic(run.topicId, { status: "done" }); - } - return after; - } catch (e) { - await setSection(runId, sectionId, { - state: "failed", - error: e instanceof Error ? e.message : "The model did not answer", - ms: Date.now() - started, - }); - /* - * Back to an idea, not left mid-write. - * - * The run keeps the failure — the section says so, and the row on Content - * reads "Failed" — while the topic returns to the pile it can be started - * from again. A topic stuck on "generating" after a run died is the state - * nothing could get it out of. - */ - if (run.topicId) await updateTopic(run.topicId, { status: "idea" }); - throw new RunError( - e instanceof Error ? e.message : "The model did not answer", - 502, - ); +/** + * Replace a section's text with what a person typed. + * + * Its own function rather than a flag on a write: nothing is generated, + * nothing is charged, and the dependency checks that guard a real run would be + * meaningless here — you are allowed to fix a sentence in section 9 without + * section 8 being finished. + */ +export async function editSection( + runId: string, + sectionId: string, + content: string, +): Promise { + const run = await getRun(runId); + if (!run) throw new RunError("No such run", 404); + if (!run.sections.some((s) => s.id === sectionId)) { + throw new RunError("No such section on this run", 404); } + + await setSection(runId, sectionId, { + content, + // Saved text is finished text, even into a section that had failed: what + // is on screen is now what a person put there. + state: "done", + error: "", + edited: true, + }); + return (await getRun(runId))!; } + From 721372c7f99c42899832f949ef8b4a50d80895dd Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 19:50:28 +0600 Subject: [PATCH 07/30] Test the one thing tsc cannot see: who gets which job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- docs/DECISIONS.md | 35 +++++++ tests/queue.test.ts | 233 ++++++++++++++++++++++++++++++++++++++++++++ vitest.config.mts | 16 ++- 3 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 tests/queue.test.ts diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d6497bd..f24656c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -140,6 +140,25 @@ that was being written when the server went down was meant to finish. same reason: a topic that fails to update must not strand the section in writing with the failure recorded nowhere. +**Superseded by the worker split — the goal stands, the mechanism is gone.** +The reasoning above rests on one premise: a `writing` row must be dead, +because the only thing that could be writing it was this process. That was +true while the model was a child of the API. It stopped being true the day +the work moved to somebody's laptop, and it inverted — the boot sweep now +requeues a section a machine is four minutes into, pays a second +subscription for it, and lets the slower answer overwrite the one that +landed. With two API replicas, every boot steals the other's work. + +`resumeOrphans`, `driveRun` and `isDriving` are deleted; +`requeueOrphans(runId?)` lost its no-argument form, so the global sweep is +not a thing the code can express any more rather than a thing nothing +calls. A run still survives a restart, and survives more than it did — it +now survives the API being down entirely, because no process holds it. What +recovers it is the lease in `jobs`, expiring on evidence rather than on an +assumption about processes: see `docs/WORKER.md`, "Liveness and recovery". +`requeueFailed` is untouched; it answers "the person pressed Retry", which +is a different question and still the right one. + ## An unreachable API says so, in red, at the top With the backend on its own port, the console has a failure it never had @@ -312,6 +331,22 @@ Do not grow this into component tests. The screens are checked by opening them (`docs/BROWSER.md`); what these are for is the logic that fails silently, on one row, days later. +**One database test, and only one.** `tests/queue.test.ts` covers the claim +query and the reaper. The "no database" rule holds everywhere it was written +for — a repo function that reads a row back is checked by using the app — but +the queue is a different kind of thing: it decides, across machines that cannot +see each other, who writes which section, and it gets that wrong silently. Two +laptops writing the same section pay two subscriptions for one answer, and a +job no worker can see leaves a run reading "Writing" for ever, which is the +same bug `statusOf` is up there for. Three real defects came out of writing it: +`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 that were still running; +and the payload sweep rewrote every historical row on every tick. + +It builds and drops its own database, so it cannot reach the workspace the dev +server is using, and it skips itself when no Postgres answers. Do not add a +second one without an argument this strong. + ## A template travels as a file, and arrives as a draft Export writes the whole brief — rules, purpose, every section and its prompt, diff --git a/tests/queue.test.ts b/tests/queue.test.ts new file mode 100644 index 0000000..4ffdcd1 --- /dev/null +++ b/tests/queue.test.ts @@ -0,0 +1,233 @@ +import { Client } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +/** + * The queue, against a real Postgres. + * + * The only test here that touches a database, and the exception is argued + * rather than assumed. Everything else in `tests/` is a pure function, because + * that is what fails silently days later on one row — but the claim query is + * not a pure function and it carries more risk than any of them. It decides, + * across machines that cannot see each other, who writes which section; the + * failure modes are two laptops writing the same thing and paying twice, or a + * job no machine can see sitting queued forever. Neither is visible to + * `tsc`, neither shows up in a screenshot, and both cost money the first time + * they happen in front of the team. + * + * Three bugs found while writing it are the argument for keeping it: + * `bool_and` over zero rows is NULL rather than true, so a job needing nothing + * was invisible to every worker; the reaper failed jobs a machine was still + * running; and the payload sweep rewrote every historical row on every tick. + * + * It builds and drops its OWN database. Nothing here can reach the workspace + * the dev server is using, and it skips itself entirely when there is no + * Postgres to talk to, so `npm run check` on a laptop with the container down + * is still green. + */ + +const BASE = + process.env.DATABASE_URL ?? + "postgres://contentos:contentos@localhost:5437/contentos"; +const TEST_DB = "contentos_queue_test"; +const TEST_URL = BASE.replace(/\/[^/?]+(\?|$)/, `/${TEST_DB}$1`); +const ADMIN_URL = BASE.replace(/\/[^/?]+(\?|$)/, "/postgres$1"); + +/** Whether there is a Postgres to talk to at all, decided once, quickly. */ +async function reachable() { + const c = new Client({ connectionString: ADMIN_URL, connectionTimeoutMillis: 1500 }); + try { + await c.connect(); + await c.end(); + return true; + } catch { + return false; + } +} + +const up = await reachable(); + +async function admin(sql: string) { + const c = new Client({ connectionString: ADMIN_URL }); + await c.connect(); + try { + await c.query(sql); + } finally { + await c.end(); + } +} + +// Bound before the repos are imported: `lib/server/db/client.ts` reads +// DATABASE_URL once, at module load, so the import has to come after this. +process.env.DATABASE_URL = TEST_URL; + +const dbMod = () => import("@/lib/server/db/client"); +const jobsMod = () => import("@/lib/server/repos/jobs"); +const workersMod = () => import("@/lib/server/repos/workers"); + +let uid = ""; +let n = 0; +/** A token nothing else in this file will collide with. */ +const token = () => `tok-${uid}-${(n += 1)}`; + +describe.skipIf(!up)("the queue", () => { + beforeAll(async () => { + await admin(`DROP DATABASE IF EXISTS ${TEST_DB}`); + await admin(`CREATE DATABASE ${TEST_DB}`); + + const { ready, q, id } = await dbMod(); + // The real migration list, in order, against an empty database — which is + // also the only place the 0015 SQL is ever executed by a test. + await ready(); + + uid = id("usr"); + await q( + "INSERT INTO users (id, email, name, password_hash, owner) VALUES ($1, $2, 'Test', 'x:y', true)", + [uid, `${uid}@test.invalid`], + ); + }, 60_000); + + afterAll(async () => { + if (!up) return; + const { pool } = await dbMod(); + await pool().end(); + await admin(`DROP DATABASE IF EXISTS ${TEST_DB}`); + }, 30_000); + + it("applies every migration, and the worker split's columns land", async () => { + const { q } = await dbMod(); + const names = await q<{ name: string }>("SELECT name FROM migrations ORDER BY name"); + expect(names.at(-1)!.name).toBe("0015_workers"); + + const cols = await q<{ t: string; c: string }>( + `SELECT table_name AS t, column_name AS c FROM information_schema.columns + WHERE table_schema = 'public'`, + ); + const have = new Set(cols.map((r) => `${r.t}.${r.c}`)); + for (const want of [ + "workers.token_hash", + "workers.workspace_ids", + "workers.can_read_frames", + "jobs.needs", + "jobs.lease_until", + "jobs.wait_until", + "jobs.swept_at", + "run_sections.wrote_with", + "workspaces.api_fallback", + "runs.created_by", + ]) { + expect(have.has(want), `migration did not create ${want}`).toBe(true); + } + }); + + it("hands out a job that needs nothing", async () => { + // `bool_and` over zero rows is NULL, not true. Without the COALESCE in the + // claim query this job is invisible to every worker for ever, and a brain + // test simply never answers. + const { enqueue, claim } = await jobsMod(); + const { registerWorker } = await workersMod(); + const w = await registerWorker({ userId: uid, name: "any", token: token(), enabled: [] }); + const { job } = await enqueue({ kind: "test_brain", needs: [] }); + const got = await claim(w.id, [], 5); + expect(got.map((j) => j.id)).toContain(job.id); + }); + + it("shows a job only to a machine that has the tool switched on", async () => { + const { enqueue, claim } = await jobsMod(); + const { registerWorker } = await workersMod(); + const without = await registerWorker({ + userId: uid, name: "no whisper", token: token(), enabled: ["claude"], + }); + const { job } = await enqueue({ kind: "transcribe_audio", needs: ["whisper"] }); + expect((await claim(without.id, ["claude"], 5)).map((j) => j.id)).not.toContain(job.id); + + const withIt = await registerWorker({ + userId: uid, name: "has whisper", token: token(), enabled: ["whisper"], + }); + expect((await claim(withIt.id, ["whisper"], 5)).map((j) => j.id)).toContain(job.id); + }); + + it("never hands the same job to two machines at once", async () => { + // The whole reason the claim is `FOR UPDATE SKIP LOCKED`. Two workers + // asking in the same millisecond must come away with disjoint sets, or a + // section is written twice and two subscriptions are spent on it. + const { enqueue, claim } = await jobsMod(); + const { registerWorker } = await workersMod(); + const a = await registerWorker({ userId: uid, name: "A", token: token(), enabled: ["claude"] }); + const b = await registerWorker({ userId: uid, name: "B", token: token(), enabled: ["claude"] }); + for (let i = 0; i < 8; i += 1) { + await enqueue({ kind: "write_section", needs: ["claude"] }); + } + const [ga, gb] = await Promise.all([ + claim(a.id, ["claude"], 8), + claim(b.id, ["claude"], 8), + ]); + const ids = [...ga, ...gb].map((j) => j.id); + expect(ids.length).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("refuses a result from a machine that no longer holds the job", async () => { + // The laptop that wakes from sleep and posts a section reassigned twenty + // minutes ago. It must not overwrite the one that actually landed. + const { enqueue, claim, succeed, fail } = await jobsMod(); + const { registerWorker } = await workersMod(); + const mine = await registerWorker({ userId: uid, name: "mine", token: token(), enabled: ["claude"] }); + const other = await registerWorker({ userId: uid, name: "other", token: token(), enabled: ["claude"] }); + const { job } = await enqueue({ kind: "write_section", needs: ["claude"] }); + expect((await claim(mine.id, ["claude"], 1))[0].id).toBe(job.id); + + expect(await succeed(job.id, other.id, { text: "stolen" })).toBeNull(); + expect(await fail(job.id, other.id, "not mine")).toBeNull(); + expect(await succeed(job.id, mine.id, { text: "ok" })).not.toBeNull(); + }); + + it("does not fail a job a machine is still running on its last attempt", async () => { + // The reaper's passes are order-dependent: expired leases go back first, + // and only then are spent jobs failed. Done in one pass it would end a + // section three minutes into the attempt that was going to finish it. + const { q } = await dbMod(); + const { enqueue, claim, reap, getJob } = await jobsMod(); + const { registerWorker } = await workersMod(); + const w = await registerWorker({ userId: uid, name: "slow", token: token(), enabled: ["claude"] }); + const { job } = await enqueue({ kind: "write_section", needs: ["claude"], maxAttempts: 1 }); + const [held] = await claim(w.id, ["claude"], 1); + expect(held.attempts).toBe(1); // already at max_attempts, and still running + + await reap(); + expect((await getJob(job.id))!.state).toBe("claimed"); + + await q("UPDATE jobs SET lease_until = now() - interval '1 minute' WHERE id = $1", [job.id]); + await reap(); + expect((await getJob(job.id))!.state).toBe("failed"); + }); + + it("clears a finished job's prompt once, not on every pass", async () => { + const { q } = await dbMod(); + const { enqueue, sweepPayloads } = await jobsMod(); + const { job } = await enqueue({ + kind: "write_section", + needs: ["claude"], + payload: { system: "x".repeat(4000), user: "y" }, + }); + await q( + `UPDATE jobs SET state = 'done', finished_at = now() - interval '2 days', + result = '{"text":"written","ms":1200}'::jsonb + WHERE id = $1`, + [job.id], + ); + + expect(await sweepPayloads()).toBeGreaterThan(0); + // The second pass must match nothing: without `swept_at` this UPDATE + // rewrote every historical row every fifteen seconds, for ever. + expect(await sweepPayloads()).toBe(0); + + const [row] = await q<{ payload: Record; result: Record }>( + "SELECT payload, result FROM jobs WHERE id = $1", + [job.id], + ); + expect(row.payload).toEqual({}); + // The operational record survives — it is the only thing that says where + // the work ran and what it cost. + expect(row.result.ms).toBe(1200); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 6cdb821..5ec3f68 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,11 +3,17 @@ import { defineConfig } from "vitest/config"; /** * Unit tests, for the logic a screenshot cannot check. * - * Deliberately narrow. No component tests and no database: what these cover is - * the pure functions everything else stands on — the status a row shows, the - * slug a URL resolves by, what an imported template turns into, what the - * researcher makes of a model's answer. Those are the things that fail - * silently, days later, on one row. + * Deliberately narrow. No component tests: what these cover is the pure + * functions everything else stands on — the status a row shows, the slug a URL + * resolves by, what an imported template turns into, what the researcher makes + * of a model's answer. Those are the things that fail silently, days later, on + * one row. + * + * `tests/queue.test.ts` is the one exception, and it argues itself in its own + * header. The claim query decides, across machines that cannot see each other, + * who writes which section; it is not a pure function and no screenshot shows + * it working. It builds and drops its own database and skips itself when there + * is no Postgres, so `npm run check` with the container down is still green. * * `.mts` because the package is CommonJS and this file is ESM; `tsconfigPaths` * is what makes `@/lib/...` resolve the way it does in the app, so a test From e1891af473239551e9c34221fe0cd35a04d2a658 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 20:05:24 +0600 Subject: [PATCH 08/30] Scope the settings, queue the ingest, and show where work is happening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/src/app.module.ts | 8 + .../integrations/integrations.controller.ts | 443 +++++++-- api/src/machines/machines.controller.ts | 195 ++++ api/src/sources/sources.controller.ts | 48 +- api/src/workers/reaper.ts | 19 +- api/src/workers/workers.controller.ts | 166 +++- components/settings/MachinesPanel.tsx | 655 +++++++++++++ components/views/DocumentView.tsx | 481 ++++++++-- components/views/SettingsView.tsx | 37 +- lib/machines-client.ts | 70 ++ lib/runs-client.ts | 69 +- lib/server/brains.ts | 22 +- lib/server/db/schema.ts | 57 ++ lib/server/repos/settings.ts | 48 +- lib/server/repos/workspaces.ts | 51 +- lib/server/services/ingest.ts | 895 +++++++++++------- lib/server/services/researcher.ts | 20 +- lib/server/services/runs.ts | 190 +++- lib/use-run-document.ts | 151 ++- lib/use-run-watch.ts | 201 +++- tests/queue.test.ts | 7 +- 21 files changed, 3227 insertions(+), 606 deletions(-) create mode 100644 api/src/machines/machines.controller.ts create mode 100644 components/settings/MachinesPanel.tsx create mode 100644 lib/machines-client.ts diff --git a/api/src/app.module.ts b/api/src/app.module.ts index b07f054..c1dd7dc 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -16,6 +16,7 @@ import { RunsController } from "./runs/runs.controller"; import { SourcesController } from "./sources/sources.controller"; import { ResearchController } from "./research/research.controller"; import { WorkersController } from "./workers/workers.controller"; +import { MachinesController } from "./machines/machines.controller"; import { Reaper } from "./workers/reaper"; /** @@ -46,6 +47,13 @@ import { Reaper } from "./workers/reaper"; SourcesController, ResearchController, WorkersController, + /* + * The console's view of the machines, not the machines' own protocol. + * `MachinesController` is behind the global session guard like every other + * console route; `WorkersController` above is bearer-token only. Two + * controllers because they are two principals — see docs/WORKER.md. + */ + MachinesController, ], /* * The reaper is a provider rather than something started in `main`, so that diff --git a/api/src/integrations/integrations.controller.ts b/api/src/integrations/integrations.controller.ts index 1bc2a69..4d86fe9 100644 --- a/api/src/integrations/integrations.controller.ts +++ b/api/src/integrations/integrations.controller.ts @@ -2,119 +2,450 @@ import { BadRequestException, Body, Controller, - ConflictException, Get, HttpCode, + NotFoundException, + Param, Patch, Post, Query, } from "@nestjs/common"; -import { brainStatuses, findBrain, write } from "@/lib/server/brains"; -import { getSettings, listSecrets, setSettings } from "@/lib/server/repos/settings"; -import { toolStatuses } from "@/lib/server/tools"; +import { BRAINS, findBrain, type BrainDef, type Transport } from "@/lib/server/brains"; +import { enqueue, getJob } from "@/lib/server/repos/jobs"; +import { getSettings, listSecrets } from "@/lib/server/repos/settings"; +import { + getWorker, + listWorkers, + updateWorker, + type Worker, +} from "@/lib/server/repos/workers"; +import { getWorkspace, updateWorkspace } from "@/lib/server/repos/workspaces"; const MEDIA = ["yt-dlp", "ffmpeg", "ffprobe", "whisper"]; /** - * What this machine can actually reach, probed by running it. + * What a MACHINE can reach — read, not probed. + * + * This controller used to call `toolStatuses()` and `brainStatuses()`, which + * spawn `claude --version` and `yt-dlp --version` in the API process. That was + * exactly right while the API process was also the machine with the binaries + * on it. On a server it is a question asked of the wrong box: the answer is + * "nothing installed", forever, and the Integrations page becomes a list of + * red rows about a machine nobody uses — while the four laptops that really do + * have the tools are invisible. * - * The models and the local binaries, each checked by invoking it rather than - * by reading a flag somebody set. `?recheck=1` runs the probes again instead - * of answering from the cache. + * So the page stops asking "what is on this machine" and starts asking "what + * is on WHICH machine". Every row here comes out of `workers.tools`, which the + * worker probed on its own hardware and reported when it registered — the same + * `ToolStatus` shape, so the component barely changes. "Re-check" stops + * meaning "spawn five processes here" and starts meaning "ask that machine to + * probe again", which is what re-registration already is. + * + * The distinction the page has to be able to draw, and could not before: + * `whisper` is not installed on that machine, versus that machine has not been + * seen since Tuesday. One is an install command; the other is "open your + * laptop". */ @Controller("integrations") export class IntegrationsController { + /** + * The wiring, as of one machine. + * + * `machine` names a worker; without one the most recently seen live machine + * is used, because "where can this run right now" is the question the page + * is actually asking and a laptop shut since Tuesday is not an answer to it. + * `workspace` names whose brain and API-fallback switch to report — both of + * those moved off the global settings in migration 0016, and neither has a + * console-wide value any more. + */ @Get() - async wiring(@Query("recheck") recheck?: string) { - const again = recheck === "1"; - const [brains, tools, settings, keys] = await Promise.all([ - again ? toolStatuses(true).then(brainStatuses) : brainStatuses(), - toolStatuses(again), + async wiring( + @Query("machine") machineId?: string, + @Query("workspace") workspaceId?: string, + ) { + const [machines, settings, keys] = await Promise.all([ + listWorkers(), getSettings(), listSecrets(), ]); + + const machine = + (machineId ? machines.find((m) => m.id === machineId) : undefined) ?? + machines.find((m) => m.live) ?? + machines[0] ?? + null; + + const workspace = workspaceId ? await getWorkspace(workspaceId) : null; + return { - brains, - tools: tools.filter((t) => MEDIA.includes(t.id)), + /* + * Everything the picker needs, and nothing a token could be rebuilt + * from. `token_hash` never leaves the repo; the rest of a worker row is + * what the page draws. + */ + machines: machines.map((m) => ({ + id: m.id, + name: m.name, + platform: m.platform, + version: m.version, + lastSeenAt: m.lastSeenAt, + live: m.live, + })), + machine: machine + ? { + id: machine.id, + name: machine.name, + live: machine.live, + lastSeenAt: machine.lastSeenAt, + } + : null, + brains: brainsOn(machine, Object.keys(keys)), + // Read off the row rather than probed. No machine enrolled yet means an + // empty list, which the page must render as "no machines" rather than as + // "nothing installed" — those are opposite sentences. + tools: (machine?.tools ?? []).filter((t) => MEDIA.includes(t.id)), + /** The subset the machine's owner has switched on. `workers.enabled`. */ + enabled: machine?.enabled ?? [], + /** `workers.can_read_frames`, which defaults OFF. Per machine, on purpose. */ + canReadFrames: machine?.canReadFrames ?? false, + /** Per workspace now, not per console. Null when none was named. */ + brain: workspace?.brain ?? null, + apiFallback: workspace?.apiFallback ?? null, settings, keys: Object.keys(keys), }; } - /** Pick the brain, or switch local tools on and off. */ + /** + * Set one of the things this page can set — each on the thing it belongs to. + * + * There is no `setSettings({ brain, enabled })` any more, and that is the + * point of migration 0016: `brain` is an editorial choice about a + * workspace's content, `enabled` is a fact about one person's machine, and a + * single PATCH that wrote both into a console-wide table let a teammate + * retune every brand in the database and switch off a binary on somebody + * else's desktop with the same click. + * + * `enabled` is also settable on `PATCH /api/machines/:id`, which is the + * canonical place for a machine's switches; it is accepted here because it + * is what the toggle on this page does, and it goes through the same repo + * call rather than a second copy of the rule. + */ @Patch() - async update(@Body() body: { brain?: string; enabled?: string[] }) { - if (body.brain) { - const status = (await brainStatuses()).find((b) => b.id === body.brain); - if (!status) throw new BadRequestException(`No model called "${body.brain}"`); - if (!status.available) throw new ConflictException(status.reason); + async update( + @Body() + body: { + workspace?: string; + brain?: string; + apiFallback?: boolean; + machine?: string; + enabled?: string[]; + }, + ) { + if (body.brain !== undefined || body.apiFallback !== undefined) { + if (!body.workspace) { + throw new BadRequestException( + "Which workspace? The model and the API-key fallback are per workspace now, not per console.", + ); + } + if (body.brain !== undefined && !findBrain(body.brain)) { + throw new BadRequestException(`No model called "${body.brain}"`); + } + /* + * Not checked against a machine before saving, on purpose. + * + * The old code refused a brain whose CLI this box did not have. A + * workspace's model is an editorial decision that outlives whichever + * laptops happen to be awake when it is made, and a machine with the CLI + * may be enrolled an hour later. A run that cannot be routed says so at + * enqueue, by name, with the install command — which is a better place + * to find out than a picker that would not let you choose. + */ + const updated = await updateWorkspace(body.workspace, { + ...(body.brain !== undefined ? { brain: body.brain } : {}), + ...(body.apiFallback !== undefined ? { apiFallback: body.apiFallback } : {}), + }); + if (!updated) throw new NotFoundException("No such workspace"); + return { brain: updated.brain, apiFallback: updated.apiFallback }; } - return setSettings(body); + + if (body.enabled !== undefined) { + if (!body.machine) { + throw new BadRequestException( + "Which machine? A tool being switched off is a fact about one desktop, not about the console.", + ); + } + const updated = await updateWorker(body.machine, { enabled: body.enabled }); + if (!updated) throw new NotFoundException("No such machine"); + return { machine: updated.id, enabled: updated.enabled }; + } + + /* + * Nothing else on this page is a setting any more. + * + * `quality`, `autoApprove` and `reduceMotion` are genuinely console-wide + * and are edited through `PATCH /api/settings`, which is where they always + * were. Refusing here rather than quietly succeeding matters because an + * old client still sends `{ brain }` or `{ enabled }` with no scope, and a + * 200 with nothing written would look exactly like a saved switch that + * silently is not. + */ + throw new BadRequestException( + "Nothing to set. `brain` and `apiFallback` need a workspace; `enabled` needs a machine.", + ); } - /** Ask a model for two words, and report what happened. */ + /** + * Ask a model for two words — on a named machine. + * + * It cannot be answered here any more. `write()` on the server would spawn a + * CLI that is not installed, or quietly spend an API key to answer a + * question about somebody's laptop; neither tells you the thing the button + * exists to tell you, which is whether THAT machine can write. So it becomes + * a `test_brain` job, and the answer arrives by polling it. + * + * A model that works on one teammate's laptop and not on another's is now + * something the page can express, and before this it could not even ask. + */ @Post("test") @HttpCode(200) - async test(@Body() body: { id?: string }) { + async test(@Body() body: { id?: string; machine?: string }) { const def = findBrain(body.id ?? ""); if (!def) throw new BadRequestException("No such model"); + if (!body.machine) throw new BadRequestException("Which machine should try it?"); - const status = (await brainStatuses()).find((b) => b.id === def.id)!; - if (!status.available) { - return { ok: false, ms: 0, reply: "", error: status.reason, fix: "" }; + const machine = await getWorker(body.machine); + if (!machine) throw new NotFoundException("No such machine"); + + /* + * Refuse before queueing when the machine plainly cannot do it. + * + * A job pinned to a worker that does not have the CLI would sit queued + * until `wait_until` and then fail on a deadline, which reads as "it was + * slow" rather than "it is not installed there". Answering now is the same + * information, two minutes earlier and in the right words. + */ + const tool = def.command + ? machine.tools.find((t) => t.id === def.command) + : undefined; + if (def.command && !tool?.present) { + return { + ok: false, + ms: 0, + reply: "", + error: `${def.command} is not installed on ${machine.name}`, + fix: tool?.install + ? `On ${machine.name}: ${tool.install}` + : `Install the ${def.command} CLI on ${machine.name}`, + machine: machine.name, + }; } - const started = Date.now(); - try { - const reply = await write(def.id, { + const { job } = await enqueue({ + kind: "test_brain", + wantsWorker: machine.id, + needs: def.command ? [def.command] : [], + // One attempt. A retry would ask the same machine the same question and, + // if it is asleep, take twice as long to say so. + maxAttempts: 1, + // Short, because somebody is watching this button. Past it the reaper + // fails the job naming the machine — which is the honest answer when a + // laptop is shut, and the one this page could not give before. + waitUntil: new Date(Date.now() + 120_000), + error: machine.live + ? "" + : `${machine.name} has not been seen recently, so nothing picked this up.`, + payload: { + brain: def.id, + transport: (def.command ? "cli" : "api") as Transport, system: "You are a terse assistant. Answer in one short line, no preamble.", user: "Reply with exactly: BRAIN OK", tier: "cheap", timeoutMs: 150_000, - }); - return { - ok: Boolean(reply.trim()), - ms: Date.now() - started, - reply: reply.slice(0, 200), - error: reply.trim() ? "" : "It answered with nothing", - fix: "", - transport: status.transport, - }; - } catch (e) { - return { - ok: false, - ms: Date.now() - started, - reply: "", - error: e instanceof Error ? e.message.slice(0, 400) : "It did not answer", - fix: e instanceof Error ? fixFor(def.id, e.message) : "", - transport: status.transport, - }; + }, + }); + + return { jobId: job.id, machine: machine.name, state: job.state }; + } + + /** + * How the test went, once a machine has answered. + * + * Polled by the page, because the result no longer comes back from the POST + * that started it: the work happens on a laptop the request has no way to + * wait for. `fixFor` runs here rather than on the worker for the same reason + * it always did — it is a lookup from an error string to a sentence, and the + * sentences belong with the console that shows them. + */ + @Get("test/:jobId") + async testResult(@Param("jobId") jobId: string) { + const job = await getJob(jobId); + if (!job || job.kind !== "test_brain") { + throw new NotFoundException("No such test"); } + + const machine = job.workerId ? await getWorker(job.workerId) : null; + const where = machine?.name ?? ""; + const brainId = String(job.payload.brain ?? ""); + + if (job.state === "queued" || job.state === "claimed") { + // `job.error` carries the sentence written at enqueue when the machine + // was not live, so "waiting for a laptop that is shut" is visible while + // it is still true rather than only once the deadline passes. + return { state: job.state, running: true, machine: where, note: job.error }; + } + + const result = job.result as { + ok?: boolean; + ms?: number; + reply?: string; + error?: string; + transport?: Transport; + }; + const reply = String(result.reply ?? ""); + const error = String(result.error || job.error || ""); + + return { + state: job.state, + running: false, + // Answering with nothing is not a pass. The button exists to prove the + // model writes, and an empty string proves the opposite. + ok: job.state === "done" && result.ok !== false && Boolean(reply.trim()), + ms: Number(result.ms ?? 0), + reply: reply.slice(0, 200), + error, + fix: error ? fixFor(brainId, error, where) : "", + transport: result.transport ?? "", + machine: where, + }; } } -/** The one-line remedy for the failures people actually hit. */ -function fixFor(id: string, message: string) { +/** + * Which models this machine could write with, derived from what it reported. + * + * The same shape `brainStatuses()` produced, built from a table instead of + * from five child processes. `cli` is present AND switched on, because the + * claim query intersects a job's `needs` against `workers.enabled` — a CLI + * that is installed but off is a model no job will ever reach that machine + * for, and a page calling it available would be describing a route that does + * not exist. + * + * `api` stays a fact about the SERVER rather than about the machine: the keys + * live here, encrypted, and the API path runs here as the workspace's + * fallback. That is why the two halves of one row can be true of two different + * computers, and why the reason strings have to say which. + */ +function brainsOn(machine: Worker | null, storedKeys: string[]) { + return BRAINS.map((def) => { + const tool = def.command + ? machine?.tools.find((t) => t.id === def.command) + : undefined; + const cli = Boolean( + def.command && tool?.present && machine?.enabled.includes(def.command), + ); + const { api, apiFrom } = keyState(def, storedKeys); + const available = cli || api; + + return { + ...def, + cli, + cliVersion: tool?.version ?? "", + api, + apiFrom, + available, + transport: (available ? (cli ? "cli" : "api") : "") as Transport | "", + // A CLI with no override uses whatever the signed-in account defaults + // to, and that account is on the machine, not here. + using: + available && cli ? (def.cliModel ?? "the account's default model") : def.model, + reason: reasonFor(def, machine, cli, api, tool?.present === true), + }; + }); +} + +/** A key is set, in this process's environment or in the settings table. */ +function keyState(def: BrainDef, storedKeys: string[]) { + if (def.env && (process.env[def.env] ?? "").trim()) { + return { api: true, apiFrom: "env" as const }; + } + if (def.secret && storedKeys.includes(def.secret)) { + return { api: true, apiFrom: "settings" as const }; + } + return { api: false, apiFrom: "" as const }; +} + +/** + * Why it cannot be used — and on which computer. + * + * "Install the claude CLI" was a complete sentence when the reader was sitting + * at the machine that would run it. On a team it is an instruction with no + * address on it, and the commonest case is not a missing binary at all: it is + * a machine that is simply not switched on, which no install command fixes. + */ +function reasonFor( + def: BrainDef, + machine: Worker | null, + cli: boolean, + api: boolean, + installed: boolean, +) { + if (cli || api) return ""; + if (!machine) { + return "No machine has been enrolled yet — install the desktop app on one and add it in Settings → Machines"; + } + if (def.id === "ollama") { + // Ollama is an HTTP server on the machine that runs it, so there is no + // binary in `tools` to look for and nothing here can reach it. Only that + // machine can answer this, which is what Test now asks it to do. + return `Ollama runs on the machine itself — press Test to ask ${machine.name}`; + } + if (installed) { + return `${def.command} is installed on ${machine.name} but switched off for it`; + } + return `Install the ${def.command} CLI on ${machine.name}, or paste a key in Settings → API keys`; +} + +/** + * The one-line remedy for the failures people actually hit. + * + * Kept, because it is a lookup from an error string to a sentence and that + * does not move anywhere. What changed is who is being told: every one of + * these is now advice about a terminal on a machine the reader may not be + * sitting at, so the machine's name goes into the sentence. "Run `codex + * login` in a terminal" is still the right advice, and it is useless run on + * the wrong computer. + */ +function fixFor(id: string, message: string, machine: string) { const m = message.toLowerCase(); + // "On Shakhawat's desktop: " — dropped when nothing ever claimed the job, + // because an instruction addressed to nowhere is worse than one addressed + // to you. + const on = machine ? `On ${machine}: ` : ""; if (m.includes("unknown variant") || m.includes("failed to refresh available models")) { return id === "codex-cli" - ? "That CLI is out of date — run: npm i -g @openai/codex@latest" - : "That CLI is out of date — reinstall it at the latest version"; + ? `${on}that CLI is out of date — run: npm i -g @openai/codex@latest` + : `${on}that CLI is out of date — reinstall it at the latest version`; } if (m.includes("auth method") || m.includes("not logged in") || m.includes("login")) { if (id === "gemini-cli") { - return "Run `gemini` once in a terminal to sign in, or paste a Google AI key in Settings → API keys"; + return `${on}run \`gemini\` once in a terminal to sign in, or paste a Google AI key in Settings → API keys`; } - if (id === "codex-cli") return "Run `codex login` in a terminal"; - if (id === "claude-cli") return "Run `claude` once in a terminal to sign in"; + if (id === "codex-cli") return `${on}run \`codex login\` in a terminal`; + if (id === "claude-cli") return `${on}run \`claude\` once in a terminal to sign in`; } if (m.includes("api key") || m.includes("401") || m.includes("unauthorized")) { + // The only one with no machine in it, and correctly so: the keys are on + // the server, so this is the same sentence wherever the job ran. return "The key was refused — check it in Settings → API keys"; } if (m.includes("timed out") || m.includes("timeout")) { - return "It did not answer in time. Try again, or pick another model."; + return `${on}it did not answer in time. Try again, or pick another model.`; + } + if (m.includes("not been seen") || m.includes("waiting for")) { + return machine ? `Open ${machine} and try again` : "Open that machine and try again"; } return ""; } diff --git a/api/src/machines/machines.controller.ts b/api/src/machines/machines.controller.ts new file mode 100644 index 0000000..aad0ee1 --- /dev/null +++ b/api/src/machines/machines.controller.ts @@ -0,0 +1,195 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Patch, + Post, +} from "@nestjs/common"; +import { randomBytes } from "node:crypto"; + +import { listUsers, type User } from "@/lib/server/auth"; +import { + deleteWorker, + getWorker, + listWorkers, + registerWorker, + updateWorker, + type Worker, +} from "@/lib/server/repos/workers"; + +import { CurrentUser } from "../common/user.decorator"; + +/** + * The console's half of the worker split. + * + * Everything here is behind the global `SessionGuard` — a person, with a + * cookie, on the Settings screen. It is emphatically NOT a worker route: + * `api/src/workers/workers.controller.ts` is the machine's six endpoints and + * every one of them is `@WorkerRoute()` with a bearer token. The two are + * different principals on purpose (`docs/WORKER.md`, "The desktop app holds + * two credentials"), so they are different controllers on different paths and + * neither decorator appears on the other's file. + * + * What is here is the half a machine is deliberately not allowed to do for + * itself: mint its credential, and set the switches its owner owns. + */ +@Controller("machines") +export class MachinesController { + /** + * Every machine, with whose it is. + * + * The owner's name is joined in here rather than being looked up by the + * browser, because the picker's whole job is to let somebody choose a + * machine and "Rakib's desktop" is the answer to "whose GPU am I about to + * spend". `Worker.live` is already derived in the repo from `last_seen_at`, + * so the page never has to know what the liveness window is. + */ + @Get() + async list(): Promise { + const [workers, users] = await Promise.all([listWorkers(), listUsers()]); + const byId = new Map(users.map((u) => [u.id, u])); + return workers.map((worker) => decorate(worker, byId.get(worker.userId))); + } + + /** + * Enrol a machine: mint the token, hand it back exactly once. + * + * 256 bits from `node:crypto` — the same randomness the session ids use, and + * not `Math.random`, because this is a credential that grants the queue. It + * is returned in this one response body and never again: `registerWorker` + * stores only its sha256, so a database dump is a list of machines, not a + * list of live logins. Lose it and the answer is to revoke the row and mint + * another, which costs one paste in the desktop app. + * + * The row is created by the same `registerWorker` the machine itself calls, + * which is what makes the first real registration an UPDATE on this row + * rather than a second one: enrolment and re-registration are the same + * insert keyed on the same token hash. The machine has not spoken yet, so + * `platform`, `version` and `tools` stay empty until it does — that emptiness + * is what the panel reads as "waiting for this machine to check in". + */ + @Post() + async mint( + @CurrentUser() user: User, + @Body() body: { name?: string }, + ): Promise<{ machine: Machine; token: string }> { + const name = (body.name ?? "").trim(); + if (!name) throw new BadRequestException("Give the machine a name"); + + const token = `wrk_${randomBytes(32).toString("base64url")}`; + const worker = await registerWorker({ userId: user.id, name, token }); + return { machine: decorate(worker, user), token }; + } + + /** + * The owner's switches. + * + * `registerWorker` refuses to overwrite these when a machine re-registers, + * on purpose — a worker restarting with its defaults must not hand itself + * back a permission somebody switched off — which makes this route the only + * place they are ever set. `tools` is not patchable for the mirror-image + * reason: nobody types a binary into existence from a browser. + * + * `enabled` is intersected with what the machine actually reported, so a + * stale form cannot leave a tool switched on that the machine has since + * stopped advertising. The claim query would then hand it work it cannot + * run, and the job would fail on somebody's laptop rather than here. + */ + @Patch(":id") + async patch( + @Param("id") id: string, + @Body() + body: { + name?: string; + enabled?: string[]; + canReadFrames?: boolean; + maxConcurrency?: number; + }, + ): Promise { + const patch: Parameters[1] = {}; + + if (body.name !== undefined) { + const name = body.name.trim(); + if (!name) throw new BadRequestException("A machine needs a name"); + patch.name = name; + } + if (body.enabled !== undefined) { + if (!Array.isArray(body.enabled)) + throw new BadRequestException("enabled must be a list of tool ids"); + const worker = await requireWorker(id); + const advertised = new Set(worker.tools.map((tool) => tool.id as string)); + patch.enabled = body.enabled.map(String).filter((t) => advertised.has(t)); + } + if (body.canReadFrames !== undefined) + patch.canReadFrames = body.canReadFrames === true; + if (body.maxConcurrency !== undefined) { + const n = Number(body.maxConcurrency); + if (!Number.isFinite(n)) throw new BadRequestException("A number, please"); + // Clamped in `updateWorker` as well; rejecting here would turn a stepper + // held down into an error toast for something the repo already handles. + patch.maxConcurrency = Math.round(n); + } + + const saved = await updateWorker(id, patch); + if (!saved) throw new NotFoundException("No such machine"); + + const users = await listUsers(); + return decorate(saved, users.find((u) => u.id === saved.userId)); + } + + /** + * Revoke: the row goes, and with it the only copy of the token's hash. + * + * There is no separate revoked flag because a hash that is not in the table + * cannot authenticate, and a row that exists but is refused is a second + * state to keep in sync with the first. Work the machine was holding is not + * lost: `jobs.worker_id` is ON DELETE SET NULL, so a claimed job simply + * loses its lease and the reaper puts it back in the queue for whoever is + * next. What it will not do is come back on this machine. + */ + @Delete(":id") + async revoke(@Param("id") id: string) { + const gone = await deleteWorker(id); + if (!gone) throw new NotFoundException("That machine is already gone"); + return { ok: true }; + } +} + +/** + * A worker as the console reads it: the row, plus whose machine it is. + * + * The extra fields are display only. They are added here rather than in the + * repo because `listWorkers` is also read by the claim path, which has no + * interest in anybody's email address. + */ +export type Machine = Worker & { + ownerName: string; + ownerEmail: string; + /** True until the machine itself has registered — nothing reported yet. */ + awaitingFirstContact: boolean; +}; + +const decorate = (worker: Worker, owner: User | undefined): Machine => ({ + ...worker, + ownerName: owner?.name || owner?.email || "Unknown", + ownerEmail: owner?.email ?? "", + /* + * A machine that has never spoken has no platform, no version and no tools, + * because only `POST /api/workers/register` sets those. `last_seen_at` is + * stamped by the mint itself — the enrolment row is written by the same + * insert — so liveness alone would call a brand new row "live" for two + * minutes and the page would be waiting for tools from something that has + * not been started yet. + */ + awaitingFirstContact: !worker.platform && !worker.version && !worker.tools.length, +}); + +async function requireWorker(id: string): Promise { + const worker = await getWorker(id); + if (!worker) throw new NotFoundException("No such machine"); + return worker; +} diff --git a/api/src/sources/sources.controller.ts b/api/src/sources/sources.controller.ts index ed67a4f..bb41425 100644 --- a/api/src/sources/sources.controller.ts +++ b/api/src/sources/sources.controller.ts @@ -29,11 +29,25 @@ import { /** * Source videos: fetched from a link, or uploaded from a machine. * - * Slow on purpose. A fetch holds while yt-dlp downloads, ffmpeg cuts stills - * and a transcriber runs — minutes, not seconds — and the browser waits with - * a spinner on it. Express has no request timeout of its own, so there is - * nothing here to raise; the Next handlers needed `maxDuration` and this does - * not. + * The two halves are no longer the same shape, and the difference is the whole + * of `docs/WORKER.md`'s "Uploads stay on the server" decision: + * + * - **A link returns immediately.** `ingest()` writes an `ingest_source` job + * and hands back a source in `fetching`; yt-dlp and ffmpeg run on whichever + * machine has them. This route used to hold for minutes with a spinner on + * the other end, which only ever worked because the API was somebody's + * laptop — on a server it is a request held open across a download that + * machine cannot do at all. + * - **An upload still holds.** The bytes are already arriving here, and + * sending a four hundred megabyte recording back out to a laptop to cut + * eight stills would double the transfer to learn nothing. The ffmpeg half + * runs in this process; only the transcription leaves, as its own job. + * + * So a caller polls `GET /sources/:id` after a fetch and watches `state` move + * from `fetching` to `ready` or `failed`. It must not treat the POST's answer + * as finished work: `frames` and `transcript` are empty in it by design, and + * a screen that renders that response as the result shows an empty picker for + * every link. */ @Controller("sources") export class SourcesController { @@ -42,7 +56,15 @@ export class SourcesController { return listSources(workspaceId || undefined); } - /** Fetch a reel by link, or hand back the one already fetched. */ + /** + * Queue a fetch for a link, or hand back the one already fetched. + * + * Returns a source in `fetching` with a job behind it — or, when nothing on + * this estate has yt-dlp, one already `failed` with a sentence naming what + * to install. Both are terminal-or-moving states a person can act on, which + * is the rule: a source must never sit on `fetching` with an empty queue + * behind it. + */ @Post() fetch( @Body() body: { url?: string; workspaceId?: string | null; refresh?: boolean }, @@ -62,6 +84,11 @@ export class SourcesController { * parser: a parser buffers, and the whole point of the ingest's streaming * path is that a 400MB recording never sits in memory. The declared size is * refused up front as a courtesy; the ingest counts the bytes itself. + * + * Still the slow route, and still on purpose — the frames are cut here. It + * comes back `ready` with the stills on it, and the transcript arrives later + * on whichever machine has whisper, so `transcript` may fill in after this + * response the same way a link's does. */ @Post("upload") upload(@Req() req: Request) { @@ -117,7 +144,14 @@ export class SourcesController { return { ok: await removeSource(id) }; } - /** One more still, at a moment somebody asked for. */ + /** + * One more still, at a moment somebody asked for. + * + * Uploads only. A fetched link's video is downloaded on the machine that + * ingests it and never comes here — only the stills and the audio do — so + * this answers 409 with a sentence saying so rather than pretending a file + * is missing. + */ @Post(":id/frames") @HttpCode(201) addFrame(@Param("id") id: string, @Body() body: { at?: number }) { diff --git a/api/src/workers/reaper.ts b/api/src/workers/reaper.ts index ca9bf16..0b63a5a 100644 --- a/api/src/workers/reaper.ts +++ b/api/src/workers/reaper.ts @@ -5,6 +5,7 @@ import { } from "@nestjs/common"; import { getJob, reap, sweepPayloads } from "@/lib/server/repos/jobs"; +import { sourceFailed } from "@/lib/server/services/ingest"; import { sectionFailed } from "@/lib/server/services/runs"; /** @@ -111,9 +112,21 @@ export class Reaper implements OnModuleInit, OnApplicationShutdown { for (const jid of failed) { try { const job = await getJob(jid); - if (job?.kind === "write_section") { - await sectionFailed(job, job.error || "No machine finished this."); - } + if (!job) continue; + const why = job.error || "No machine finished this."; + /* + * Every kind, not just the one. A reaped `ingest_source` used to end + * here silently, leaving its source on `fetching` with an empty queue + * behind it — the same dead row as a section stuck on `writing`, and + * the one a person is most likely to meet, because "nobody opened the + * laptop that has yt-dlp" is an ordinary Tuesday. + * + * `sourceFailed` is a no-op for `test_brain` and for anything with no + * source, so the branch does not have to enumerate the kinds that + * have nothing to tell. + */ + if (job.kind === "write_section") await sectionFailed(job, why); + else await sourceFailed(job, why); } catch (e) { console.error( `Reaper: job ${jid} ended but its section could not be marked:`, diff --git a/api/src/workers/workers.controller.ts b/api/src/workers/workers.controller.ts index 29c1312..0c6b98a 100644 --- a/api/src/workers/workers.controller.ts +++ b/api/src/workers/workers.controller.ts @@ -13,7 +13,7 @@ import { } from "@nestjs/common"; import busboy from "busboy"; import type { Response } from "express"; -import { createWriteStream } from "node:fs"; +import { createReadStream, createWriteStream } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { Transform } from "node:stream"; @@ -23,7 +23,6 @@ import { LEASE_SECONDS, claim, fail, - getJob, heartbeat, heldBy, succeed, @@ -31,10 +30,15 @@ import { import { registerWorker } from "@/lib/server/repos/workers"; import { getSource, updateSource } from "@/lib/server/repos/sources"; import { - DATA_ROOT, + AUDIO_FILE, FRAME_FILE, MAX_UPLOAD_BYTES, + audioPath, + ensureSourceDir, frameFile, + sourceFailed, + sourceResult, + transcriptResult, } from "@/lib/server/services/ingest"; import { sectionFailed, sectionResult } from "@/lib/server/services/runs"; import type { ToolStatus } from "@/lib/server/tools"; @@ -208,12 +212,20 @@ export class WorkersController { * refused, the result already in `run_sections` stands, and the worker is * told 409 so that it stops rather than retrying into the same wall. * - * A `write_section` result is handed to the runs service, which records the - * text, the machine that wrote it and the milliseconds it took, and then - * advances the run. Nothing here writes `run_sections` itself: the ordering, - * the dependency waves and what "written" means to a run all belong on that - * side, and a second implementation of them living in a controller is how - * the two quietly stop agreeing. + * Every kind is handed to the service that owns the row it writes, and + * nothing here writes `run_sections` or `sources` itself: the ordering, the + * dependency waves, what "written" means to a run and what a missing + * transcript means to a source all belong on that side, and a second + * implementation of them living in a controller is how the two quietly stop + * agreeing. + * + * Until now only `write_section` was applied and the other three kinds were + * stored on the job and nowhere else — which is the same bug in three + * places: a machine finished the work, said so, and the row a person is + * looking at never moved. An ingest that landed left its source on + * "Fetching…" with an empty queue behind it, and the frames and the WAV it + * had already uploaded sat on disk with nothing in the database pointing at + * them. */ @Post("jobs/:id/result") @WorkerRoute() @@ -227,7 +239,34 @@ export class WorkersController { const job = await succeed(jobId, worker.id, result); if (!job) throw new ConflictException(staleJob(jobId)); - if (job.kind === "write_section") await sectionResult(job, result, worker.name); + switch (job.kind) { + case "write_section": + await sectionResult(job, result, worker.name); + break; + /* + * The frames and the WAV are already here — they went up through the two + * upload endpoints below before this was posted — so the result only + * names files the server holds, and `sourceResult` re-checks every one + * of those names before it puts them on the row. + */ + case "ingest_source": + await sourceResult(job, result); + break; + case "transcribe_audio": + await transcriptResult(job, result); + break; + /* + * `test_brain` has nothing to apply anywhere else, and that is the whole + * of its handling — but it is written down rather than left to fall off + * the end, because the thing that matters about it is that `succeed()` + * above has already put the job at `done` with the reply on it. That is + * what the Integrations panel polls for; a job that stayed `claimed` + * because nobody moved it would leave the button spinning until the + * lease expired and the reaper handed the test to a second machine. + */ + case "test_brain": + break; + } return { ok: true }; } @@ -267,7 +306,23 @@ export class WorkersController { * sat on "Writing" for five days, which `docs/DECISIONS.md` records and * this whole design exists to make impossible. */ - if (job.state === "failed") await sectionFailed(job, message); + if (job.state === "failed") { + if (job.kind === "write_section") { + await sectionFailed(job, message); + } else { + /* + * The same rule for a source, and `sourceFailed` draws the one + * distinction that matters: a failed `ingest_source` fetched nothing, + * so the source fails and names the machine; a failed + * `transcribe_audio` cost the source its words and nothing else, so + * the source stays ready and says which step fell short. A missing or + * broken transcriber degrades a source; it does not fail it. A + * `test_brain` has no source and falls through as a no-op — its + * failure is the answer, and it is already on the job. + */ + await sourceFailed(job, message, worker.name); + } + } return { ok: true }; } @@ -296,8 +351,11 @@ export class WorkersController { const source = await getSource(sourceId); if (!source) throw new NotFoundException("No such source"); - const dir = path.join(DATA_ROOT, "sources", sourceId); - await fs.mkdir(dir, { recursive: true }); + // Through the ingest service rather than by rebuilding the path here: + // there is one place that knows where a source's files live, and a second + // copy of `.data/sources/` in a controller is a layout that drifts the + // first time CONTENTOS_DATA_DIR means something new. + const dir = await ensureSourceDir(sourceId); return new Promise<{ files: string[] }>((resolve, reject) => { const bb = busboy({ headers: req.headers }); @@ -335,6 +393,88 @@ export class WorkersController { }); } + /** + * The audio, from the machine that fetched the video. + * + * The other half of "ingest uploads, write downloads", and the reason + * `whisper` never became a dependency of this server. Audio is roughly a + * thirtieth of the video, so the ingesting worker sends a 16 kHz mono WAV — + * about a megabyte a minute rather than four hundred — and the machine with + * a GPU downloads only that when it claims the `transcribe_audio` job. + * + * One fixed filename rather than one the sender chooses. A frame's name + * carries information — which second it was cut at — and so has to be + * checked against `FRAME_FILE`; a source has exactly one audio track, so the + * safest name is the one the server picks and the sender cannot influence at + * all. Multipart, and counted as it passes, for the same reason the frames + * are: a parser buffers, and a cap read off content-length is a number the + * client chose. + */ + @Post("sources/:id/audio") + @WorkerRoute() + async audioUp(@Req() req: WorkerRequest, @Param("id") sourceId: string) { + const source = await getSource(sourceId); + if (!source) throw new NotFoundException("No such source"); + + await ensureSourceDir(sourceId); + const dest = audioPath(sourceId); + + return new Promise<{ ok: true; file: string }>((resolve, reject) => { + const bb = busboy({ headers: req.headers, limits: { files: 1 } }); + let handled = false; + + bb.on("file", (field, file) => { + if (field !== "file") { + file.resume(); + return; + } + handled = true; + writeCappedFile(file, dest) + .then(() => resolve({ ok: true, file: AUDIO_FILE })) + .catch(reject); + }); + + bb.on("error", reject); + bb.on("finish", () => { + if (!handled) reject(new BadRequestException("No audio in that request")); + }); + + req.pipe(bb); + }); + } + + /** + * The audio, back down to the machine about to transcribe it. + * + * Streamed rather than read into memory the way a frame is: a JPEG is tens + * of kilobytes and an hour of speech is sixty megabytes, and buffering that + * per concurrent transcription is the API process falling over rather than + * the request. + * + * A 404 here is a real answer, not a fault — an `ingest_source` that could + * not download the video uploaded no audio, and the job for it should not + * have been enqueued, but a source deleted mid-transcription reaches exactly + * this line. The worker fails the job, `sourceFailed` writes the sentence, + * and the source stays readable. + */ + @Get("sources/:id/audio") + @WorkerRoute() + async audioDown(@Param("id") sourceId: string, @Res() res: Response) { + const file = audioPath(sourceId); + const size = await fs + .stat(file) + .then((s) => s.size) + .catch(() => 0); + if (!size) throw new NotFoundException("No audio for that source"); + + res.set({ + "content-type": "audio/wav", + "content-length": String(size), + "cache-control": "private, max-age=31536000, immutable", + }); + createReadStream(file).pipe(res); + } + /** * The transcript, as text. * diff --git a/components/settings/MachinesPanel.tsx b/components/settings/MachinesPanel.tsx new file mode 100644 index 0000000..e775dc2 --- /dev/null +++ b/components/settings/MachinesPanel.tsx @@ -0,0 +1,655 @@ +"use client"; + +import { useCallback, useEffect, useState, type ReactNode } from "react"; + +import { ago } from "@/lib/packs-client"; +import { + listMachines, + mintMachine, + patchMachine, + revokeMachine, + type Machine, +} from "@/lib/machines-client"; +import { font, panel, t, w } from "@/lib/theme"; +import { + Button, + Chip, + EmptyState, + Field, + IconButton, + ListCell, + ListHeader, + ListPanel, + ListRow, + Modal, + RowTile, + Segmented, + TextInput, + Toggle, +} from "@/components/ui"; +import { CopyIcon, PlusIcon, TrashIcon } from "@/components/ui/Icons"; +import { CpuGlyph, KeyGlyph, WrenchGlyph } from "@/components/ui/DocIcons"; + +const COLUMNS = "1.6fr 104px 1.3fr 104px 66px"; + +/** The four values a machine's concurrency is ever set to, as a pill row. */ +const CONCURRENCY = ["1", "2", "3", "4"] as const; + +/** + * The machines that do the work. + * + * Runs used to happen inside the API process, on whatever box answered HTTP. + * They now happen on desktops — one per teammate, each with its own signed-in + * CLI and its own GPU — which makes "which machines are there, and what may + * they do" a thing somebody has to be able to see and change. This is that + * screen. + * + * It mints a credential, shows it once, lists who holds one and revokes it — + * the same shape as the invite panel next door, deliberately, because it is + * the same problem and two consoles for it would be one too many. + */ +export function MachinesPanel() { + const [machines, setMachines] = useState(null); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + /** The one moment the token exists in a browser. Cleared, never re-fetched. */ + const [minted, setMinted] = useState<{ name: string; token: string } | null>( + null, + ); + const [copied, setCopied] = useState(false); + const [editing, setEditing] = useState(null); + const [revoking, setRevoking] = useState(null); + + const load = useCallback(() => { + void listMachines() + .then(setMachines) + .catch((e: unknown) => { + setMachines([]); + setError( + e instanceof Error ? e.message : "Could not read the machines", + ); + }); + }, []); + + useEffect(load, [load]); + + const enrol = async () => { + if (busy || !name.trim()) return; + setBusy(true); + setError(""); + try { + const made = await mintMachine(name.trim()); + setName(""); + setMachines((prev) => [made.machine, ...(prev ?? [])]); + setMinted({ name: made.machine.name, token: made.token }); + // Straight to the clipboard, as an invite link is: the only thing to do + // with a fresh token is paste it into the desktop app, and it is 50 + // characters nobody should be reading off a screen. + await navigator.clipboard?.writeText(made.token).catch(() => {}); + setCopied(true); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not enrol a machine"); + } finally { + setBusy(false); + } + }; + + const save = async (id: string, patch: Parameters[1]) => { + const saved = await patchMachine(id, patch).catch((e: unknown) => { + setError(e instanceof Error ? e.message : "That did not save"); + return null; + }); + if (!saved) return; + setMachines((prev) => (prev ?? []).map((m) => (m.id === saved.id ? saved : m))); + setEditing((prev) => (prev && prev.id === saved.id ? saved : prev)); + }; + + const revoke = async (machine: Machine) => { + setRevoking(null); + setMachines((prev) => (prev ?? []).filter((m) => m.id !== machine.id)); + await revokeMachine(machine.id).catch(() => {}); + load(); + }; + + return ( + <> +
+
Enrol a machine
+

+ A machine is one desktop running the Content OS worker, under one + person’s login, with their own model CLI and their own GPU. Name + it here, paste the token it gives you into the desktop app on that + computer, and it starts asking for work. Nothing runs on the server + itself. +

+ +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void enrol(); + }} + placeholder="e.g. Rakib’s desktop" + /> + +
+ +
+
+ + {error ? ( +
{error}
+ ) : null} + + {/* + Shown once, and this is the once. + The server keeps only a sha256 of it, for the same reason API keys + are encrypted and invite tokens are hashed: a database dump must not + be a list of live credentials. There is no route that can show it + again — if it is lost, revoke the machine and enrol it afresh. + */} + {minted ? ( +
+
+ + The token for {minted.name} +
+

+ Copied to your clipboard. This is the only time it is shown — + only a hash of it is stored, so nothing here can print it again. + Paste it into the desktop app on that machine; if you lose it, + revoke the machine below and enrol it again. +

+
+ + {minted.token} + + + +
+
+ ) : null} +
+ + + + + {machines === null ? ( + + ) : machines.length === 0 ? ( + } + title="No machines yet" + line="Until one is enrolled there is nowhere for a run to happen." + /> + ) : ( + machines.map((machine) => ( + setEditing(machine)} + > + + + + } + title={machine.name} + subtitle={[ + machine.ownerName, + machine.platform, + machine.version, + ] + .filter(Boolean) + .join(" · ")} + /> + + + + + + + {machine.lastSeenAt ? ago(machine.lastSeenAt) : "never"} + + + + setEditing(machine)} + > + + + setRevoking(machine)} + > + + + + + )) + )} + + +

+ A grey tool is one that machine does not have — an install away, on that + computer. A machine that is offline is a different thing entirely: its + tools are still listed because they are still installed; it is simply + not there to run them, and the fix is somebody opening their laptop. +

+ + {editing ? ( + setEditing(null)} + onSave={save} + onRevoke={() => { + const m = editing; + setEditing(null); + setRevoking(m); + }} + /> + ) : null} + + {revoking ? ( + } + onClose={() => setRevoking(null)} + footer={ + <> + + + + } + > +

+ Anything it was part way through is not lost and is not finished + either: the job goes back in the queue within a minute or two and + the next capable machine picks it up from the start. A section is + written whole or not at all, so nothing half-done is saved. +

+

+ The token cannot be brought back — only its hash was ever stored. + Putting this machine back means enrolling it again and pasting a new + one into the desktop app. +

+
+ ) : null} + + ); +} + +/** + * Three states, and the difference between them is the point of the column. + * + * "Waiting" is a row that exists because somebody made a token and has not + * pasted it anywhere yet — it has never reported a thing about itself. + * "Offline" is a machine that has told us all about itself and is currently + * shut. Collapsing the two would make a token nobody used look like a laptop + * somebody closed. + */ +function Status({ machine }: { machine: Machine }) { + if (machine.awaitingFirstContact) + return ( + + Waiting + + ); + if (machine.live) + return ( + + Online + + ); + return ( + + Offline + + ); +} + +/** What the machine reported, and which of it its owner has switched on. */ +function Tools({ machine }: { machine: Machine }) { + if (machine.awaitingFirstContact) + return ( + nothing reported yet + ); + + const present = machine.tools.filter((tool) => tool.present); + if (!present.length) + return ( + no tools installed + ); + + return ( + + {present.map((tool) => { + const on = machine.enabled.includes(tool.id); + return ( + + {tool.id} + + ); + })} + + ); +} + +/** + * The switches the machine is not allowed to set for itself. + * + * `registerWorker` refuses to overwrite any of these when a worker + * re-registers, which is the whole reason this dialog exists: a machine + * restarting with its defaults must not hand itself back a permission somebody + * switched off here. + */ +function MachineSettings({ + machine, + onClose, + onSave, + onRevoke, +}: { + machine: Machine; + onClose: () => void; + onSave: ( + id: string, + patch: { + name?: string; + enabled?: string[]; + canReadFrames?: boolean; + maxConcurrency?: number; + }, + ) => Promise; + onRevoke: () => void; +}) { + const [name, setName] = useState(machine.name); + const [enabled, setEnabled] = useState(machine.enabled); + const [canReadFrames, setCanReadFrames] = useState(machine.canReadFrames); + const [maxConcurrency, setMaxConcurrency] = useState(machine.maxConcurrency); + const [busy, setBusy] = useState(false); + + const toggle = (id: string) => + setEnabled((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + + const apply = async () => { + setBusy(true); + try { + await onSave(machine.id, { + name: name.trim() || machine.name, + enabled, + canReadFrames, + maxConcurrency, + }); + onClose(); + } finally { + setBusy(false); + } + }; + + return ( + } + footer={ + <> + + + + + } + > + + setName(e.target.value)} /> + + + + {null} + + + {machine.tools.map((tool) => ( + + toggle(tool.id)} + label={`Let this machine use ${tool.id}`} + /> + + ))} + + + setCanReadFrames((v) => !v)} + label="Let the Claude CLI open frame files" + /> + + + + ({ value: v, label: v }))} + value={String(maxConcurrency)} + onChange={(v) => setMaxConcurrency(Number(v))} + /> + + + ); +} + +/** One labelled control in the settings dialog. */ +function Row({ + label, + hint, + mono = false, + children, +}: { + label: string; + hint: string; + mono?: boolean; + children: ReactNode; +}) { + return ( +
+
+
+ {label} +
+
+ {hint} +
+
+ {children ?
{children}
: null} +
+ ); +} diff --git a/components/views/DocumentView.tsx b/components/views/DocumentView.tsx index 8743437..e8db5cd 100644 --- a/components/views/DocumentView.tsx +++ b/components/views/DocumentView.tsx @@ -22,12 +22,17 @@ import { import { useStore } from "@/lib/store"; import { latestRunFor, topicSlug } from "@/lib/run-doc"; import { packForSeries } from "@/lib/packs"; -import { editSection, startWriting, writeSection } from "@/lib/runs-client"; -import { useRunDocument } from "@/lib/use-run-document"; +import { + editSection, + queueSection, + startWriting, + stopRun, +} from "@/lib/runs-client"; +import { useRunDocument, type Placement } from "@/lib/use-run-document"; import { frameUrl, type Source } from "@/lib/sources-client"; import { font, panel, primary, rise, t, w } from "@/lib/theme"; import { Hov } from "@/components/ui/Hov"; -import { Button, Rail, TextArea } from "@/components/ui"; +import { Button, Chip, Rail, TextArea, type Tone } from "@/components/ui"; import { PencilIcon } from "@/components/ui/Icons"; import { UnwrittenTopic } from "@/components/views/UnwrittenTopic"; import { ReadingMode } from "@/components/overlays/ReadingMode"; @@ -203,6 +208,68 @@ const STATUS_TONE = { }, } as const; +/** + * A place, as one word and one colour. + * + * `blocked` and `idle` are grey because neither is a problem: one is waiting + * its turn and the other is waiting for you. `waiting` is amber because it is + * an errand — somebody has to switch a machine on. `unroutable` is red and says + * NO MACHINE rather than FAILED, because Retry cannot fix it: the tool is not + * installed anywhere on the estate, and pressing Retry until it is would be + * the only thing the old wording invited. + */ +const PLACE_LABEL: Record = { + done: "READY", + idle: "NOT STARTED", + blocked: "BLOCKED", + queued: "QUEUED", + waiting: "WAITING", + writing: "WRITING", + unroutable: "NO MACHINE", + failed: "FAILED", +}; + +const PLACE_TONE: Record = { + done: "good", + idle: "mute", + blocked: "mute", + queued: "accent", + waiting: "warn", + writing: "accent", + unroutable: "bad", + failed: "bad", +}; + +/** + * The sentence under the row, when there is something worth saying. + * + * Empty for the ordinary states — a row that is written, being written, or + * plainly not started yet explains itself, and a line of prose under every one + * of twelve sections is noise that hides the two that matter. + * + * The two that matter read as opposite instructions on purpose. "whisper is + * not installed on any machine" is a command to run; "that machine has not + * been seen recently" is a laptop to open. They were the same grey QUEUED chip + * before, which is how a run could sit all afternoon while everybody assumed + * the other thing. + */ +function placementNote(at: Placement | undefined): string { + if (!at) return ""; + switch (at.kind) { + case "blocked": + return `Needs ${at.on.join(" and ")} first.`; + case "waiting": + return at.machine + ? `Waiting for ${at.machine}, which has not been seen recently — opening it starts this.` + : "On the queue. No machine has picked it up yet."; + case "unroutable": + case "failed": + return at.message; + default: + return ""; + } +} + export function DocumentView({ slug, runId, @@ -229,13 +296,28 @@ export function DocumentView({ setFetched, serverWriting, setServerWriting, + waitingForMachine, + placements, + noteAdvance, } = useRunDocument({ slug, runId }); const [openSections, setOpenSections] = useState>( new Set(["03"]), ); const [filter, setFilter] = useState(""); - const [busy, setBusy] = useState>(new Set()); + /* + * Sections whose request is in the air, by id. + * + * It used to be "sections this tab is writing", held for the length of the + * model call, because the tab WAS the driver: it asked for a section and + * waited minutes for the text. The request is a database write now and comes + * back in milliseconds with the row already saying `writing`, so this covers + * only the gap between the click and that answer — it stops a second click + * queueing the same section and gives the button something to say meanwhile. + */ + const [queuing, setQueuing] = useState>(new Set()); + /** True between pressing Stop and the server confirming the jobs are gone. */ + const [stopping, setStopping] = useState(false); const [copied, setCopied] = useState(null); /* @@ -264,70 +346,120 @@ export function DocumentView({ const [reading, setReading] = useState(false); /** - * Is it writing, waiting, or stopped? + * Is it writing, waiting on a machine, unrunnable, or stopped? * * The page showed "0/12 written" and twelve QUEUED chips whether the run was * mid-flight, finished with nothing, or had never been started — three very - * different situations that all look identical while you watch them. With - * auto-approve off a new run does not start on its own, so the honest answer - * for most of them is "waiting for you", and that was the one thing nothing - * on the page said. + * different situations that all look identical while you watch them. The + * split into workers added two more that look identical to those: a run + * whose sections are on the queue with every machine switched off, and a run + * that can never move because nothing on the estate has the tool. Those two + * have opposite answers — open a laptop, or install something — so the line + * says which. */ const status = useMemo(() => { if (!run) return null; + const placed = run.sections.map((x) => ({ + row: x, + at: placements.get(x.id), + })); + /* - * In flight here, or in flight anywhere. + * Something is actually being written. * - * `busy` is this tab's own loop; the row's state is what the server - * recorded. A run driven from another tab moves the second and not the - * first, and reading only the local set would report "not running" while - * the machine was busy writing. + * The row's own state, not this tab's: the writing happens on somebody + * else's machine now, so there is no local fact to prefer. */ - const serverBusy = run.sections.find((x) => x.state === "writing"); - if (busy.size || serverBusy || serverWriting) { - const now = - doc?.sections.find((x) => busy.has(x.n)) ?? - doc?.sections.find((x) => x.id === serverBusy?.id); - /* - * Between two sections there is nothing being written, and the honest - * word for that is not "Writing…" with no object. The one about to go is - * the next queued, which is what the server picks the moment the last one - * lands — so the pill names it rather than going vague for a second and a - * half every time a section finishes. - */ + const writing = placed.find((x) => x.at?.kind === "writing"); + if (writing || queuing.size) { + const now = doc?.sections.find((x) => x.id === writing?.row.id); const next = doc?.sections.find((x) => x.state === "queued"); return { tone: "busy" as const, + pulse: true, text: now ? `Writing ${now.n} · ${now.name}…` : next ? `Starting ${next.n} · ${next.name}…` : "Writing…", - // It runs from whichever tab started it: the browser asks for one - // section, waits, then asks for the next. Closing that tab stops it - // between sections. - note: busy.size ? "Keep this tab open" : "Running on the server", + note: "On a worker machine — you can close this tab", + }; + } + + /* + * On the queue, and nothing has picked it up. + * + * The state that did not exist before and that the old page had no word + * for: it read "Writing…" and pulsed at a run where nothing was happening + * at all, forever, because a laptop was shut. Naming the machine when the + * server told us which turns it into an errand somebody can run. + */ + const asleep = placed.find( + (x) => x.at?.kind === "waiting" && x.at.machine, + )?.at; + if (waitingForMachine || asleep) { + const machine = asleep?.kind === "waiting" ? asleep.machine : ""; + return { + tone: "idle" as const, + pulse: false, + text: "Queued — waiting for a machine", + note: machine + ? `${machine} has not been seen recently. Open it and this starts.` + : "No machine has picked this up yet.", + }; + } + + /* + * Nothing here can ever run it. Not a failure to retry — an install. + * + * `advance` fails the section with the sentence naming the tool and the + * command, so the note is the server's own words rather than a second copy + * of them written here and gone stale. + */ + const unroutable = placed.filter((x) => x.at?.kind === "unroutable"); + if (unroutable.length) { + const first = unroutable[0]?.at; + return { + tone: "bad" as const, + pulse: false, + text: `${unroutable.length} section${unroutable.length === 1 ? "" : "s"} no machine can write`, + note: + first?.kind === "unroutable" + ? first.message + : "Nothing here has the tool it needs.", }; } - const failed = run.sections.filter((x) => x.state === "failed"); + const failed = placed.filter((x) => x.at?.kind === "failed"); if (failed.length) { + const first = failed[0]?.at; return { tone: "bad" as const, + pulse: false, text: `Stopped — ${failed.length} section${failed.length === 1 ? "" : "s"} failed`, - note: failed[0]?.error?.slice(0, 90) || "Open the section to see why", + note: + (first?.kind === "failed" ? first.message.slice(0, 90) : "") || + "Open the section to see why", }; } - if (!unwritten) return { tone: "good" as const, text: "All written", note: "" }; + if (!unwritten) { + return { + tone: "good" as const, + pulse: false, + text: "All written", + note: "", + }; + } return { tone: "idle" as const, + pulse: false, text: `Not running — ${unwritten} queued`, note: 'Press "Write the rest"', }; - }, [run, busy, doc, unwritten, serverWriting]); + }, [run, queuing, doc, unwritten, placements, waitingForMachine]); /* * Whether it carries on by itself. @@ -346,59 +478,114 @@ export function DocumentView({ }, [settings, unwritten]); /** - * Write these sections, by id, in order. + * Ask for these sections, by id. * - * One request each, because the model behind them is a single local process - * and parallel requests raced it into returning an error page instead of - * JSON. Slower, and it finishes. + * It used to be a driver: one request per section, awaited, in order, + * because the model was a child process of the request and the answer WAS + * the text. `POST /runs/:id/sections/:id` enqueues now and comes straight + * back with the run, so awaiting it in a loop asked for the second section + * while the first had not been written — and the page believed text had + * landed when nothing had. + * + * So: ask, take the run each answer carries, and let the watcher follow the + * rest. The server cascades on its own — a section landing runs `advance`, + * which queues whatever that unblocked — so this is only ever "please start + * this one". */ - const writeIds = useCallback(async (ids: string[]) => { - if (!run || !ids.length) return; - const ns = ids - .map((id) => doc?.sections.find((x) => x.id === id)?.n) - .filter((n): n is string => Boolean(n)); - - setBusy((prev) => new Set([...prev, ...ns])); - for (const id of ids) { - try { - const after = await writeSection(run.id, id); - // The server's copy, straight away: the driver reads `run` to decide - // what is ready next, and waiting for a list reload to tell it would - // stall between every section. - if (runId) setFetched(after); - } catch { - // The row shows what the server recorded on the next read; a failed - // one must not stop the sections queued behind it. + const writeIds = useCallback( + async (ids: string[]) => { + if (!run || !ids.length) return; + setQueuing((prev) => new Set([...prev, ...ids])); + /* + * Poll fast from this instant rather than from the first answer: the run + * IS on the queue the moment the request is accepted, and a page sitting + * on "Not running" for six seconds after a click reads as a click that + * missed. + */ + setServerWriting(true); + for (const id of ids) { + try { + const after = await queueSection(run.id, id); + // The server's copy, straight away — it already says `writing`, and + // waiting for a list reload to say so is a second of dead page. + setFetched(after); + } catch { + // Whatever the server recorded shows on the next poll; one section + // being refused must not stop the ones asked for behind it. + } + setQueuing((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); } - setBusy((prev) => { - const next = new Set(prev); - const n = doc?.sections.find((x) => x.id === id)?.n; - if (n) next.delete(n); - return next; - }); - } - await reloadRuns(); - }, [run, doc, runId, reloadRuns, setFetched]); - - // The local driver, for "Write next" and for the auto-approve preference. - // The whole-run case is the server's job now — see `serverWriting` below. - useEffect(() => { - if (!auto || !run || busy.size || !pending.length) return; - const timer = setTimeout(() => void writeIds([pending[0]]), 150); - return () => clearTimeout(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [auto, run, pending, busy.size]); + await reloadRuns(); + }, + [run, reloadRuns, setFetched, setServerWriting], + ); - /** Write the lot, on the server, and start watching straight away. */ + /** + * Write the lot, and keep what the server said about routing. + * + * The answer carries the only machine names the browser ever sees: which + * sections went on the queue, which are queued behind a machine that is + * asleep and what it is called, and which nothing on the estate can run. + * Dropping it — as a bare `await` would — throws away the difference between + * "open your laptop" and "install whisper". + */ const writeEverything = useCallback(async () => { if (!run) return; setServerWriting(true); try { - await startWriting(run.id); + const answer = await startWriting(run.id); + noteAdvance(answer); + setServerWriting(answer.running); } catch { setServerWriting(false); } - }, [run, setServerWriting]); + }, [run, setServerWriting, noteAdvance]); + + /** + * Stop a run that is writing. + * + * Queued jobs stop being work at once; a machine already part way through a + * section finds out at its next heartbeat, within about fifteen seconds, so + * a row can still say `writing` for a moment after this returns. Which is + * why the run is re-read afterwards rather than assumed idle. + */ + const stop = useCallback(async () => { + if (!run || stopping) return; + setStopping(true); + try { + await stopRun(run.id); + noteAdvance(null); + setServerWriting(false); + await reloadRuns(); + } catch { + // It is still running, and the next poll will say so. + } finally { + setStopping(false); + } + }, [run, stopping, setServerWriting, reloadRuns, noteAdvance]); + + /* + * Auto-approve: carry on without being asked. + * + * One press of Start rather than a section at a time. The old loop drove the + * run from this tab — write one, wait, pick the next — which is exactly the + * job the server took over: `advance` queues everything ready, and every + * section that lands queues whatever it unblocked. Driving it from here as + * well would only race the server to enqueue the same section, and would + * stop the moment the tab was closed. + */ + useEffect(() => { + if (!auto || !run || serverWriting || queuing.size || !pending.length) { + return; + } + const timer = setTimeout(() => void writeEverything(), 150); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [auto, run, pending, serverWriting, queuing.size]); /* * Two ways to have nothing to show, and they are different problems. @@ -504,7 +691,17 @@ export function DocumentView({ const written = writtenCount(doc); const stateOf = (s: DocSection): SectionState => - busy.has(s.n) ? "writing" : s.state; + s.id && queuing.has(s.id) ? "writing" : s.state; + + /** + * Where this row's work is, as opposed to what state it is in. + * + * `state` is one of four words the database keeps; this is the sentence a + * person can act on — which machine, awake or not, or nothing at all. See + * lib/use-run-document.ts for how the two are told apart. + */ + const placeOf = (s: DocSection): Placement | undefined => + s.id ? placements.get(s.id) : undefined; /** The one being written this second, if any, and how many gave up. */ const nowWriting = doc.sections.find((s) => stateOf(s) === "writing") ?? null; @@ -674,12 +871,33 @@ export function DocumentView({
+ {/* + Stop, whenever there is anything on the queue for this run. + + It is offered for a run that is merely queued as well as one being + written, and that is the point: a run parked behind a machine + nobody is going to switch on is the commonest reason to want out, + and before this there was no way to cancel it — the only control + was the one that started it. + */} + {serverWriting ? ( + + ) : null} {/* While a run still has sections to write, the header is about finishing it, not about redoing what is already there. Redo all - comes back once it is done. + comes back once it is done. Nothing that starts work is offered + while work is already queued: pressing it would be asking for the + same sections a second time. */} - {unwritten ? ( + {serverWriting ? null : unwritten ? ( auto ? ( setAuto(false)} @@ -793,10 +1011,12 @@ export function DocumentView({ height: 7, borderRadius: "50%", background: "currentColor", - animation: - status.tone === "busy" - ? "os-pulse 1.1s ease-in-out infinite" - : undefined, + // A run waiting on a shut laptop is amber and still; only + // work actually moving is allowed to pulse, or the page + // animates at you about something that is not happening. + animation: status.pulse + ? "os-pulse 1.1s ease-in-out infinite" + : undefined, }} /> {status.text} @@ -918,9 +1138,20 @@ export function DocumentView({ writing {nowWriting.n} · {nowWriting.name} - ) : serverWriting || busy.size ? ( + ) : queuing.size ? ( - picking the next section… + queueing… + + ) : waitingForMachine ? ( + /* + * Queued, and nothing has taken it. Not "picking the next + * section" — that sentence was written when picking took a + * second and a half and happened in the API process. It can now + * be until somebody gets back to their desk, and saying so is + * the difference between waiting and wondering. + */ + + on the queue — no machine has taken it yet ) : failedCount ? ( @@ -1121,6 +1352,8 @@ export function DocumentView({ {g.sections.map((s) => { const sState = stateOf(s); const sSt = STATE_STYLE[sState]; + const at = placeOf(s); + const said = placementNote(at); const open = openSections.has(s.n) && s.body.length > 0; return (
@@ -1204,24 +1437,38 @@ export function DocumentView({ {s.lang} ) : null} + {/* + One word for where the work is, not for what the + row's column says. + + Four situations shared the word QUEUED: nobody + has asked; a machine is about to take it; a + machine that is switched off is meant to take it; + nothing anywhere can take it. The first two are + fine, the third is an errand and the fourth is an + install — so they get different words and + different colours. + */} {sState !== "written" ? ( - - {sSt.label} - + at ? ( + + {PLACE_LABEL[at.kind]} + + ) : ( + + {sSt.label} + + ) ) : null} {/* Quiet, because it is a note about provenance rather than a state you have to act on. */} @@ -1254,6 +1501,36 @@ export function DocumentView({ > {s.purpose}
+ {/* + Why it is not written, in the server's own words. + + The failure text was carried all the way from + `run_sections.error` into the document and then + drawn nowhere, so a section that could not run said + FAILED and nothing else — and the sentence that says + WHICH tool to install was sitting in the object the + row was rendered from. It wraps, because an install + command truncated at one line is not an install + command. + */} + {said ? ( +
+ {said} +
+ ) : null}
diff --git a/components/views/SettingsView.tsx b/components/views/SettingsView.tsx index 3ff00c5..72d5b6f 100644 --- a/components/views/SettingsView.tsx +++ b/components/views/SettingsView.tsx @@ -26,10 +26,12 @@ import { UsedGlyph, GlobeGlyph, KeyGlyph, + PlugGlyph, SpeechGlyph, StackGlyph, } from "@/components/ui/DocIcons"; import { TeamPanel } from "@/components/settings/TeamPanel"; +import { MachinesPanel } from "@/components/settings/MachinesPanel"; /** * Preferences, keys and the account. @@ -45,6 +47,7 @@ type SectionKey = | "general" | "keys" | "team" + | "machines" | "language" | "appearance" | "account"; @@ -53,6 +56,9 @@ const SECTIONS: { key: SectionKey; label: string; icon: typeof CpuGlyph }[] = [ { key: "general", label: "Generation", icon: CpuGlyph }, { key: "keys", label: "API keys", icon: KeyGlyph }, { key: "team", label: "Team", icon: UsedGlyph }, + // Not CpuGlyph: Generation already wears it, and two identical glyphs in a + // seven-row nav make the reader check the words to tell them apart. + { key: "machines", label: "Machines", icon: PlugGlyph }, { key: "language", label: "Language", icon: SpeechGlyph }, { key: "appearance", label: "Appearance", icon: GlobeGlyph }, { key: "account", label: "Account", icon: StackGlyph }, @@ -239,18 +245,22 @@ export function SettingsView() { label="Approve finished sections automatically" /> + {/* + This used to be a console-wide toggle, and it granted the + Read tool on whatever filesystem happened to be running the + model. Now that the model runs on somebody's desktop, a + global here would let a teammate turn on file access on + another person's computer — which is not a setting, it is a + hole. It is per machine, in Machines, owned by whoever owns + the disk. + */} - - put({ cliCanReadFrames: !settings.cliCanReadFrames }) - } - label="Let the Claude CLI open frame files" - /> + @@ -258,6 +268,15 @@ export function SettingsView() { {section === "team" ? : null} + {/* + Where runs actually happen. It is a settings section rather than a + page of its own because it is the same act as Team next door — + mint a credential, show it once, list who holds one, revoke — and + splitting the two would put "who is in" and "what may run" on + different screens. + */} + {section === "machines" ? : null} + {settings && section === "keys" ? ( <> diff --git a/lib/machines-client.ts b/lib/machines-client.ts new file mode 100644 index 0000000..4f48033 --- /dev/null +++ b/lib/machines-client.ts @@ -0,0 +1,70 @@ +"use client"; + +import { apiFetch } from "@/lib/api-base"; +import { json } from "@/lib/api-json"; +import type { ToolStatus } from "@/lib/server/tools"; +import type { Worker } from "@/lib/server/repos/workers"; + +/** + * The browser's half of Settings → Machines. + * + * Session-authenticated like every other `lib/*-client.ts`: these are the + * console's routes, reached with the cookie `apiFetch` carries. The worker's + * own six endpoints under `/api/workers` are bearer-token only and are spoken + * by the desktop app, never by this file — nothing in the browser has, or + * should be able to get, a worker token after the one moment it is shown. + * + * `Worker` comes from the server's own module rather than being redeclared + * here, so the two descriptions of this JSON boundary cannot disagree — a + * disagreement shows up as a blank field, not as an error. + */ +export type { ToolStatus }; + +/** A machine row as the console reads it: the worker, plus whose it is. */ +export type Machine = Worker & { + ownerName: string; + ownerEmail: string; + /** Enrolled here, but the machine itself has not checked in yet. */ + awaitingFirstContact: boolean; +}; + +/** The switches that belong to whoever owns the machine. */ +export type MachinePatch = { + name?: string; + enabled?: string[]; + canReadFrames?: boolean; + maxConcurrency?: number; +}; + +/** Every machine, most recently seen first. */ +export const listMachines = () => + apiFetch("/api/machines", { cache: "no-store" }).then((r) => + json(r), + ); + +/** + * Enrol a machine and get its token. + * + * The token is in this response and in no other, ever: the server keeps only + * its sha256. Whatever calls this has one chance to put it in front of a + * person and on their clipboard. + */ +export const mintMachine = (name: string) => + apiFetch("/api/machines", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }).then((r) => json<{ machine: Machine; token: string }>(r)); + +export const patchMachine = (id: string, patch: MachinePatch) => + apiFetch(`/api/machines/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }).then((r) => json(r)); + +/** Revoke: the row goes, and the token with it. */ +export const revokeMachine = (id: string) => + apiFetch(`/api/machines/${id}`, { method: "DELETE" }).then((r) => + json<{ ok: true }>(r), + ); diff --git a/lib/runs-client.ts b/lib/runs-client.ts index b4396f6..32c64c5 100644 --- a/lib/runs-client.ts +++ b/lib/runs-client.ts @@ -50,34 +50,79 @@ export const deleteRun = (id: string) => ); /** - * Write one section. + * What `advance` did on the server, section by section. * - * Resolves with the whole run rather than the section: a section finishing - * changes what the rest of the page may now do, and returning only the one row - * makes the caller stitch state back together by hand. + * The three outcomes are genuinely different sentences on the page, and the + * only place two of them reach the browser at all: a job queued behind a + * machine that is asleep carries the machine's NAME here and nowhere in the + * run row, and an unroutable section is a missing install rather than a model + * that refused. Keep the shapes as the service returns them. */ +export type AdvanceReport = { + /** Sections that now have a job a live machine can claim. */ + enqueued: string[]; + /** Sections queued behind a machine that has not been seen lately. */ + waiting: { sectionId: string; machine: string }[]; + /** Sections no machine on this estate can ever write. Already failed. */ + unroutable: string[]; +}; + /** - * Write the whole run, server-side. + * Start the run: queue everything that is ready, and return at once. * - * Returns as soon as the loop starts, not when the run finishes — the page - * follows it by reading the run, the same way it reads anything else. + * Nothing is written by the time this resolves — the sections are jobs now, + * picked up by whichever machine holds the CLI. The page follows the run by + * reading it, the same way it reads anything else. * * This is also Retry: the server puts failed sections back in the queue before - * it drives, and `retried` says how many went back, so a caller can tell a + * it advances, and `retried` says how many went back, so a caller can tell a * retry from a plain start. */ export const startWriting = (runId: string) => apiFetch(`/api/runs/${runId}/start`, { method: "POST" }).then((r) => - json<{ started: boolean; running: boolean; retried: number }>(r), + json< + { started: boolean; running: boolean; retried: number } & AdvanceReport + >(r), ); -/** Is this run being written right now, by anyone? */ +/** + * Is anything on the queue for this run — anywhere, on anyone's machine? + * + * True for a job that is merely queued as well as one a machine is holding, so + * it is "this run is not finished with" rather than "a model is talking right + * now". The two are told apart by looking at which sections say `writing`. + */ export const isWriting = (runId: string) => apiFetch(`/api/runs/${runId}/start`, { cache: "no-store" }).then((r) => json<{ running: boolean }>(r), ); -export const writeSection = (runId: string, sectionId: string) => +/** + * Stop a run that is writing. + * + * Queued jobs stop being work immediately; a machine already running one finds + * out at its next heartbeat and kills the child process, so `stopped` counts + * jobs cancelled rather than processes already dead. + */ +export const stopRun = (runId: string) => + apiFetch(`/api/runs/${runId}/stop`, { method: "POST" }).then((r) => + json<{ ok: boolean; stopped: number; requeued: number }>(r), + ); + +/** + * Ask for one section to be written. + * + * It queues; it does not write. This used to resolve with the section + * finished, because the model was a child process of the request — awaiting it + * now waits for a database write and gets back a run whose section says + * `writing`, with the text still minutes away on somebody else's laptop. + * + * The whole run comes back rather than the one row because queueing a section + * runs `advance`, which may queue everything else that has just become ready, + * and because a section changing state changes what the rest of the page may + * do. + */ +export const queueSection = (runId: string, sectionId: string) => apiFetch(`/api/runs/${runId}/sections/${sectionId}`, { method: "POST" }).then( (r) => json(r), ); @@ -85,7 +130,7 @@ export const writeSection = (runId: string, sectionId: string) => /** * Save what a person typed into a section. * - * Returns the whole run for the same reason `writeSection` does: an edit can + * Returns the whole run for the same reason `queueSection` does: an edit can * change whether the run reads as finished, and the page should not have to * work that out from a single row. */ diff --git a/lib/server/brains.ts b/lib/server/brains.ts index befcade..2f334db 100644 --- a/lib/server/brains.ts +++ b/lib/server/brains.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { cliHome, run, toolStatuses } from "@/lib/server/tools"; -import { getSecret, getSettings } from "@/lib/server/repos/settings"; +import { getSecret } from "@/lib/server/repos/settings"; /** * The models that can be the brain, and how each one is reached. @@ -278,6 +278,22 @@ export type WriteRequest = { * anywhere. */ imageFiles?: string[]; + /** + * Whether Claude's CLI may be granted `Read` for this one call. + * + * Decided by whoever owns the filesystem the grant lands on, and carried in + * the request rather than read from a console-wide setting. It used to be + * `settings.cliCanReadFrames`, one row for the whole install — which was + * right while the install was one person's laptop and is a hole the moment + * the CLI runs somewhere else: a teammate flipping a switch in the console + * would be granting file access on YOUR desktop. + * + * The server sends it from `workers.can_read_frames`; the worker checks its + * own row again before spawning, because the last word belongs to the + * machine. Absent means no — a grant is never made on the strength of the + * request alone. + */ + allowFrameRead?: boolean; }; /** @@ -320,13 +336,13 @@ export async function write(brainId: string, req: WriteRequest): Promise * switch that says so. Codex and Gemini are given the picture and granted * nothing. */ - (def.id !== "claude-cli" || (await getSettings()).cliCanReadFrames); + (def.id !== "claude-cli" || req.allowFrameRead === true); if (req.images?.length && !canSeeOnApi && !canSeeOnCli) { throw new Error( status.transport === "cli" ? def.id === "claude-cli" - ? `${def.name} can read the frames, but “Let the Claude CLI open frame files” is off in Settings — that switch is what grants it Read for the one call.` + ? `${def.name} can read the frames, but this machine is not allowed to open frame files — turn that on for it in Settings → Machines. It is per machine because the grant lands on that machine’s disk.` : `${def.name} cannot be shown frames here.` : `${def.name} on an API key cannot be given frames here. Switch it to the CLI in Settings — a signed-in CLI can open the frame files.`, ); diff --git a/lib/server/db/schema.ts b/lib/server/db/schema.ts index 1feda17..f889bef 100644 --- a/lib/server/db/schema.ts +++ b/lib/server/db/schema.ts @@ -673,4 +673,61 @@ export const MIGRATIONS: { name: string; sql: string }[] = [ WHERE state = 'writing'; `, }, + + { + /* + * The three global settings that could never be global. + * + * `settings` is one row per key for the whole console, which was exactly + * right for one person on one laptop and is three separate wrong things + * for a team on a server. Each of these moves to the thing it is actually + * a fact about — see docs/WORKER.md, "settings.brain is a singleton and + * must be scoped". + * + * `enabled` and `cliCanReadFrames` need no new column: `workers.enabled` + * and `workers.can_read_frames` arrived with 0015 and a machine reports + * or is granted them per row. They are only deleted here. + */ + name: "0016_scoped_settings", + sql: ` + -- Which model writes is an editorial decision about the CONTENT, so it + -- sits beside brand_voice, which is scoped for exactly that reason and is + -- already read by startRun off the workspace rather than off the request. + -- A workspace writing Bangla scripts and one writing SEO copy may want + -- different models, and neither should change under the other because + -- somebody switched a global. + ALTER TABLE workspaces ADD COLUMN brain TEXT NOT NULL DEFAULT 'claude-cli'; + + /* + * Carry the global onto every workspace that exists. + * + * Without this, upgrading silently retunes every run in the database to + * the default: somebody who switched the console to Gemini months ago + * would press Run and get Claude, with nothing on any screen saying the + * setting had moved. The value is a jsonb string, so #>> '{}' is what + * takes the text out of it without the quotes coming along. COALESCE + * covers a console that never wrote the row at all, which is every + * install that left the picker alone. + */ + UPDATE workspaces SET brain = COALESCE( + (SELECT value #>> '{}' FROM settings WHERE key = 'brain'), 'claude-cli'); + + /* + * And delete all three globals, rather than leaving them to rot. + * + * getSettings() only copies keys it already knows, so a leftover row is + * invisible today — which is the danger. A future setting that reuses one + * of these names would come up holding a value somebody set for a + * different purpose in a different world, and nothing would say so. + * + * cliCanReadFrames in particular must NOT be carried anywhere. It + * defaults ON here and workers.can_read_frames defaults OFF, deliberately: + * it grants the Read tool on one specific person's filesystem, and + * enrolling a machine must not inherit a permission somebody granted on a + * different one. Dropping the value on the floor is the migration doing + * its job. + */ + DELETE FROM settings WHERE key IN ('brain', 'enabled', 'cliCanReadFrames'); + `, + }, ]; diff --git a/lib/server/repos/settings.ts b/lib/server/repos/settings.ts index 0ae51bb..53e63ae 100644 --- a/lib/server/repos/settings.ts +++ b/lib/server/repos/settings.ts @@ -22,28 +22,33 @@ export type Settings = { autoApprove: boolean; /** Stops the aurora drifting and shortens transitions. */ reduceMotion: boolean; - /** The model that writes. An integration id. */ - brain: string; - /** - * Local tools allowed to run, by id. A tool that is installed but switched - * off is skipped — which is how you get metadata without a 200MB download. - */ - enabled: string[]; - /** - * Whether the Claude CLI may open the frame files on this machine. - * - * Only Claude's: Codex is handed the picture with `-i` and Gemini opens the - * one path it is given, neither of which grants the model anything. Claude - * reads a still by being granted the Read TOOL for that call, which is a - * different kind of permission from WebFetch, so it has its own switch. - * - * On by default, because this console runs off signed-in CLIs and the - * alternative is not "safer", it is "the researcher does not work". The - * switch stays so it can be turned off by whoever owns the machine. - */ - cliCanReadFrames: boolean; }; +/* + * `brain`, `enabled` and `cliCanReadFrames` used to live here, and each was a + * different kind of wrong. See migration 0016 and docs/WORKER.md. + * + * This table is one row per key for the WHOLE console — there is no scope + * column and there never was one. That was right for one person on one + * laptop, and on a server with four teammates it meant a value that is a fact + * about one thing was being read as though it were a fact about everything: + * + * - `brain` is a fact about the CONTENT. It is on `workspaces` now, beside + * `brand_voice`, so the Bangla-script brand and the SEO-copy brand can want + * different models without either changing under the other. + * - `enabled` is a fact about a MACHINE. "yt-dlp is switched off" is true of + * one desktop; it is `workers.enabled`, evaluated by the worker against its + * own row. + * - `cliCanReadFrames` is a fact about a FILESYSTEM. It grants the Read tool + * on one specific person's disk, so a global that let a teammate turn it on + * from the other side of the office was not a setting, it was a hole. It is + * `workers.can_read_frames`, which defaults OFF where this defaulted ON — + * on purpose, and not a mismatch to be tidied away. + * + * What is left is genuinely console-wide, or wants to be per-user one day, + * which is a separate argument this move does not force. + */ + /* * `languages` and `connected` used to live here. * @@ -63,9 +68,6 @@ export const DEFAULTS: Settings = { quality: "Balanced", autoApprove: false, reduceMotion: false, - brain: "claude-cli", - enabled: ["yt-dlp", "ffmpeg", "ffprobe", "whisper"], - cliCanReadFrames: true, }; export async function getSettings(): Promise { diff --git a/lib/server/repos/workspaces.ts b/lib/server/repos/workspaces.ts index 8083c81..702f271 100644 --- a/lib/server/repos/workspaces.ts +++ b/lib/server/repos/workspaces.ts @@ -7,6 +7,17 @@ import { id, iso, one, q } from "@/lib/server/db/client"; * nothing in it decides policy: that belongs to the services. */ +/** + * What a workspace writes with when nobody has said otherwise. + * + * Lives here rather than in the settings repo because the choice is a + * property of the workspace now: `settings.brain` was one row for the whole + * console, so two brands in one database could not want different models — + * see migration 0016. Exported so the run service and the integrations page + * fall back to the same id instead of each spelling one out. + */ +export const DEFAULT_BRAIN = "claude-cli"; + export type Workspace = { id: string; name: string; @@ -15,6 +26,26 @@ export type Workspace = { status: string; goal: string; brandVoice: string; + /** + * Which model writes for this brand. An integration id. + * + * Editorial, not hardware: the Bangla-script workspace and the SEO-copy one + * may reasonably want different models, and neither should change under the + * other because somebody switched a global. It sits beside `brandVoice` for + * exactly that reason. + */ + brain: string; + /** + * May the server spend an API key when no machine can take the work? + * + * Off, and off is the feature. The whole worker split exists so a run costs + * a subscription somebody already pays for rather than money per token, and + * a fallback that fired whenever nobody's laptop was open would spend that + * money precisely when nobody was watching. Turning it on is a deliberate + * act, per workspace, by somebody who knows what it costs — which is why it + * cannot be a global either. + */ + apiFallback: boolean; langs: string[]; tint: string; photo: string | null; @@ -33,6 +64,12 @@ const map = (r: Row): Workspace => ({ status: String(r.status ?? "Planning"), goal: String(r.goal ?? ""), brandVoice: String(r.brand_voice ?? ""), + // Empty only on a hand-edited row — the column has a default and 0016 + // filled every existing row — but an empty brain id would reach findBrain() + // and fail a run with "No model called", which is a confusing sentence + // about a column nobody typed into. + brain: String(r.brain || DEFAULT_BRAIN), + apiFallback: r.api_fallback === true, // jsonb comes back already parsed; the guard is for a hand-edited row. langs: Array.isArray(r.langs) ? (r.langs as string[]) : [], tint: String(r.tint ?? ""), @@ -58,10 +95,10 @@ export async function createWorkspace( ): Promise { const row = await one( `INSERT INTO workspaces - (id, name, handle, channel, status, goal, brand_voice, langs, tint, - photo, position) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, - COALESCE($11, (SELECT COALESCE(MAX(position), -1) + 1 FROM workspaces))) + (id, name, handle, channel, status, goal, brand_voice, brain, + api_fallback, langs, tint, photo, position) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $12, + COALESCE($13, (SELECT COALESCE(MAX(position), -1) + 1 FROM workspaces))) RETURNING *`, [ patch.id || id("ws"), @@ -71,6 +108,10 @@ export async function createWorkspace( patch.status ?? "Planning", patch.goal ?? "", patch.brandVoice ?? "", + patch.brain || DEFAULT_BRAIN, + // Never inherited from another workspace and never on by default: a new + // brand must not arrive already able to spend money unattended. + patch.apiFallback ?? false, JSON.stringify(patch.langs ?? []), patch.tint ?? "", patch.photo ?? null, @@ -98,6 +139,8 @@ export async function updateWorkspace( if (patch.status !== undefined) set("status", patch.status); if (patch.goal !== undefined) set("goal", patch.goal); if (patch.brandVoice !== undefined) set("brand_voice", patch.brandVoice); + if (patch.brain !== undefined) set("brain", patch.brain || DEFAULT_BRAIN); + if (patch.apiFallback !== undefined) set("api_fallback", patch.apiFallback); if (patch.langs !== undefined) set("langs", JSON.stringify(patch.langs), "::jsonb"); if (patch.tint !== undefined) set("tint", patch.tint); if (patch.photo !== undefined) set("photo", patch.photo); diff --git a/lib/server/services/ingest.ts b/lib/server/services/ingest.ts index b3256b3..22a15cb 100644 --- a/lib/server/services/ingest.ts +++ b/lib/server/services/ingest.ts @@ -4,8 +4,13 @@ import path from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { requireTool, run, toolStatus } from "@/lib/server/tools"; -import { getSettings } from "@/lib/server/repos/settings"; +import { run, toolStatus } from "@/lib/server/tools"; +import { + activeJobs, + enqueue, + type Job, +} from "@/lib/server/repos/jobs"; +import { listWorkers, routeFor } from "@/lib/server/repos/workers"; import { createSource, deleteSource, @@ -29,6 +34,22 @@ import { * frames out of it, and a transcriber turns the audio into text. Each step is * optional in the sense that a missing tool degrades the result rather than * failing the run: metadata alone is still far more than nothing. + * + * **None of that runs here any more, for a link.** `ingest()` builds a job and + * returns; a worker on somebody's desktop runs yt-dlp and ffmpeg, uploads the + * stills and a 16 kHz WAV, and posts back what it found. This file keeps the + * decisions — what to fetch, where it goes, what a missing tool means — and + * the machine only executes, exactly as `advance()` does for a section. See + * docs/WORKER.md. + * + * The one ingest that stays here is an upload. The bytes have already arrived + * on this server from the browser, and sending four hundred megabytes back out + * to a laptop to cut eight stills would double the transfer to learn nothing; + * `ffmpeg` is a real dependency of the API image and already in + * `api/Dockerfile`. Transcription still leaves, as its own job, because + * `whisper` is the one step that wants a GPU and the one dependency the server + * should not carry — and whisper.cpp, the only whisper a non-developer can + * install in one click, takes a 16 kHz mono WAV as input anyway. */ /** @@ -46,6 +67,42 @@ const DATA = path.join(DATA_ROOT, "sources"); /** How many stills to take. Enough to see a flow, few enough to read. */ const FRAME_COUNT = 8; +/** + * The tallest a still is worth being. + * + * The frames are read for on-screen text and a browser address bar, and 4K + * costs bandwidth and disk to answer the same question. Named here rather than + * only at the ffmpeg call site because it now also travels in a payload to a + * machine this process cannot see, and the two must not drift apart. + */ +const FRAME_MAX_HEIGHT = 720; + +/** + * What whisper.cpp takes, and therefore what gets uploaded. + * + * Sixteen kilohertz mono is not a number invented here to be tidy — it is + * whisper.cpp's input format, so the conversion is a step that had to happen + * on somebody's machine regardless. Doing it during the ingest means the audio + * that crosses the network is roughly a thirtieth of the video: a megabyte a + * minute rather than four hundred, which is the whole reason transcription can + * afford to be a separate job on a different desktop. + */ +const AUDIO_RATE = 16_000; + +/** The one name a source's audio may have, on either side of the network. */ +export const AUDIO_FILE = "audio.wav"; + +/** + * How long the machine fetching a link gets before it gives up. + * + * yt-dlp climbs a ladder of three attempts and each may pull tens of + * megabytes, so this is generous on purpose. The server says it rather than + * the worker choosing, for the same reason `SECTION_TIMEOUT_MS` does: an + * ingest must not take twice as long on one teammate's laptop as on another's + * because of a different default. + */ +const INGEST_TIMEOUT_MS = 900_000; + /** * How long whisper gets before it is killed. * @@ -56,6 +113,19 @@ const FRAME_COUNT = 8; */ const WHISPER_TIMEOUT_MS = 600_000; +/** Which whisper model the transcriber is told to load. */ +const WHISPER_MODEL = "base"; + +/** + * How long a job may sit waiting for a machine that is capable but shut. + * + * The same fifteen minutes `advance()` gives a section, and for the same + * reason: somebody opening their laptop is the normal resolution and it should + * just start, while a laptop that stays shut has to end as a stated failure + * rather than as a row that reads "Fetching…" all night. + */ +const WAIT_FOR_MACHINE_MS = 15 * 60_000; + /** * The only shapes a frame filename may have — the evenly spaced ones, and the * ones cut at a time somebody asked for. Anything else is a path traversal @@ -74,11 +144,38 @@ export class IngestError extends Error { const dirFor = (sid: string) => path.join(DATA, sid); +/** Where a source's 16 kHz WAV is, for the endpoints that take and serve it. */ +export function audioPath(sourceId: string) { + return path.join(dirFor(sourceId), AUDIO_FILE); +} + +/** Make sure a source's directory exists before something is written into it. */ +export async function ensureSourceDir(sourceId: string) { + const dir = dirFor(sourceId); + await fs.mkdir(dir, { recursive: true }); + return dir; +} + /** * Fetch a source, or hand back the one already fetched. * * Re-fetching is opt-in: the same reel gives the same frames and the same * transcript, and a run started twice should not cost two downloads. + * + * **This no longer fetches anything.** It writes a job and returns a source in + * `fetching`, exactly the shape `advance()` gave `write_section`: the server + * decides what to fetch and where the results go, and whichever machine has + * yt-dlp on it does the work. The old version spawned yt-dlp, ffmpeg and + * whisper inside the API process, which is only correct while the API is a + * laptop — on the VPS `docs/DEPLOYING.md` describes it is a box with no GPU, + * no browser cookies and, quite often, no yt-dlp at all. + * + * The three routing cases are `advance()`'s, and they are three rather than + * one because the answers differ: an install command, "open your laptop", and + * "wait a moment" are not the same sentence. What they share is the rule + * underneath — a job that cannot run must reach a terminal state, visibly. A + * source left on `fetching` with nothing in the queue behind it is the + * five-days-on-Writing row of `docs/DECISIONS.md` wearing a different hat. */ export async function ingest(input: { url: string; @@ -93,66 +190,126 @@ export async function ingest(input: { const existing = await findByUrl(url, input.workspaceId ?? null); if (existing && !input.refresh && existing.state === "ready") return existing; - const source = existing ?? (await createSource({ url, workspaceId: input.workspaceId })); - await updateSource(source.id, { state: "fetching", error: "" }); + const source = + existing ?? (await createSource({ url, workspaceId: input.workspaceId })); - try { - const meta = await fetchMeta(url); - await updateSource(source.id, { - title: String(meta.title ?? ""), - uploader: String(meta.uploader ?? meta.channel ?? ""), - description: String(meta.description ?? "").slice(0, 8000), - duration: meta.duration == null ? null : Math.round(Number(meta.duration)), - thumbnail: String(meta.thumbnail ?? ""), - meta, - }); + /* + * One live ingest per source. + * + * `jobs_one_per_section` cannot help here: its predicate is on + * (run_id, section_id), both null for an ingest, and Postgres treats NULLs + * as distinct — so the index that stops a section being written twice is + * silent about a link being fetched twice. Two tabs, a double click on + * Fetch, or a refresh while one is already running would otherwise put two + * machines on the same download, and the second one's frames would land on + * top of the first's half-written JPEGs. + * + * Read rather than enforced, because the honest fix is a partial unique + * index on `source_id` and that lives in a migration this change does not + * own. A race between two clicks in the same second can still slip through; + * the cost of that is one duplicated download, not a corrupted row. + */ + const already = (await activeJobs()).some( + (j) => j.sourceId === source.id && j.kind === "ingest_source", + ); + if (already) { + return (await updateSource(source.id, { state: "fetching" }))!; + } - const dir = dirFor(source.id); - await fs.mkdir(dir, { recursive: true }); + const payload = { + sourceId: source.id, + url, + refresh: input.refresh === true, + frameCount: FRAME_COUNT, + maxHeight: FRAME_MAX_HEIGHT, + audioRate: AUDIO_RATE, + audioFile: AUDIO_FILE, + timeoutMs: INGEST_TIMEOUT_MS, + /* + * Where the bytes go, named in the payload rather than assembled on the + * worker. The server is the single source of truth for frames — the + * browser renders them, the machine that ingests and the machine that + * writes are usually different, and desktops are not backed up — so + * ingest uploads and write downloads, and the uploader is told exactly + * where by the side that owns the filesystem. + */ + uploadFrames: `/api/workers/sources/${source.id}/frames`, + uploadAudio: `/api/workers/sources/${source.id}/audio`, + }; - const video = await download(url, dir); - const frames = video.file ? await cutFrames(video.file, dir) : []; - // Only when there is a file: with nothing downloaded there is no audio, - // and "whisper is switched off" underneath "the video is private" is a - // second sentence about a step that was never going to run. - const spoken = video.file - ? await transcribe(video.file, dir) - : { text: "", note: "", failed: false }; + /* + * `yt-dlp` alone, and the rest as soft preferences. + * + * The pipeline degrades rather than failing when a tool is missing — + * metadata alone is still far more than nothing — and that must survive the + * move. Naming ffmpeg in `needs` would make a machine with yt-dlp and no + * ffmpeg unable to see the job at all, so a link that would have yielded a + * title, a caption and a transcript yields an error instead. `whisper` is + * not here for a stronger reason still: transcription is its own job, so an + * estate with no whisper anywhere still ingests. + */ + const needs = ["yt-dlp"]; + const route = await routeFor(needs); + /* + * Case 1: nothing on this estate has ever advertised yt-dlp. + * + * The job is unroutable at birth and the source fails with the sentence + * naming what to install. Queueing it instead would be a row that reads + * "Fetching…" until somebody deletes it, because no machine can ever claim + * it — forever is not a state a person can act on. + */ + if (route.missing.length) { + const need = route.missing[0]; + const install = await installHint(need); + const message = install + ? `No machine here has ${need}, so this link cannot be fetched. Install it on one — ${install} — then press Fetch again.` + : `No machine here has ${need}, and none has ever reported how to install it.`; + await enqueue({ + kind: "ingest_source", + workspaceId: source.workspaceId, + sourceId: source.id, + needs, + payload, + state: "unroutable", + error: message, + }); return (await updateSource(source.id, { - state: "ready", - frames, - transcript: spoken.text, - /* - * The reason, in yt-dlp's own words. - * - * "Could not be downloaded" is the same sentence for a private video, a - * dead link and a platform refusing anonymous requests, and only one of - * those is worth trying again. The tool already says which. - */ - /* - * Whichever step fell short, in its own words. - * - * The download's reason wins when there is one: it is the larger loss, - * and it is why whisper never ran. Otherwise a whisper that FAILED gets - * the line — this path said nothing at all about the transcript before, - * so a whisper killed at the ten minute mark produced a source marked - * ready, with an empty transcript, and no hint anywhere that anything - * had gone wrong. A whisper deliberately switched off says nothing here: - * a link fetched for its caption is doing what was asked of it, and an - * error on every one of them is a banner people learn to stop reading. - */ - error: video.file - ? spoken.failed - ? spoken.note - : "" - : `Metadata only — ${video.error}. The caption and title are still usable.`, + state: "failed", + error: message, }))!; - } catch (e) { - const message = e instanceof Error ? e.message : "The download failed"; - await updateSource(source.id, { state: "failed", error: message.slice(0, 600) }); - throw new IngestError(message, 502); } + + /* + * Case 2: a machine could fetch it, but none of them is awake. + * + * Queued with a deadline and a sentence naming the machine, so somebody + * opening their laptop simply starts it. The source stays `fetching` and + * carries the wait as its error line, because "waiting for Shakhawat's + * desktop" is the true answer to "why has this not moved" and a blank row is + * not. Past `wait_until` the reaper ends the job — see `sourceFailed`. + */ + const asleep = route.live.length === 0; + const machines = route.capable.map((w) => w.name).join(" or "); + const waiting = `Waiting for ${machines}, which has not been seen recently.`; + + await enqueue({ + kind: "ingest_source", + workspaceId: source.workspaceId, + sourceId: source.id, + needs, + payload, + ...(asleep + ? { waitUntil: new Date(Date.now() + WAIT_FOR_MACHINE_MS), error: waiting } + : {}), + }); + + // Case 3 is ordinary queueing and has nothing to say beyond position, so + // the error line is cleared rather than left showing a previous attempt's. + return (await updateSource(source.id, { + state: "fetching", + error: asleep ? waiting : "", + }))!; } /** @@ -169,11 +326,13 @@ export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024; /** * A video somebody had on their machine. * - * The same pipeline as a link, minus the download: the file is written into - * the source's own directory and then cut and transcribed exactly as a fetched - * one is. Footage that was never posted anywhere is the ordinary case for - * research — a screen recording, a client's export, a reel saved months ago — - * and it had no way in at all while a URL was the only input. + * The one ingest that does **not** become a job, and deliberately. The bytes + * are already arriving here from the browser; sending a four hundred megabyte + * recording back out to a laptop to cut eight stills would double the transfer + * to learn nothing, and `ffmpeg` is already a dependency of the API image. So + * the server does the ffmpeg half itself — the frames and the WAV — and then + * enqueues `transcribe_audio` like any other source, because that is the half + * that wants a GPU and is the one dependency the server should not carry. * * Streamed to disk, not buffered. `await file.arrayBuffer()` holds the whole * upload in memory — a 400MB recording became 400MB of heap in the route, then @@ -201,27 +360,37 @@ export async function ingestFile(input: { await updateSource(source.id, { state: "fetching", error: "" }); try { - const dir = dirFor(source.id); - await fs.mkdir(dir, { recursive: true }); + const dir = await ensureSourceDir(source.id); const file = path.join(dir, `video${ext}`); await writeCapped(input.stream, file); const seconds = await durationOf(file); const frames = await cutFrames(file, dir); - const spoken = await transcribe(file, dir); + // The audio out of the same file, in the format the transcriber takes, so + // the only thing that ever leaves this server for a transcription is about + // a megabyte a minute rather than the whole recording. + const audio = await extractAudio(file, dir); - return (await updateSource(source.id, { + const ready = (await updateSource(source.id, { state: "ready", title: name, duration: seconds || null, frames, - transcript: spoken.text, - // What actually happened, rather than the guess this used to print. "Off - // or not installed" was shown to people whose whisper was installed, - // switched on, and killed by the timeout — the one case of the four - // where there is something to be done about it. - error: spoken.note, }))!; + + /* + * The transcript arrives later, on somebody else's machine. + * + * An upload is `ready` the moment the frames exist — everything a person + * came here for is on the page — and the note says where the words are. + * Waiting for whisper before calling it ready would put a spinner on a + * source that is already usable, on an estate that may have no whisper at + * all. + */ + const note = audio + ? await requestTranscript(ready) + : "No transcript — ffmpeg could not get any audio out of that file."; + return (await updateSource(source.id, { error: note }))!; } catch (e) { const message = e instanceof Error ? e.message : "The upload failed"; await updateSource(source.id, { state: "failed", error: message.slice(0, 600) }); @@ -229,6 +398,290 @@ export async function ingestFile(input: { } } +/** + * A machine has finished fetching a link. Write down what it found. + * + * The counterpart of `sectionResult` in services/runs.ts, and called from the + * same place: the worker-facing controller, once `jobs.succeed()` has + * confirmed the job still belonged to the machine posting it. Do not call it + * on a result `succeed()` returned null for — that is a laptop waking up and + * posting an ingest that was reassigned twenty minutes ago, and whatever is on + * the row now is the copy that stands. + * + * The frames and the WAV are already here: they went up through + * `POST /api/workers/sources/:id/frames` and `.../audio` before this was + * posted, so the result only NAMES files the server already holds. Every name + * is checked against `FRAME_FILE` again anyway. It was checked at the upload, + * but this is a different write — these names go onto the row and are later + * joined to a path by `framePath`, and a name that never matched an uploaded + * file would put a broken picture in the picker at best. + * + * Nothing here fails the source over a missing transcript. The worker reports + * what it could not do in `error`, that sentence is shown, and the source is + * still `ready`: metadata and stills are far more than nothing, and an ingest + * that failed because an optional tool was absent would make the feature + * unavailable to anyone without a Python toolchain. + */ +export async function sourceResult( + job: Pick, + result: Record, +): Promise { + if (job.kind !== "ingest_source" || !job.sourceId) return null; + const source = await getSource(job.sourceId); + if (!source) return null; + + const str = (v: unknown) => (typeof v === "string" ? v : ""); + const meta = result.meta; + const duration = Number(result.duration); + + const frames: Frame[] = Array.isArray(result.frames) + ? (result.frames as unknown[]) + .map((f) => (f && typeof f === "object" ? (f as Record) : {})) + .filter((f) => FRAME_FILE.test(str(f.file))) + .map((f) => ({ at: Number(f.at) || 0, file: str(f.file) })) + .sort((a, b) => a.at - b.at) + : []; + + const transcript = str(result.transcript).trim(); + + await updateSource(source.id, { + state: "ready", + title: str(result.title), + uploader: str(result.uploader), + description: str(result.description).slice(0, 8000), + duration: Number.isFinite(duration) && duration > 0 ? Math.round(duration) : null, + thumbnail: str(result.thumbnail), + frames, + ...(transcript ? { transcript } : {}), + ...(meta === undefined ? {} : { meta }), + }); + + /* + * Whichever step fell short, in its own words — and the machine's reason + * wins. + * + * A download that failed is the larger loss and it is why there is no audio + * to transcribe, so its sentence goes on the row and no transcription is + * asked for. `Metadata only — ` matters because "could + * not be downloaded" is the same sentence for a private video, a dead link + * and a platform refusing anonymous requests, and only one of those is worth + * trying again. + */ + const reported = str(result.error).trim(); + const audio = result.audio === true; + + let note = reported; + if (!note && !transcript) { + note = audio + ? await requestTranscript(source) + : "No transcript — the machine that fetched this got no audio out of it."; + } + + return (await updateSource(source.id, { error: note }))!; +} + +/** + * A transcription came back. Put the words on the source. + * + * Separate from `sourceResult` because it is a separate job on, usually, a + * separate machine: an upload never had an `ingest_source` at all, and a link + * fetched on a laptop with no GPU is transcribed on a desktop that has one. + */ +export async function transcriptResult( + job: Pick, + result: Record, +): Promise { + if (job.kind !== "transcribe_audio" || !job.sourceId) return null; + const source = await getSource(job.sourceId); + if (!source) return null; + + const text = (typeof result.text === "string" ? result.text : "").trim(); + + /* + * Transcribed to nothing is a fact about the audio, not a success. + * + * A reel with music and no speech genuinely has no transcript, and so does a + * whisper that exited clean and wrote an empty file. Neither is a failure of + * the source and neither may look like a transcript that simply has not + * arrived yet, so both get a sentence and the source stays ready. + */ + if (!text) { + return (await updateSource(source.id, { + error: "No transcript — the audio was transcribed and came back empty.", + }))!; + } + + return (await updateSource(source.id, { + transcript: text, + // Only a note about the transcript is cleared. An ingest's own reason — + // "Metadata only — this video is private" — is still true after a + // transcription lands somewhere else, and wiping it would quietly remove + // the only record that the video was never downloaded. + ...(isTranscriptNote(source.error) ? { error: "" } : {}), + }))!; +} + +/** + * A source's job failed on the machine that held it, or ran out of patience. + * + * The counterpart of `sectionFailed`, and it must be called from the same two + * places for the same reason: the worker-facing controller when `jobs.fail()` + * comes back with a job in a TERMINAL state, and the reaper when a job ends on + * evidence nobody reports — a lease that expired because a laptop was shut, + * attempts spent, or a `wait_until` nobody came back for. Without the second + * caller a source sits on `fetching` with an empty queue behind it, which is + * precisely the row this whole design exists to make impossible. + * + * Do not call it on a retryable failure that still has attempts left: that job + * is about to be tried on another machine, and marking the source failed in + * between would flash "Failed" on something that is about to succeed. + * + * The two kinds end differently, and that difference is the degradation + * philosophy stated in code: + * + * - An `ingest_source` that failed produced nothing, so the source fails and + * says which machine said what. + * - A `transcribe_audio` that failed cost the source its words and nothing + * else. The metadata, the caption and the stills are all still there and are + * still worth reading, so the source stays `ready` and the failure is a + * sentence rather than a state. Failing it here would mean an estate whose + * only whisper machine is broken could not use a link at all. + */ +export async function sourceFailed( + job: Pick, + error: string, + /** The machine that reported it, so the sentence can name it. "" if none. */ + workerName = "", +): Promise { + if (!job.sourceId) return null; + const source = await getSource(job.sourceId); + if (!source) return null; + + const reason = (error || "The machine did not say why.").slice(0, 600); + const on = workerName ? ` on ${workerName}` : ""; + + if (job.kind === "transcribe_audio") { + return (await updateSource(source.id, { + // Kept in the "No transcript" shape on purpose: `transcriptResult` reads + // that shape to know whether an arriving transcript may clear the line. + error: `No transcript — transcription failed${on}: ${reason}`, + }))!; + } + + if (job.kind !== "ingest_source") return source; + + return (await updateSource(source.id, { + state: "failed", + error: `${reason}${on ? ` (${workerName})` : ""}`, + }))!; +} + +/** + * Ask for a transcript, and return the sentence the source should show. + * + * The same three routing cases as everything else, and the same rule — a job + * that cannot run must reach a terminal state, visibly — with one deliberate + * difference: **none of the three fails the source.** A missing transcriber + * degrades a source and says which; it does not fail an ingest. That was true + * when whisper ran inline and it has to survive the split, or an estate where + * nobody has installed whisper stops being able to use a link at all. + * + * So case 1 does not even enqueue. An `unroutable` row would be an honest + * record, but it would also be a job on the queue view for something nobody + * asked to be told about twice; the sentence on the source is the whole of + * what a person can act on. + */ +async function requestTranscript(source: Source): Promise { + if (source.transcript.trim()) return ""; + + const needs = ["whisper"]; + const route = await routeFor(needs); + + if (route.missing.length) { + const install = await installHint("whisper"); + return install + ? `No transcript — no machine here has whisper. Install it on one: ${install}` + : "No transcript — no machine here has whisper."; + } + + /* + * One live transcription per source, for the reason `ingest` guards itself: + * an ingest whose result lands twice, or an upload retried, would otherwise + * put two machines on the same WAV and spend two GPUs to write the same + * words. + */ + const already = (await activeJobs()).some( + (j) => j.sourceId === source.id && j.kind === "transcribe_audio", + ); + if (already) return "Transcript — a machine is working on it."; + + const asleep = route.live.length === 0; + const machines = route.capable.map((w) => w.name).join(" or "); + + await enqueue({ + kind: "transcribe_audio", + workspaceId: source.workspaceId, + sourceId: source.id, + needs, + payload: { + sourceId: source.id, + // The WAV and nothing else. This is the sentence the whole split turns + // on: audio is roughly a thirtieth of the video, so the round trip a + // desktop GPU is worth is a megabyte a minute rather than four hundred. + audioUrl: `/api/workers/sources/${source.id}/audio`, + model: WHISPER_MODEL, + timeoutMs: WHISPER_TIMEOUT_MS, + }, + ...(asleep + ? { + waitUntil: new Date(Date.now() + WAIT_FOR_MACHINE_MS), + error: `No transcript yet — waiting for ${machines}, which has not been seen recently.`, + } + : {}), + }); + + return asleep + ? `No transcript yet — waiting for ${machines}, which has not been seen recently.` + : "Transcript — a machine is working on it."; +} + +/** + * Whether a source's error line is about its transcript rather than about the + * source itself. + * + * A marker in the wording rather than a column, because there is exactly one + * `error` field and two things want to write to it. Every sentence this file + * produces about a transcript begins with "No transcript" or "Transcript", and + * only those may be cleared when words finally arrive — an ingest's own reason + * ("Metadata only — the video is private") is still true afterwards and must + * survive. + */ +function isTranscriptNote(error: string) { + return /^(No transcript|Transcript)\b/.test(error.trim()); +} + +/** + * How to install a tool nothing on this estate has. + * + * Read off any machine that has ever reported the tool, present or not: a + * `ToolStatus` carries its own `install` string, so the sentence a person is + * shown comes from the same table the probe wrote rather than from a second + * copy of the install commands kept here and left to drift. When no machine + * has ever heard of it there is nothing honest to say beyond its name. + * + * The twin of the one in services/runs.ts. Duplicated rather than shared + * because it is six lines and the alternative is a helpers module that exists + * to hold six lines; if a third caller appears, that is the moment to move it. + */ +async function installHint(need: string): Promise { + const workers = await listWorkers(); + for (const w of workers) { + const tool = w.tools.find((t) => t.id === need); + if (tool?.install) return tool.install; + } + return ""; +} + /** * One more still, at a second somebody asked for. * @@ -236,6 +689,12 @@ export async function ingestFile(input: { * the wrong answer whenever the thing worth seeing — the address bar, the * result on screen — happens between two of them. Added to the row rather than * replacing it, in time order, so the strip stays a timeline. + * + * Only for uploads, now, and the message says so. A fetched link's video never + * comes to this server — the worker uploads eight stills and a WAV and keeps + * the four hundred megabytes on its own disk — so there is genuinely nothing + * here to cut a ninth frame out of, and a sentence that pretended otherwise + * would send somebody looking for a file that was never meant to exist. */ export async function addFrameAt(sourceId: string, at: number): Promise { const source = await getSource(sourceId); @@ -246,7 +705,7 @@ export async function addFrameAt(sourceId: string, at: number): Promise const video = files.find((f) => f.startsWith("video.")); if (!video) { throw new IngestError( - "The video itself was never downloaded, so there is nothing to take a frame from.", + "The video itself is not on this server — a fetched link is downloaded on the machine that ingests it, and only the stills and the audio come back. Upload the file to take extra frames from it.", 409, ); } @@ -269,7 +728,7 @@ export async function addFrameAt(sourceId: string, at: number): Promise "-frames:v", "1", "-vf", - "scale=720:-2", + `scale=-2:${FRAME_MAX_HEIGHT}`, "-q:v", "4", path.join(dir, file), @@ -351,110 +810,25 @@ export function framePath(sourceId: string, file: string) { return FRAME_FILE.test(file) ? path.join(dirFor(sourceId), file) : null; } -/** What the platform says about the video, without downloading it. */ -async function fetchMeta(url: string): Promise> { - await requireTool("yt-dlp"); - const { code, out, err } = await run( - "yt-dlp", - ["--dump-single-json", "--no-warnings", "--no-playlist", url], - { timeout: 90_000 }, - ); - if (code !== 0 || !out.trim()) { - throw new Error(firstUseful(err) || "yt-dlp could not read that link"); - } - try { - return JSON.parse(out) as Record; - } catch { - throw new Error("yt-dlp returned something that was not JSON"); - } -} - -/** - * The video file itself. - * - * Tried more than once, because "download this video" is not one operation any - * more. YouTube in particular refuses anonymous requests for the media even - * when it happily hands over the metadata, so the ladder goes: a single - * progressive file (smallest, most often allowed), then separate video+audio - * streams muxed by ffmpeg, then the same again with the cookies from a signed - * in browser. - * - * Capped at 720p throughout: the frames are read for on-screen text and a - * browser address bar, and 4K costs bandwidth and disk to answer the same - * question. - * - * Returns the path, or "" and the reason it could not — metadata alone is - * still a useful source, so this degrades rather than throwing. - */ -async function download( - url: string, - dir: string, -): Promise<{ file: string; error: string }> { - // Switched off means metadata only, deliberately — which is the fast path - // when all you want is the caption and the title. - if (!(await allowed("yt-dlp"))) { - return { file: "", error: "yt-dlp is switched off in Integrations" }; - } - - const attempts: { label: string; args: string[] }[] = [ - { label: "progressive", args: ["-f", "b[height<=720]/b"] }, - { label: "split streams", args: ["-f", "bv*[height<=720]+ba/bv*+ba"] }, - // Last, and only if the others failed: reading the browser's cookies is - // how you get at something the platform will only serve to a signed-in - // session. It fails harmlessly when the browser is running and holding - // its cookie database open. - { - label: "browser cookies", - args: [ - "--cookies-from-browser", - "chrome", - "-f", - "b[height<=720]/b", - ], - }, - ]; - - const failures: string[] = []; - - for (const attempt of attempts) { - const { code, err } = await run( - "yt-dlp", - [ - ...attempt.args, - "--no-playlist", - "--no-warnings", - "-o", - path.join(dir, "video.%(ext)s"), - url, - ], - { timeout: 300_000 }, - ); - - const files = await fs.readdir(dir).catch(() => []); - const file = files.find((f) => f.startsWith("video.")); - if (code === 0 && file) return { file: path.join(dir, file), error: "" }; - - failures.push(`${attempt.label}: ${firstUseful(err) || `exit ${code}`}`); - } - - return { file: "", error: failures[0] ?? "yt-dlp could not fetch the media" }; -} - /** - * Whether a tool may run: installed AND switched on. + * Whether a tool is here to be run. * - * The switches on the integrations page were saved and then ignored, which - * made them the worst kind of control — one that remembers what you told it - * and does the opposite. + * Presence only. This used to AND `toolStatus(id).present` with the global + * `settings.enabled`, and that switch is now a fact about a WORKER — + * `workers.enabled`, evaluated by the machine against its own row. Leaving it + * in would mean somebody switching ffmpeg off for their laptop silently + * stopped every browser upload on the server from getting any stills, which is + * a control doing something nobody asked it to. `ffmpeg` and `ffprobe` are + * dependencies of the API image — they are in `api/Dockerfile` — so on this + * side there is nothing to switch off. */ -async function allowed(id: "yt-dlp" | "ffmpeg" | "ffprobe" | "whisper") { - const [status, settings] = await Promise.all([toolStatus(id), getSettings()]); - return status.present && settings.enabled.includes(id); +async function present(id: "ffmpeg" | "ffprobe") { + return (await toolStatus(id)).present; } /** Evenly spaced stills, as JPEGs beside the video. */ async function cutFrames(video: string, dir: string): Promise { - if (!(await allowed("ffmpeg"))) return []; + if (!(await present("ffmpeg"))) return []; const seconds = await durationOf(video); if (!seconds) return []; @@ -476,7 +850,7 @@ async function cutFrames(video: string, dir: string): Promise { "-frames:v", "1", "-vf", - "scale=720:-2", + `scale=-2:${FRAME_MAX_HEIGHT}`, "-q:v", "4", path.join(dir, file), @@ -488,8 +862,35 @@ async function cutFrames(video: string, dir: string): Promise { return frames; } +/** + * The audio, on its own, in the only format the transcriber takes. + * + * `-vn` drops the video, `-ac 1` mixes to mono and `-ar 16000` resamples, + * which together are whisper.cpp's required input and, not by coincidence, + * about a thirtieth of the bytes. Returns whether there is a file: a silent + * screen recording with no audio track at all is a real thing people upload, + * and it is not a failure — it is a source with no words in it, which the + * caller says out loud rather than leaving as an empty transcript nobody can + * explain. + */ +async function extractAudio(video: string, dir: string): Promise { + if (!(await present("ffmpeg"))) return false; + const out = path.join(dir, AUDIO_FILE); + const { code } = await run( + "ffmpeg", + ["-y", "-i", video, "-vn", "-ac", "1", "-ar", String(AUDIO_RATE), out], + { timeout: 300_000 }, + ); + if (code !== 0) return false; + // ffmpeg can exit 0 having written a header and nothing else, and a WAV with + // no samples sent to a GPU is a minute of somebody's machine spent to + // transcribe silence. + const size = await fs.stat(out).then((s) => s.size).catch(() => 0); + return size > 1024; +} + async function durationOf(video: string) { - if (!(await allowed("ffprobe"))) return 0; + if (!(await present("ffprobe"))) return 0; const { out } = await run( "ffprobe", [ @@ -507,148 +908,6 @@ async function durationOf(video: string) { return Number.isFinite(seconds) && seconds > 0 ? seconds : 0; } -/** - * What came back from the attempt to transcribe — and, when nothing did, why. - * - * A bare "" said four different things at once: whisper is switched off, - * whisper is not installed, whisper ran for ten minutes and was killed, and - * whisper exited fine but wrote nothing. The first two are the fast path and - * the last two are failures, and the caller marked all four `state: "ready"` - * with an empty transcript — so a source whose transcription had timed out - * looked exactly like one nobody wanted transcribed, and the model later wrote - * a thinner section off the frames alone with nobody the wiser. - */ -type Transcribed = { - /** The spoken audio as text, or "" if there is none. */ - text: string; - /** - * Why there is no text, as the sentence the source's `error` field carries - * to the screen. Empty when there IS text, and only then. - */ - note: string; - /** - * Whether that note is a real failure or an expected absence. Switched off - * and not installed are decisions about this machine; timed out and crashed - * are things that went wrong and might go right on a retry. - */ - failed: boolean; -}; - -/** - * The spoken audio, as text. - * - * Whisper if it is switched on and installed. If it is not, this returns no - * text and the source is still useful — the caption and description usually - * carry the domain too. The alternative, failing the whole ingest over an - * optional tool, would make the feature unavailable to anyone without a Python - * toolchain. A whisper that ran and failed degrades the same way, but says so: - * the transcript is the difference between a section written from what was - * said and one written from what could be seen. - */ -async function transcribe(video: string, dir: string): Promise { - /* - * However this machine can reach it. - * - * `pip install openai-whisper` often lands the script somewhere not on - * PATH — normal on Windows — so the probe also tries `python -m whisper` - * and records whichever answered. Running the recorded one means an install - * the probe found is an install this can use. - */ - const [tool, settings] = await Promise.all([toolStatus("whisper"), getSettings()]); - - /* - * The two expected absences, told apart. - * - * `allowed()` collapses them into one boolean, which is the right answer for - * ffmpeg and the wrong one here: "switched off" is fixed by a toggle on the - * Integrations page and "not installed" by a pip install, and a person told - * the wrong one goes looking in the wrong place. - */ - if (!settings.enabled.includes("whisper")) { - return { - text: "", - note: "No transcript — whisper is switched off in Integrations.", - failed: false, - }; - } - if (!tool.present) { - return { - text: "", - note: `No transcript — whisper is not installed here. ${tool.install}`, - failed: false, - }; - } - - const { code, err } = await run( - tool.command, - [ - ...tool.lead, - video, - "--model", - "base", - "--output_format", - "txt", - "--output_dir", - dir, - "--fp16", - "False", - ], - { env: tool.env, timeout: WHISPER_TIMEOUT_MS }, - ); - - if (code !== 0) { - /* - * A timeout reads as its own thing, not as a crash. - * - * `run()` kills the child and returns -1 with "Timed out after ..." on - * stderr, and that is the likeliest real failure here: the base model on a - * CPU runs at roughly real time, so a long video on an ordinary laptop - * runs out of clock rather than crashing. The remedy is a shorter clip, - * not a reinstall, so the sentence must not say the same thing a crash - * would. - */ - if (/Timed out after/.test(err)) { - return { - text: "", - note: `No transcript — whisper ran out of time after ${Math.round( - WHISPER_TIMEOUT_MS / 60_000, - )} minutes on this video. A shorter clip will finish.`, - failed: true, - }; - } - return { - text: "", - note: `No transcript — whisper failed: ${firstUseful(err) || `exit ${code}`}`, - failed: true, - }; - } - - const files = await fs.readdir(dir).catch(() => []); - const txt = files.find((f) => f.endsWith(".txt")); - /* - * Exited clean and left nothing behind: a directory it could not write into, - * or a build that wrote some other format. There is no transcript either - * way, and it is not the fast path, so it does not get to look like one. - */ - if (!txt) { - return { - text: "", - note: "No transcript — whisper finished but wrote no text file.", - failed: true, - }; - } - - const text = (await fs.readFile(path.join(dir, txt), "utf8").catch(() => "")).trim(); - if (!text) { - return { - text: "", - note: "No transcript — whisper wrote an empty text file.", - failed: true, - }; - } - return { text, note: "", failed: false }; -} - /** One frame's bytes, for the API route that serves them. */ export async function frameFile(sourceId: string, file: string) { // Nothing but a bare filename gets through: a source id and a name from the @@ -710,13 +969,3 @@ export async function sourceBrief(sourceId: string) { ...lines, ].join("\n\n"); } - -/** Trims a tool's stderr to the line that says what went wrong. */ -function firstUseful(err: string) { - const line = err - .split("\n") - .map((l) => l.trim()) - .find((l) => l && !l.startsWith("WARNING")); - return (line ?? "").replace(/^ERROR:\s*/, "").slice(0, 300); -} - diff --git a/lib/server/services/researcher.ts b/lib/server/services/researcher.ts index 24d54a6..f1645ec 100644 --- a/lib/server/services/researcher.ts +++ b/lib/server/services/researcher.ts @@ -1,6 +1,6 @@ import { write } from "@/lib/server/brains"; -import { getSettings } from "@/lib/server/repos/settings"; import { getSource, updateSource } from "@/lib/server/repos/sources"; +import { DEFAULT_BRAIN, getWorkspace } from "@/lib/server/repos/workspaces"; import { frameBytes, framePath } from "@/lib/server/services/ingest"; /** @@ -193,13 +193,27 @@ export async function research(input: { .filter(Boolean) .join("\n\n"); - const settings = await getSettings(); - const raw = await write(settings.brain, { + /* + * The brain belongs to the workspace the source is filed under. + * + * It used to be `settings.brain`, one value for the whole console. Which + * model reads a reel is the same editorial choice as which model writes the + * script from it — a workspace producing Bangla scripts and one producing + * SEO copy may reasonably differ, and neither should change because somebody + * else switched a global. A source with no workspace falls back to the same + * default a new workspace gets, so this can never resolve to nothing. + */ + const workspace = source.workspaceId ? await getWorkspace(source.workspaceId) : null; + const raw = await write(workspace?.brain || DEFAULT_BRAIN, { system: SYSTEM, user: context, tier: "high", images: images.length ? images : undefined, imageFiles: files.length ? files : undefined, + // This runs in the API process, on the API's own disk, at the request of + // whoever administers it — not on a teammate's desktop, which is the case + // the per-machine switch exists for. + allowFrameRead: true, timeoutMs: 600_000, }); diff --git a/lib/server/services/runs.ts b/lib/server/services/runs.ts index c0091c6..5007093 100644 --- a/lib/server/services/runs.ts +++ b/lib/server/services/runs.ts @@ -1,10 +1,9 @@ import { markUsed, resolve as resolvePack } from "@/lib/server/repos/packs"; -import { findBrain, type Transport } from "@/lib/server/brains"; -import { getSettings } from "@/lib/server/repos/settings"; -import { sourceBrief } from "@/lib/server/services/ingest"; +import { findBrain, write, type Transport } from "@/lib/server/brains"; +import { frameBytes, framePath, sourceBrief } from "@/lib/server/services/ingest"; import { fetchSource } from "@/lib/server/services/webfetch"; import { plan, systemPrompt, userPrompt } from "@/lib/server/prompt"; -import { getWorkspace } from "@/lib/server/repos/workspaces"; +import { DEFAULT_BRAIN, getWorkspace } from "@/lib/server/repos/workspaces"; import { getTopic, updateTopic } from "@/lib/server/repos/series"; import { getSource } from "@/lib/server/repos/sources"; import { @@ -374,11 +373,21 @@ export async function advance(runId: string): Promise<{ waiting: { sectionId: string; machine: string }[]; /** Sections nothing on this estate can ever write. Already failed. */ unroutable: string[]; + /** + * Sections the server is writing itself, on the workspace's API key. + * + * Its own list rather than folded into `enqueued`, because these have no job + * row and no machine: "3 sections queued" and "3 sections are costing money + * right now" are different sentences and the page must be able to tell them + * apart. Empty unless `workspaces.api_fallback` is on. + */ + onApi: string[]; }> { const out = { enqueued: [] as string[], waiting: [] as { sectionId: string; machine: string }[], unroutable: [] as string[], + onApi: [] as string[], }; const run = await getRun(runId); @@ -449,8 +458,17 @@ export async function advance(runId: string): Promise<{ if (!ready.length) return out; - const settings = await getSettings(); - const brainId = settings.brain; + /* + * Which model, and may the server pay for it — both read off the workspace. + * + * `settings.brain` was one row for the whole console, so a teammate + * switching the picker retuned every brand in the database at once. It is + * `workspaces.brain` now, beside `brand_voice`, and read here the way + * `startRun` already reads the voice: off the workspace, never off the + * request. See migration 0016. + */ + const workspace = await getWorkspace(run.workspaceId); + const brainId = workspace?.brain || DEFAULT_BRAIN; const { needs, transport } = transportFor(brainId); /* @@ -474,6 +492,23 @@ export async function advance(runId: string): Promise<{ */ const allowFrameRead = route.capable.some((w) => w.canReadFrames); + /* + * The API-key fallback, and the two conditions it takes. + * + * Nothing awake can write this — either no machine anywhere has the CLI, or + * every machine that does is shut — AND this workspace has said, in + * writing, that the server may spend a key when that happens. + * + * The second half is the whole point and is not caution. This split exists + * so a run costs a subscription somebody already pays for; a fallback that + * fired by itself would spend real money at exactly the moment nobody was + * watching, which is the failure docs/DEPLOYING.md warns about. With it off + * the job takes the routing below — unroutable, or queued behind + * `wait_until` — and says so on the page. It stays available because + * "everyone has gone home and this ships tonight" is a real evening. + */ + const onApi = route.live.length === 0 && workspace?.apiFallback === true; + // One fetch of the evidence for the whole wave, not one per section: it is // the same website and the same reel for every section in a run. const brief = await evidenceFor(run); @@ -490,6 +525,25 @@ export async function advance(runId: string): Promise<{ allowFrameRead, ); + /* + * Case 0: nobody can write it and the workspace has agreed to pay. + * + * Ahead of the other three because it answers the same question they do — + * "no machine is going to take this" — with the one answer that produces a + * section instead of a message. The row goes to `writing` here, where the + * database can see it, and the write itself is detached: `advance` is + * called from an HTTP handler and a section takes minutes, so awaiting it + * would hold the request open until the gateway gave up. No job row, + * because a job with no machine to claim it is a queue entry nothing can + * ever pick up — which is the "Writing forever" bug in another costume. + */ + if (onApi) { + await setSection(runId, def.id, { state: "writing", error: "" }); + writeOnServer(run, def, brainId, payload); + out.onApi.push(def.id); + continue; + } + /* * Case 1: nothing on this estate has ever advertised the tool. * @@ -575,13 +629,123 @@ export async function advance(runId: string): Promise<{ // it. After the enqueues, not before: a topic marked generating for a run // that turned out to be entirely unroutable would be stuck on a state // nothing takes it out of. - if (run.topicId && out.enqueued.length) { + if (run.topicId && (out.enqueued.length || out.onApi.length)) { await updateTopic(run.topicId, { status: "generating" }); } return out; } +/* + * The sections the server is part way through writing itself. + * + * Only meaningful for the API fallback, and only because that path has no job + * row: everything on the queue is guarded by `jobs_one_per_section`, which is + * the database saying it where every replica can hear. This is the weaker + * thing — one process's memory — and it is honest about covering one process: + * two API replicas with the fallback on could both start the same section in + * the same second, before either had written `writing`. That is one more + * reason the fallback is off by default and per workspace rather than a mode + * the console runs in. + */ +const writingOnServer = new Set(); + +/** + * The frames, as bytes and as paths on THIS machine. + * + * Only for the fallback, and only because the server is where the frames + * actually live (docs/WORKER.md, "Frames": ingest uploads, write downloads). + * A worker gets URLs and downloads them; the server has the files already, so + * it reads them rather than fetching its own HTTP endpoint. + * + * Both shapes, because `write()` picks the transport: the Anthropic API path + * takes image blocks and every CLI takes a path. Handing over neither would be + * the worst outcome in docs/DECISIONS.md — a section answered from the + * transcript and presented as though the stills had been looked at — so when + * a section has frames and the chosen brain cannot be shown them, `write()` + * refuses and the section fails with a sentence saying why. + */ +async function serverFrames(run: Run, def: PackSectionDef) { + if (!run.sourceId || def.dependsOn.length) return { images: [], files: [] }; + const source = await getSource(run.sourceId); + if (!source?.frames.length) return { images: [], files: [] }; + + const images: { mediaType: string; data: string }[] = []; + const files: string[] = []; + for (const f of source.frames) { + const bytes = await frameBytes(run.sourceId, f.file); + if (bytes) images.push({ mediaType: "image/jpeg", data: bytes.toString("base64") }); + const file = framePath(run.sourceId, f.file); + if (file) files.push(file); + } + return { images, files }; +} + +/** + * Write one section here, on the workspace's API key, and pay for it. + * + * Deliberately not awaited by its caller — see "Case 0" — so it owns its own + * ending: every path through it finishes the section, because a section left + * on `writing` with no job behind it is a run that reads Writing forever and + * nothing on any timer to end it. The success and failure tails are + * `sectionResult` and `sectionFailed`, the same two the worker-facing + * controller calls, so a section written here is recorded, unblocks its + * dependants and moves its topic exactly as one written on a laptop does. + * + * `server:api` in `wrote_with` is the point of the column: it is the only + * record anywhere of which sections cost money rather than somebody's + * subscription. + */ +function writeOnServer( + run: Run, + def: PackSectionDef, + brainId: string, + payload: { system: string; user: string; tier: string; timeoutMs: number }, +) { + const key = `${run.id}::${def.id}`; + if (writingOnServer.has(key)) return; + writingOnServer.add(key); + + const job = { kind: "write_section" as const, runId: run.id, sectionId: def.id }; + + void (async () => { + const started = Date.now(); + try { + const { images, files } = await serverFrames(run, def); + const text = await write(brainId, { + system: payload.system, + user: payload.user, + tier: payload.tier, + timeoutMs: payload.timeoutMs, + images: images.length ? images : undefined, + imageFiles: files.length ? files : undefined, + /* + * The server's own disk, granted by the person who runs the server. + * + * `workers.can_read_frames` defaults off because that grant lands on + * somebody else's desktop and they get the last word. This path is the + * API host reading files it already stores, at the explicit request of + * a workspace that switched the fallback on, so the same caution does + * not apply and refusing here would only mean a frame section silently + * losing its frames. + */ + allowFrameRead: true, + }); + // Through sectionResult rather than around it: it is the one place that + // knows an empty answer is a failure rather than an empty section. + await sectionResult(job, { text, ms: Date.now() - started }, "server:api"); + } catch (e) { + await sectionFailed( + job, + e instanceof Error ? e.message.slice(0, 400) : "The API did not answer", + Date.now() - started, + ); + } finally { + writingOnServer.delete(key); + } + })(); +} + /** * A `write_section` result has come back from a machine. Write it, then look * for what that unblocks. @@ -599,8 +763,10 @@ export async function advance(runId: string): Promise<{ * about the model at all. * * `wroteWith` is the machine's name, from the job's worker rather than from - * anything in the request body. It is the only record of whose subscription - * paid for this section. + * anything in the request body — or the literal `server:api` when the + * workspace's API-key fallback wrote it here instead. It is the only record of + * whose subscription paid for this section, and of which sections were paid + * for in money. * * Returns the run as it now stands, and has already called `advance` — so the * next wave is on the queue by the time this resolves. @@ -610,7 +776,11 @@ export async function sectionResult( job: Pick, /** What the machine posted: `{ text, ms, using }`. */ result: Record, - /** The machine's name, from the authenticated worker — never from the body. */ + /** + * The machine's name, from the authenticated worker — never from the body — + * or `server:api` from the fallback in `advance`, which is the only other + * thing allowed to call this. + */ workerName: string, ): Promise { if (job.kind !== "write_section" || !job.runId || !job.sectionId) return null; diff --git a/lib/use-run-document.ts b/lib/use-run-document.ts index 3efa815..5d3dbaf 100644 --- a/lib/use-run-document.ts +++ b/lib/use-run-document.ts @@ -1,12 +1,52 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useStore } from "@/lib/store"; import { latestRunFor, runToDoc, topicSlug } from "@/lib/run-doc"; import { getPack, type ApiPack } from "@/lib/packs-client"; import { getSource, type Source } from "@/lib/sources-client"; import { useRunWatch } from "@/lib/use-run-watch"; +import type { AdvanceReport } from "@/lib/runs-client"; + +/** + * Where one section's work actually is. + * + * Since the writing moved off the API process, "queued" covers four situations + * a person would act on completely differently: nobody has asked for it yet; + * it is on the queue and a machine is about to take it; it is on the queue + * behind a machine that is switched off; or it can never run because nothing + * on this estate has the tool. The row said QUEUED for all four. + * + * The two that matter most are the last two, and they are opposite answers: + * "whisper is not installed on that machine" is an install command, and "that + * machine has not been seen since Tuesday" is somebody opening their laptop. + */ +export type Placement = + | { kind: "done" } + /** Nothing has been asked for. The answer is a button. */ + | { kind: "idle" } + /** Waiting on a section that has to be written first. */ + | { kind: "blocked"; on: string[] } + /** On the queue, with machines awake to take it. */ + | { kind: "queued" } + /** On the queue behind a machine that has not been seen recently. */ + | { kind: "waiting"; machine: string } + /** A machine is holding it. `machine` is "" — see the note below. */ + | { kind: "writing"; machine: string } + /** No machine anywhere advertises the tool. `message` says which, and how. */ + | { kind: "unroutable"; message: string } + | { kind: "failed"; message: string }; + +/** + * The sentence `advance` writes for a section nothing can run. + * + * Matched on rather than carried as a flag because the section row has no + * field for it: the job knows it is `unroutable`, and `run_sections` only has + * `failed` plus this text. `jobsForRun` holds the distinction properly, and + * nothing exposes it over HTTP — see the note in DocumentView. + */ +const NO_MACHINE = /^No machine here has /; /** * The run a document page is about, resolved from either kind of address. @@ -48,8 +88,40 @@ export function useRunDocument({ return mine ? (latestRunFor(runs, mine.id)?.id ?? null) : null; }, [runId, seriesList, slug, runs]); - const { fetched, setFetched, serverWriting, setServerWriting } = - useRunWatch(watchId); + const { + fetched, + setFetched, + serverWriting, + setServerWriting, + waitingForMachine, + } = useRunWatch(watchId); + + /* + * The last thing the server said about routing, kept. + * + * `POST /runs/:id/start` answers with which sections were queued, which are + * waiting on a named machine, and which nothing can run. That is the ONLY + * place a machine's name reaches the browser — the run row records + * `wroteWith` when a section finishes and nothing at all while it is being + * written — so the answer is held here until the sections it describes move + * on, rather than being read once and dropped. + */ + /* + * Kept WITH the run it describes, rather than cleared when that changes. + * + * A routing answer belongs to the run it was asked about — carrying one + * across a navigation would name a machine at the wrong document — but + * clearing it in an effect keyed on `watchId` means one render where the old + * report is still on screen under the new run's sections. Storing the id + * alongside makes the answer simply not match, which is true from the first + * render rather than the second. + */ + const [held, setHeld] = useState<{ runId: string; report: AdvanceReport } | null>(null); + const noteAdvance = useCallback( + (r: AdvanceReport | null) => setHeld(r && watchId ? { runId: watchId, report: r } : null), + [watchId], + ); + const report = held?.runId === watchId ? held.report : null; const run = useMemo(() => { /* @@ -157,6 +229,76 @@ export function useRunDocument({ ? run.sections.filter((x) => x.state !== "done").length : 0; + /* + * Where each section's work is, as one lookup the page can read per row. + * + * Built from three sources because no single one has the whole picture: the + * run's own rows (state and error), whether the queue holds anything for + * this run at all, and the routing report from the last start. A section is + * `writing` on the row from the moment its job exists, so "a machine is + * holding it" is really "a job exists and something is moving" — the run + * having live jobs with NO section writing is the tell for a queue nothing + * has claimed. + */ + const placements = useMemo(() => { + const out = new Map(); + if (!run) return out; + + const done = new Set( + run.sections.filter((x) => x.state === "done").map((x) => x.id), + ); + const ready = new Set(pending); + const asleep = new Map( + (report?.waiting ?? []).map((x) => [x.sectionId, x.machine]), + ); + const titleOf = (id: string) => + run.sections.find((x) => x.id === id)?.title ?? id; + + for (const row of run.sections) { + if (row.state === "done") { + out.set(row.id, { kind: "done" }); + continue; + } + if (row.state === "failed") { + out.set( + row.id, + NO_MACHINE.test(row.error) + ? { kind: "unroutable", message: row.error } + : { kind: "failed", message: row.error || "It did not say why." }, + ); + continue; + } + if (row.state === "writing") { + // No name available: the machine is on the job, and the job is not + // exposed. `wroteWith` fills this in once the text lands. + out.set(row.id, { kind: "writing", machine: "" }); + continue; + } + + // Queued, which is the word that hid four different situations. + const deps = runtime?.sections.find((d) => d.id === row.id)?.dependsOn ?? []; + const missing = deps.filter((d) => !done.has(d)); + if (missing.length) { + out.set(row.id, { kind: "blocked", on: missing.map(titleOf) }); + continue; + } + const machine = asleep.get(row.id); + if (machine) { + out.set(row.id, { kind: "waiting", machine }); + continue; + } + if (serverWriting && ready.has(row.id)) { + out.set( + row.id, + waitingForMachine ? { kind: "waiting", machine: "" } : { kind: "queued" }, + ); + continue; + } + out.set(row.id, { kind: "idle" }); + } + return out; + }, [run, runtime, pending, report, serverWriting, waitingForMachine]); + return { run, runtime, @@ -169,5 +311,8 @@ export function useRunDocument({ setFetched, serverWriting, setServerWriting, + waitingForMachine, + placements, + noteAdvance, }; } diff --git a/lib/use-run-watch.ts b/lib/use-run-watch.ts index b281d28..eb69222 100644 --- a/lib/use-run-watch.ts +++ b/lib/use-run-watch.ts @@ -1,9 +1,61 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { getRun, isWriting, type Run } from "@/lib/runs-client"; +/** + * How often to ask, for each of the three things a run can be doing. + * + * The old loop knew two speeds — 1.5s while "running", 6s otherwise — because + * running meant a loop inside the API process was writing a section this + * second. It does not any more. A run whose sections are jobs is *running* the + * whole time it sits on the queue waiting for somebody to open their laptop, + * which can be until tomorrow, and asking twice a second about that is a hot + * spin against the API for a fact that will not change for hours. + * + * So the speed follows what is actually moving: + * + * - `writing` — a machine is holding a section right now, and the text can + * land at any moment. Worth a second and a half. + * - `waiting` — jobs exist, nothing has claimed them. Something WILL happen, + * just not this second: five seconds, stretched to fifteen once it has been + * like that for a couple of minutes, because "queued behind a laptop that is + * shut" is a state measured in hours. + * - `settled` — nothing on the queue. Eight seconds, stretched to thirty when + * nothing has changed for a while, so a page left open overnight costs a + * couple of requests a minute rather than a thousand. + */ +const PACE = { + writing: 1500, + waiting: 5000, + waitingSlow: 15000, + settled: 8000, + settledSlow: 30000, +} as const; + +/** How long a state has to hold before the slow cadence takes over. */ +const PATIENCE_MS = 120_000; + +/** Backoff for a failing poll, and its ceiling. */ +const RETRY_FLOOR = 4000; +const RETRY_CEILING = 30_000; + +export type RunWatch = { + fetched: Run | null; + setFetched: (run: Run | null) => void; + /** Anything on the queue for this run, on any machine. */ + serverWriting: boolean; + setServerWriting: (running: boolean) => void; + /** + * Jobs exist but no section says `writing`: the work is queued and no + * machine has picked it up. The page says something different for this than + * for "a model is typing", because the answer is usually to switch a + * computer on. + */ + waitingForMachine: boolean; +}; + /** * The server's copy of one run, kept current while it moves. * @@ -14,64 +66,145 @@ import { getRun, isWriting, type Run } from "@/lib/runs-client"; * it returns over any list copy of the same run. * * Two jobs, because they are the same fact: fetch the run the URL names (it may - * be seconds old and not in any list yet), then follow it — fast while - * something is being written, slowly once it settles, so a page left open - * overnight is not asking twice a second. + * be seconds old and not in any list yet), then follow it — at whichever of the + * three speeds above matches what the run is actually doing. + * + * It never gives up. A run can legitimately sit queued for a day waiting for a + * machine, and it can resume on a different machine than the one it started + * on, so there is no point at which "nothing has happened lately" means + * "nothing will". Failures back off and keep asking; they do not stop, and + * they do not throw away the copy already on screen. */ -export function useRunWatch(runId: string | null | undefined) { +export function useRunWatch(runId: string | null | undefined): RunWatch { const [fetched, setFetched] = useState(null); const [serverWriting, setServerWriting] = useState(false); + const [waitingForMachine, setWaitingForMachine] = useState(false); - useEffect(() => { - if (!runId) return; - let cancelled = false; - void getRun(runId) - .then((r) => { - if (!cancelled) setFetched(r); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [runId]); + /* + * What the loop needs to know, in refs rather than in its dependencies. + * + * The interval is decided from the last answer, and putting that answer in + * the effect's deps would tear the timer down and rebuild it on every poll — + * which is how a "slow" poll quietly becomes an immediate one. + */ + const since = useRef({ pace: "" as string, at: 0 }); + const failures = useRef(0); useEffect(() => { + // Nothing to follow, and deliberately nothing set: writing state from an + // effect makes React render twice to reach the same screen. What a caller + // sees with no run is derived at the bottom of this hook instead. if (!runId) return; + let stop = false; + let timer: ReturnType | undefined; + since.current = { pace: "", at: Date.now() }; + failures.current = 0; - const tick = async () => { + /** One poll. Returns how long to wait before the next one. */ + const tick = async (): Promise => { + let answer: [{ running: boolean }, Run | null]; try { - const [state, fresh] = await Promise.all([ - isWriting(runId), - getRun(runId), - ]); - if (stop) return; - setServerWriting(state.running); - if (fresh) setFetched(fresh); - return state.running ? 1500 : 6000; + answer = await Promise.all([isWriting(runId), getRun(runId)]); } catch { - return 6000; + /* + * A failed poll is not an answer about the run. + * + * The API restarting, a laptop's wifi blinking, a 502 from a proxy — + * none of them mean the run stopped, and the old loop's flat six-second + * retry hammered a dead API for as long as the page stayed open. Back + * off, keep the last good copy on screen, and keep asking. + */ + failures.current += 1; + return Math.min(RETRY_FLOOR * 2 ** (failures.current - 1), RETRY_CEILING); + } + failures.current = 0; + if (stop) return PACE.settled; + const [{ running }, fresh] = answer; + + const writing = Boolean(fresh?.sections.some((s) => s.state === "writing")); + setServerWriting(running); + setWaitingForMachine(running && !writing); + if (fresh) setFetched(fresh); + + /* + * How long this has been true, so a long wait can slow down. + * + * Keyed on the pace rather than on the run's `updatedAt`: a run whose + * sections are being written changes constantly and must stay fast, and + * a run that is merely queued does not change at all — which is exactly + * the case the timer is being stretched for. + */ + const pace = writing ? "writing" : running ? "waiting" : "settled"; + if (pace !== since.current.pace) since.current = { pace, at: Date.now() }; + const held = Date.now() - since.current.at; + + if (pace === "writing") return PACE.writing; + if (pace === "waiting") { + return held > PATIENCE_MS ? PACE.waitingSlow : PACE.waiting; } + return held > PATIENCE_MS ? PACE.settledSlow : PACE.settled; }; - let timer: ReturnType; const loop = async () => { - const wait = (await tick()) ?? 6000; + const wait = await tick(); if (!stop) timer = setTimeout(() => void loop(), wait); }; + + /* + * A hidden tab asks nothing, and asks again the moment it is looked at. + * + * Browsers already clamp timers in a background tab, but not to zero, and + * "someone left fifteen run pages open" is a real shape of this app's use. + * Coming back has to be instant, though: a person switching to this tab to + * see whether it finished must not read a thirty-second-old page. + */ + const wake = () => { + if (stop || document.visibilityState !== "visible") return; + clearTimeout(timer); + void loop(); + }; + document.addEventListener("visibilitychange", wake); + void loop(); return () => { stop = true; clearTimeout(timer); + document.removeEventListener("visibilitychange", wake); }; }, [runId]); /** - * `setFetched` is handed back on purpose: writing a section returns the whole - * run, and the page that asked has the answer a poll would spend a second and - * a half getting to. `setServerWriting` likewise, so pressing "Write the - * rest" shows as busy before the first poll confirms it. + * `setFetched` is handed back on purpose: queueing a section returns the + * whole run, and the page that asked has the answer a poll would spend a + * second and a half getting to. `setServerWriting` likewise, so pressing + * "Write the rest" — or Stop — shows before the first poll confirms it. */ - return { fetched, setFetched, serverWriting, setServerWriting }; + /* + * With no run there is nothing true to say about one. + * + * Derived rather than cleared in the effect: a stale `fetched` belonging to + * the run we just navigated away from must not be handed back for one render + * while an effect catches up, and clearing it in an effect is exactly that + * render. The state itself is left alone, so coming back to the same run + * still has its last copy. + */ + if (!runId) { + return { + fetched: null, + setFetched, + serverWriting: false, + setServerWriting, + waitingForMachine: false, + }; + } + + return { + fetched, + setFetched, + serverWriting, + setServerWriting, + waitingForMachine, + }; } diff --git a/tests/queue.test.ts b/tests/queue.test.ts index 4ffdcd1..1f814d4 100644 --- a/tests/queue.test.ts +++ b/tests/queue.test.ts @@ -95,8 +95,12 @@ describe.skipIf(!up)("the queue", () => { it("applies every migration, and the worker split's columns land", async () => { const { q } = await dbMod(); + const { MIGRATIONS } = await import("@/lib/server/db/schema"); const names = await q<{ name: string }>("SELECT name FROM migrations ORDER BY name"); - expect(names.at(-1)!.name).toBe("0015_workers"); + // Compared against the list itself rather than a name written here: a + // hardcoded "the last one is 0015" fails the day somebody appends 0016, + // which is a passing test breaking on correct work. + expect(names.map((r) => r.name)).toEqual(MIGRATIONS.map((m) => m.name).sort()); const cols = await q<{ t: string; c: string }>( `SELECT table_name AS t, column_name AS c FROM information_schema.columns @@ -114,6 +118,7 @@ describe.skipIf(!up)("the queue", () => { "run_sections.wrote_with", "workspaces.api_fallback", "runs.created_by", + "workspaces.brain", ]) { expect(have.has(want), `migration did not create ${want}`).toBe(true); } From ce5730d3f53b03d48bdc69533bf87385f7d29023 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 20:30:17 +0600 Subject: [PATCH 09/30] Build the worker, and let the page name the machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- api/src/runs/runs.controller.ts | 86 ++ components/views/DocumentView.tsx | 70 +- components/views/IntegrationsView.tsx | 1067 +++++++++++++++---------- components/views/SettingsView.tsx | 42 +- docs/WORKER.md | 59 +- lib/integrations-client.ts | 409 ++++++++-- lib/runs-client.ts | 68 +- lib/server/repos/workers.ts | 41 +- lib/server/services/runs.ts | 18 + lib/use-run-document.ts | 156 ++-- lib/use-run-watch.ts | 72 +- package.json | 1 + tests/queue.test.ts | 41 + worker/api.ts | 225 ++++++ worker/brains.ts | 466 +++++++++++ worker/config.ts | 127 +++ worker/executor.ts | 100 +++ worker/index.ts | 410 ++++++++++ worker/ingest-source.ts | 393 +++++++++ worker/job-types.ts | 88 ++ worker/register.mjs | 9 + worker/resolve-ts.mjs | 28 + worker/temp.ts | 50 ++ worker/test-brain.ts | 51 ++ worker/transcribe-audio.ts | 130 +++ worker/write-section.ts | 114 +++ 26 files changed, 3717 insertions(+), 604 deletions(-) create mode 100644 worker/api.ts create mode 100644 worker/brains.ts create mode 100644 worker/config.ts create mode 100644 worker/executor.ts create mode 100644 worker/index.ts create mode 100644 worker/ingest-source.ts create mode 100644 worker/job-types.ts create mode 100644 worker/register.mjs create mode 100644 worker/resolve-ts.mjs create mode 100644 worker/temp.ts create mode 100644 worker/test-brain.ts create mode 100644 worker/transcribe-audio.ts create mode 100644 worker/write-section.ts diff --git a/api/src/runs/runs.controller.ts b/api/src/runs/runs.controller.ts index 11814c8..56d69d7 100644 --- a/api/src/runs/runs.controller.ts +++ b/api/src/runs/runs.controller.ts @@ -18,6 +18,7 @@ import { requeueFailed, setRunDecision, } from "@/lib/server/repos/runs"; +import { jobsForRun, type JobWithWorker } from "@/lib/server/repos/jobs"; import { advance, editSection, @@ -30,6 +31,45 @@ import { import type { User } from "@/lib/server/auth"; import { CurrentUser } from "../common/user.decorator"; +/** + * What a job looks like from the browser: an allowlist, not a row. + * + * A `Pick` of the repo's own type rather than a fresh interface, for the same + * reason lib/runs-client.ts imports `Run` instead of redeclaring it — two ends + * of a JSON boundary that describe a field separately are two things that can + * disagree, and the disagreement shows up as a blank chip rather than an + * error. The client picks the same keys off the same type, so renaming one in + * repos/jobs.ts breaks both compiles instead of quietly emptying the page. + * + * What is deliberately NOT here: + * + * - `payload`. A `write_section` job carries six to eight thousand characters + * of system prompt. The browser has no use for a single one of them, and a + * twelve-section run polled every second and a half would ship more prompt + * per minute than the run has content in it. + * - `result`. Its `text` IS the section, and `run_sections` already holds the + * copy every screen reads; sending it again doubles the page for nothing. + * - the ids of machines and workspaces. The page names a machine, it never + * addresses one. + * + * An allowlist rather than an omit-these because a column added to `jobs` next + * month must not start reaching browsers because nobody remembered this route. + */ +export type RunJob = Pick< + JobWithWorker, + | "id" + | "kind" + | "state" + | "sectionId" + | "workerName" + | "workerLive" + | "error" + | "attempts" + | "maxAttempts" + | "waitUntil" + | "createdAt" +>; + /** * Runs, and the sections written into them. * @@ -141,6 +181,52 @@ export class RunsController { return { running: await isRunning(id) }; } + /** + * The queue's own record of this run, one entry per job, with the machine. + * + * Three things the page had to fake because none of this was reachable: + * + * - "no machine here has the tool" was a regex against the section's error + * text, matching a sentence `advance()` writes. Rewording that sentence + * silently downgraded a NO MACHINE chip to a plain FAILED one and nothing + * failed anywhere. `state` says `unroutable` here, as a state. + * - the name of a machine that is asleep survived only in the answer to + * `POST :id/start`, so it appeared only in the tab that pressed the + * button, never for a colleague and never for a single-section write. + * - "being written on Shakhawat's desktop" could not be said at all: + * `run_sections.wrote_with` is set on success, so while a section is in + * flight there is no name anywhere the browser can see. `worker_id` is + * joined to a name here from the moment the job is claimed. + * + * Session-authenticated, like every other route on this controller — the + * global `SessionGuard` covers it. It is NOT a worker route: a worker talks + * to /api/workers with its own credential, and deliberately cannot read the + * content library. Nothing here could reconstruct that credential either; + * the token is a sha256 in `workers` and never leaves the database at all. + * + * This is also what `GET :id/start` above answers, in full rather than + * reduced to a boolean: `isRunning` IS `jobsForRun(...).some(live)`. The + * page's poll therefore swaps one call for the other rather than adding a + * second timer to the tick. + */ + @Get(":id/jobs") + async jobs(@Param("id") id: string): Promise { + if (!(await getRun(id))) throw new NotFoundException("No such run"); + return (await jobsForRun(id)).map((j) => ({ + id: j.id, + kind: j.kind, + state: j.state, + sectionId: j.sectionId, + workerName: j.workerName, + workerLive: j.workerLive, + error: j.error, + attempts: j.attempts, + maxAttempts: j.maxAttempts, + waitUntil: j.waitUntil, + createdAt: j.createdAt, + })); + } + /** * Stop a run that is writing. * diff --git a/components/views/DocumentView.tsx b/components/views/DocumentView.tsx index e8db5cd..ba88d1c 100644 --- a/components/views/DocumentView.tsx +++ b/components/views/DocumentView.tsx @@ -258,10 +258,26 @@ function placementNote(at: Placement | undefined): string { switch (at.kind) { case "blocked": return `Needs ${at.on.join(" and ")} first.`; + case "writing": + /* + * Whose machine is typing it. The page could not say this at all until + * the queue became readable: `run_sections` records `wroteWith` on + * success, so mid-flight there was no name anywhere, and a run spread + * across four laptops looked exactly like a run on one. + */ + return at.machine ? `Being written on ${at.machine}.` : ""; case "waiting": - return at.machine - ? `Waiting for ${at.machine}, which has not been seen recently — opening it starts this.` - : "On the queue. No machine has picked it up yet."; + /* + * The server's own sentence first, because it names every machine the + * job could go to and the browser has no list of machines to rebuild + * that from. The fallbacks cover a job pinned to one machine — a name + * and no sentence — and a section the poll has not caught up with yet. + */ + return at.message + ? `${at.message} Opening it starts this.` + : at.machine + ? `Waiting for ${at.machine}, which has not been seen recently — opening it starts this.` + : "On the queue. No machine has picked it up yet."; case "unroutable": case "failed": return at.message; @@ -298,7 +314,6 @@ export function DocumentView({ setServerWriting, waitingForMachine, placements, - noteAdvance, } = useRunDocument({ slug, runId }); const [openSections, setOpenSections] = useState>( @@ -375,6 +390,10 @@ export function DocumentView({ if (writing || queuing.size) { const now = doc?.sections.find((x) => x.id === writing?.row.id); const next = doc?.sections.find((x) => x.state === "queued"); + // Which machine, once one has claimed it. This said "a worker machine" + // to everybody, always, because nothing named one until the section had + // already landed and `wroteWith` was written. + const on = writing?.at?.kind === "writing" ? writing.at.machine : ""; return { tone: "busy" as const, pulse: true, @@ -383,7 +402,9 @@ export function DocumentView({ : next ? `Starting ${next.n} · ${next.name}…` : "Writing…", - note: "On a worker machine — you can close this tab", + note: on + ? `On ${on} — you can close this tab` + : "On a worker machine — you can close this tab", }; } @@ -396,17 +417,17 @@ export function DocumentView({ * server told us which turns it into an errand somebody can run. */ const asleep = placed.find( - (x) => x.at?.kind === "waiting" && x.at.machine, + (x) => x.at?.kind === "waiting" && (x.at.machine || x.at.message), )?.at; if (waitingForMachine || asleep) { - const machine = asleep?.kind === "waiting" ? asleep.machine : ""; return { tone: "idle" as const, pulse: false, text: "Queued — waiting for a machine", - note: machine - ? `${machine} has not been seen recently. Open it and this starts.` - : "No machine has picked this up yet.", + // The row's own sentence rather than a second wording of it: two + // copies of one fact, written in two places, drift apart — and this + // one already names the machines and says what opening one does. + note: placementNote(asleep) || "No machine has picked this up yet.", }; } @@ -525,25 +546,26 @@ export function DocumentView({ ); /** - * Write the lot, and keep what the server said about routing. + * Write the lot. * - * The answer carries the only machine names the browser ever sees: which - * sections went on the queue, which are queued behind a machine that is - * asleep and what it is called, and which nothing on the estate can run. - * Dropping it — as a bare `await` would — throws away the difference between - * "open your laptop" and "install whisper". + * The answer used to be held onto: its `waiting` list carried the only + * machine names the browser ever saw, so it was kept in a hook and drawn + * under sections it may already have stopped describing — and only ever in + * the tab that pressed this button, never for a colleague opening the same + * run and never for a single-section write. `GET /runs/:id/jobs` says all of + * that for every section, to everybody, however the run was started, so this + * is back to what it looks like: start it, and let the watcher follow. */ const writeEverything = useCallback(async () => { if (!run) return; setServerWriting(true); try { const answer = await startWriting(run.id); - noteAdvance(answer); setServerWriting(answer.running); } catch { setServerWriting(false); } - }, [run, setServerWriting, noteAdvance]); + }, [run, setServerWriting]); /** * Stop a run that is writing. @@ -558,7 +580,6 @@ export function DocumentView({ setStopping(true); try { await stopRun(run.id); - noteAdvance(null); setServerWriting(false); await reloadRuns(); } catch { @@ -566,7 +587,7 @@ export function DocumentView({ } finally { setStopping(false); } - }, [run, stopping, setServerWriting, reloadRuns, noteAdvance]); + }, [run, stopping, setServerWriting, reloadRuns]); /* * Auto-approve: carry on without being asked. @@ -705,6 +726,14 @@ export function DocumentView({ /** The one being written this second, if any, and how many gave up. */ const nowWriting = doc.sections.find((s) => stateOf(s) === "writing") ?? null; + /* + * And on which machine, once one has claimed it. Empty for the second + * between the row being marked and a worker's long poll taking it, and for + * a section the server is writing itself on the workspace's API key. + */ + const nowWritingAt = nowWriting ? placeOf(nowWriting) : undefined; + const nowWritingOn = + nowWritingAt?.kind === "writing" ? nowWritingAt.machine : ""; const failedCount = doc.sections.filter((s) => stateOf(s) === "failed").length; const rollUp = (sections: DocSection[]): SectionState => { @@ -1137,6 +1166,7 @@ export function DocumentView({ {nowWriting ? ( writing {nowWriting.n} · {nowWriting.name} + {nowWritingOn ? ` on ${nowWritingOn}` : ""} ) : queuing.size ? ( diff --git a/components/views/IntegrationsView.tsx b/components/views/IntegrationsView.tsx index 3f7bdf8..f831c75 100644 --- a/components/views/IntegrationsView.tsx +++ b/components/views/IntegrationsView.tsx @@ -1,41 +1,69 @@ "use client"; -import { useState, type ReactNode } from "react"; +import { Fragment, useState, type ReactNode } from "react"; +import { ago } from "@/lib/packs-client"; import { - patchWiring, - testBrain, + patchMachineTools, + patchWorkspaceWiring, + useBrainTests, useWiringState, type BrainStatus, - type BrainTest, + type ChosenMachine, + type TestView, type ToolStatus, } from "@/lib/integrations-client"; import { useStore } from "@/lib/store"; -import { font, panel, rise, t, w } from "@/lib/theme"; -import { Button, Toggle } from "@/components/ui"; +import { color, font, panel, t, w } from "@/lib/theme"; +import { + Button, + Chip, + EmptyState, + ListCell, + ListHeader, + ListPanel, + ListRow, + Page, + PageHeader, + RowTile, + Select, + Toggle, + Toolbar, +} from "@/components/ui"; import { BrainGlyph, - CheckGlyph, - CloseGlyph, CpuGlyph, + KeyGlyph, WrenchGlyph, } from "@/components/ui/DocIcons"; +import { RefreshIcon } from "@/components/ui/Icons"; /** - * What Content OS can reach, checked rather than claimed. + * What can run where. + * + * This page used to ask "what is on this machine", and answered by spawning + * `claude --version` and `yt-dlp --version` inside the API process. That was + * the right question exactly as long as the API process and the machine with + * the binaries on it were the same computer. They are not: work happens on + * enrolled desktops now, and on a server the old question has one permanent + * answer — nothing installed — while the four laptops that really do have the + * tools stay invisible. * - * Every row here is the result of running the thing: `claude --version`, - * `yt-dlp --version`, a request to localhost:11434. A tool that is not - * installed says so and shows the one command that installs it, because the - * previous version of this page displayed a version number for software that - * might never have been on the machine. + * So it asks "what is on WHICH machine" instead, and the whole page is scoped + * by two pickers it did not have: the machine at the top, and the workspace + * the console is already switched to. Those are the two things migration 0016 + * separated — a fact about somebody's hardware and an editorial choice about a + * brand — and one flat settings object could not tell them apart. + * + * Settings → Machines enrols them and owns their permissions; this page + * answers a different question with the same vocabulary, deliberately, so the + * two read as one console rather than as two lists of the same computers. */ -const GOOD = "#4bb07a"; -const WARN = "#e0a83c"; -const BAD = "#d1656b"; +const MODELS = "1.5fr 1.25fr 168px"; +const TOOLS = "1.4fr 1fr 118px 62px"; -/** What each local tool is actually for, in this app's own terms. */ +/** What each tool is actually for, in this app's own terms. */ const TOOL_ROLE: Record = { "yt-dlp": "Pulls a source reel down so a template can be told what it shows", ffmpeg: "Cuts stills out of that video for the identify step to read", @@ -45,270 +73,389 @@ const TOOL_ROLE: Record = { }; export function IntegrationsView() { - const { go } = useStore(); - const { wiring, setWiring, loading, checking, error, reload } = useWiringState(); - const [saving, setSaving] = useState(""); - const [refused, setRefused] = useState(""); + const { go, project } = useStore(); /* - * Test results, per model, kept until the page is left. + * Empty means "whichever machine the server thinks is the live one". * - * A version number proves a command exists and nothing more: codex can be - * installed AND logged in and still fail because the CLI is a release - * behind. Only sending a prompt settles it, so the page offers to. + * Not defaulted to the first row here, because the server already answers + * that better than a browser can: it picks the most recently seen live + * machine, which is the honest answer to "where can this run right now". */ - const [tests, setTests] = useState>({}); - - async function test(id: string) { - setTests((prev) => ({ ...prev, [id]: "running" })); - try { - const result = await testBrain(id); - setTests((prev) => ({ ...prev, [id]: result })); - } catch (e) { - setTests((prev) => ({ - ...prev, - [id]: { - ok: false, - ms: 0, - reply: "", - error: e instanceof Error ? e.message : "The test could not run", - fix: "", - }, - })); - } - } + const [machineId, setMachineId] = useState(""); + const workspaceId = project?.id ?? ""; + const { wiring, setWiring, loading, checking, error, reload } = useWiringState( + machineId, + workspaceId, + ); + const { tests, test } = useBrainTests(); + const [saving, setSaving] = useState(""); + const [refused, setRefused] = useState(""); + const machines = wiring?.machines ?? []; + const machine = wiring?.machine ?? null; const brains = wiring?.brains ?? []; const tools = wiring?.tools ?? []; - const settings = wiring?.settings; - - /** Missing pieces, counted for the banner at the top. */ - const missingTools = tools.filter((x) => !x.present); - const readyBrains = brains.filter((b) => b.available); - const brain = brains.find((b) => b.id === settings?.brain); + const enabled = wiring?.enabled ?? []; + const ready = brains.filter((b) => b.available); + const installed = tools.filter((x) => x.present); + const chosenBrain = brains.find((b) => b.id === wiring?.brain); + const noMachines = !loading && machines.length === 0; async function chooseBrain(id: string) { - if (!wiring) return; + if (!wiring || !workspaceId) return; setSaving(id); setRefused(""); try { - const next = await patchWiring({ brain: id }); - setWiring({ ...wiring, settings: next }); + const next = await patchWorkspaceWiring(workspaceId, { brain: id }); + setWiring({ ...wiring, brain: next.brain, apiFallback: next.apiFallback }); } catch (e) { - setRefused(e instanceof Error ? e.message : "That model could not be set"); + setRefused( + e instanceof Error ? e.message : "That model could not be set for this workspace", + ); } finally { setSaving(""); } } + async function toggleFallback() { + if (!wiring || !workspaceId) return; + const apiFallback = !wiring.apiFallback; + setWiring({ ...wiring, apiFallback }); + setRefused(""); + await patchWorkspaceWiring(workspaceId, { apiFallback }).catch((e: unknown) => { + setWiring({ ...wiring, apiFallback: !apiFallback }); + setRefused(e instanceof Error ? e.message : "That switch did not save"); + }); + } + async function toggleTool(id: string) { - if (!wiring || !settings) return; - const enabled = settings.enabled.includes(id) - ? settings.enabled.filter((x) => x !== id) - : [...settings.enabled, id]; - setWiring({ ...wiring, settings: { ...settings, enabled } }); - await patchWiring({ enabled }).catch(() => {}); + if (!wiring || !machine) return; + const next = enabled.includes(id) + ? enabled.filter((x) => x !== id) + : [...enabled, id]; + setWiring({ ...wiring, enabled: next }); + await patchMachineTools(machine.id, next).catch((e: unknown) => { + setWiring({ ...wiring, enabled }); + setRefused(e instanceof Error ? e.message : "That switch did not save"); + }); } return ( -
-
-
-

- Integrations -

-

- {loading - ? "Checking this machine…" - : `${readyBrains.length} of ${brains.length} models usable · ${ - tools.filter((x) => x.present).length - } of ${tools.length} tools installed`} -

-
+ + + {/* + The two places the things on this page are actually owned: a + machine's permissions belong to whoever owns the machine, and the + keys belong to the server. Offered once here rather than as a + button in every row that lacks one. + */} + + + + } + /> + + + {machines.length ? ( + <> + +

Where your team's Content OS is. Ask whoever set up the server.

+ + + +

+ On the console: Settings → Machines → add this machine. The token is + shown once. It is kept in Windows Credential Manager, not in a file. +

+ +
+ + +
+ +
+

+
+ + + + diff --git a/worker/index.ts b/worker/index.ts index 5034df5..d2b5e42 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -376,11 +376,22 @@ function killTree(child: ChildProcess) { } } -const sleep = (ms: number) => - new Promise((resolve) => { - const timer = setTimeout(resolve, ms); - timer.unref?.(); - }); +/** + * A wait that keeps the process alive while it waits. + * + * This timer used to be `unref`ed, and that is the difference between a worker + * that retries and one that quietly gives up. Before the first successful + * registration nothing else is holding Node's event loop open — the heartbeat + * and re-register intervals do not exist yet — so an unref'd timer left + * NOTHING ref'd, and the process printed "Trying again in 10s" and exited 0 on + * the spot. The one case it broke is the ordinary one: a laptop opened before + * its wifi is up. + * + * It stays ref'd on purpose. Shutdown is not this function's job: the signal + * handlers stop the loop and clear the intervals, which is what actually ends + * the process. + */ +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const say = (e: unknown) => (e instanceof Error ? e.message : String(e)); From 8435fadcda56f673d9bc3c68bd7ae85c119fa122 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 22:26:58 +0600 Subject: [PATCH 13/30] Add the Tauri CLI, which is what produces an installer `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) --- package-lock.json | 218 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 219 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4735359..1802875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "react-dom": "19.2.8" }, "devDependencies": { + "@tauri-apps/cli": "^2.11.4", "@types/node": "^24.13.3", "@types/pg": "^8.23.1", "@types/react": "^19", @@ -1609,6 +1610,223 @@ "tslib": "^2.8.0" } }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/package.json b/package.json index 801ca66..ddd8080 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react-dom": "19.2.8" }, "devDependencies": { + "@tauri-apps/cli": "^2.11.4", "@types/node": "^24.13.3", "@types/pg": "^8.23.1", "@types/react": "^19", From 519a3d0a5329e0811e2689b59139473de04d9e11 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 22:58:22 +0600 Subject: [PATCH 14/30] Ship Node inside the app, and produce something to double-click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .gitignore | 5 + docs/WORKER.md | 246 +++++++++++++---- package.json | 2 + src-tauri/Cargo.lock | 440 ++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/scripts/fetch-node.mjs | 226 ++++++++++++++++ src-tauri/scripts/sign.ps1 | 85 ++++++ src-tauri/src/main.rs | 10 + src-tauri/src/tray.rs | 55 +++- src-tauri/src/updates.rs | 115 ++++++++ src-tauri/src/worker.rs | 217 ++++++++++++--- src-tauri/tauri.conf.json | 30 ++- 12 files changed, 1327 insertions(+), 105 deletions(-) create mode 100644 src-tauri/scripts/fetch-node.mjs create mode 100644 src-tauri/scripts/sign.ps1 create mode 100644 src-tauri/src/updates.rs diff --git a/.gitignore b/.gitignore index 3a96c90..04ff2e0 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,8 @@ api/dist # is an application, so the exact versions it was built against are part of it. src-tauri/target src-tauri/gen/schemas + +# The Node the desktop app ships. ~90 MB, fetched and checksummed by +# src-tauri/scripts/fetch-node.mjs at build time — a runtime is not a thing to +# keep in a repository. +src-tauri/binaries diff --git a/docs/WORKER.md b/docs/WORKER.md index b463b63..2248c4f 100644 --- a/docs/WORKER.md +++ b/docs/WORKER.md @@ -1038,59 +1038,212 @@ npm run desktop:build # a release binary ``` It needs the Rust MSVC toolchain and the WebView2 runtime, which every Windows -11 machine already has. There is deliberately **no `@tauri-apps/cli` in -`package.json`**: nothing else in this repository needs a Rust toolchain, and -`cargo run` is the whole dev loop. Producing an installer does need it — -`npm i -D @tauri-apps/cli && npx tauri build` — and that is the point at which -adding the dependency will be worth it. +11 machine already has. `cargo run` is the whole dev loop; `@tauri-apps/cli` is +a devDependency and is needed only to produce an installer, which is the next +section. First run has no console address, so it opens the setup window. After that it -opens the console and starts the worker. It finds `worker/` by looking upwards -from its own executable, so a debug build inside the checkout simply works; -`CONTENTOS_WORKER_DIR` names the folder when it is somewhere else. +opens the console and starts the worker. + +**A checkout runs the checkout's worker.** The app looks for `worker/index.ts` +in its own resource directory first — where an installed copy keeps it — and +then upwards from its own executable, which from `src-tauri/target/debug` is +the repository. So a debug build runs the files being edited, with whichever +Node is on PATH, and `npm run worker` in a terminal runs the same command +against the same files. `CONTENTOS_WORKER_DIR` names the folder when it is +somewhere else. The status is in the tray menu and in the setup window, in these words: -*Connected*, *Cannot reach the console*, *Node is not installed*, *Node is too -old*, *This machine's token was refused*, *The worker's files are missing*, -*Not set up yet*, *The worker is not running*. They are separate states because -they are separate actions — one is wifi, one is a download, one is a trip to -Settings → Machines — and a single "error" would hide all three behind the same -shrug. Anything the worker prints that this app does not recognise is still -shown verbatim, under "What the worker is saying". - -**Closing the window does not stop the worker; Quit does.** A machine that -stops taking jobs because somebody closed a browser window would only ever work -while it was being watched. That is only honest because of the tray: what -carries on running is visible, says what it is doing, and can be stopped from -the same menu. Quit kills the worker's whole process tree — `taskkill /T`, the -same reasoning as `killTree` in `worker/index.ts`, because each job is its own -process and the model CLIs are grandchildren. - -**The app restarts a worker that ends on its own**, after ten seconds. That is -not belt-and-braces: `sleep()` in `worker/index.ts` unrefs its timer, so when -the console cannot be reached during the *first* registration there is nothing -ref'd left in Node's event loop and the process exits 0 — having just printed -"Trying again in 10s", which it then does not do. After registration the -heartbeat and re-register intervals hold the loop open and the retry works -properly. Until that is fixed in the worker, this app is what makes a laptop -whose wifi arrives thirty seconds after the login screen start working anyway. +*Connected*, *Cannot reach the console*, *This app's copy of Node is missing*, +*Node is too old*, *This machine's token was refused*, *The worker's files are +missing*, *Not set up yet*, *The worker is not running*. They are separate +states because they are separate actions — one is wifi, one is a trip to +Settings → Machines, one is installing the app again — and a single "error" +would hide all of them behind the same shrug. Anything the worker prints that +this app does not recognise is still shown verbatim, under "What the worker is +saying". + +Two of those changed meaning when Node moved inside the installer. *This app's +copy of Node is missing* used to mean "this computer has no Node", which was a +download from nodejs.org; on an installed copy it can only mean the +installation is damaged, so it says so and asks for a reinstall. From a +checkout it still gives the developer's sentence, because there the fix really +is to install one. *Node is too old* is now reachable only from a checkout at +all — the Node inside the installer is pinned well above the 22.18 floor. + +### Building the installer + +``` +npm run desktop:installer +``` + +Two steps, and it is worth knowing which: `src-tauri/scripts/fetch-node.mjs` +puts the pinned Node in `src-tauri/binaries/`, then `tauri build` bundles it. +The fetch is a separate step because a 90 MB runtime is not a thing to keep in +a repository — `src-tauri/binaries` is in `.gitignore`, and running the script +is how a fresh clone gets one. Repeating it is cheap: a file that is already +there and already matches its digest is reported and left alone. + +``` +src-tauri/target/release/bundle/nsis/Content OS_0.1.0_x64-setup.exe ~25 MB +``` + +**What is inside it**, all of it landing in the install directory beside the +app, and all of it removed by the uninstaller: + +| | | +|---|---| +| `contentos-desktop.exe` | the window, the tray, and the supervisor that runs the worker | +| `node.exe` | Node **24.20.0**, the current LTS, carried as a Tauri `externalBin` | +| `worker/` | every file, byte for byte the ones in this repository | +| `lib/server/tools.ts`, `brain-defs.ts`, `brain-transports.ts` | the only three things `worker/` imports from the rest of the app | +| `package.json` | the repository's, for the version the worker reports and so that Node reads the `.ts` files exactly as `npm run worker` does | + +That list is short because the worker imports nothing from `node_modules` — +every import in `worker/*.ts` is a `node:` builtin, another `worker/` file, or +one of those three, and those three import only `node:` builtins and each +other. It is a claim worth re-checking rather than trusting whenever any of +them changes: + +``` +grep -h "^import" worker/*.ts lib/server/tools.ts lib/server/brain-defs.ts lib/server/brain-transports.ts +``` + +Nothing is installed and nothing is compiled at install time. Node strips the +types itself, which is the whole reason the worker was written without a build +step in the first place. + +**How Node is pinned, and how the bytes are checked.** The version, the URL and +the sha256 are constants in `src-tauri/scripts/fetch-node.mjs`, deliberately +the same convention `worker/setup.ts` uses for yt-dlp, ffmpeg and whisper.cpp: +nothing ever resolves "latest", the plan is printed before anything is fetched, +and the digest being checked is the one in this repository, reviewed with it. +The digests come from `https://nodejs.org/dist/v/SHASUMS256.txt`, +which is the file the Node project signs. Bytes stream into +`node-.exe.part` while their sha256 and their length are computed on +the way past, and only a file matching **both** is renamed into place — a +captive portal answering with a login page and a `200` would otherwise leave a +plausible-looking `node.exe` for the next build to ship. The name matters as +much: `externalBin` resolves `binaries/node` to +`binaries/node-x86_64-pc-windows-msvc.exe`, and a file under any other name +means an installer that builds cleanly and carries no runtime at all. + +Bumping Node is that constant and its digest, and nothing else. Windows x64 and +arm64 are in the catalogue; macOS and Linux are not, for the reason +`worker/setup.ts` gives about ffmpeg — this team runs Windows, and an untested +entry is worse than an absent one. + +**Where an installed app writes.** Its working directory is inside Program +Files, where a normal user account cannot create anything, so the app hands the +worker `CONTENTOS_DATA_DIR` and `CONTENTOS_TOOLS_DIR` under +`%APPDATA%\com.contentos.desktop\`. Without that, `cliHome()` in +`lib/server/tools.ts` — which does a `mkdirSync` — would fail on the first job, +on the machine where nobody is reading a terminal. A checkout is deliberately +left alone: `.data/` beside `worker/` is what `npm run worker` uses and what +`npm run worker:setup` fills, and an app quietly preferring somewhere else +would make a bug seen here impossible to reproduce there. + +### Signing, and what it costs not to + +**The installer this repository produces is unsigned**, and every build says so +once per file: + +``` +sign.ps1: no certificate configured, so Content OS_0.1.0_x64-setup.exe is unsigned. +``` + +What that means for whoever installs it: SmartScreen shows *"Windows protected +your PC"* and hides the Run button behind **More info**. For an internal team +of a handful of people that is a decision rather than a blocker — somebody says +"click More info, then Run anyway" once. For anything wider it is not, because +that is also exactly what malware looks like, and teaching people to click +through the warning is teaching them the wrong reflex. + +Fixing it costs money and identity rather than code: an **OV or EV code signing +certificate** from a certificate authority, issued to a verified legal entity, +roughly $200–$600 a year, and since June 2023 the private key has to live on a +hardware token or in a cloud HSM. An EV certificate skips SmartScreen's +reputation warm-up; an OV one still warns until enough people have installed +it. + +The wiring is here and takes environment variables, so a real certificate plugs +in without editing anything that is committed. +`bundle.windows.signCommand` runs `src-tauri/scripts/sign.ps1` over every +binary and the installer, and that script reads: + +| | | +|---|---| +| `CONTENTOS_SIGN_THUMBPRINT` | the SHA1 thumbprint of a certificate in this user's certificate store. This is the shape a token or HSM certificate takes — the key never leaves it, so there is no file to point at. | +| `CONTENTOS_SIGN_PFX`, `CONTENTOS_SIGN_PFX_PASSWORD` | …or a `.pfx`, for a certificate that is a file. | +| `CONTENTOS_SIGN_TIMESTAMP_URL` | an RFC3161 timestamp server. Defaults to DigiCert's. Not optional in practice: without a timestamp every signature stops verifying the day the certificate expires, including on installers already downloaded. | +| `CONTENTOS_SIGN_TOOL` | `signtool.exe`, when the newest Windows SDK copy is not where the script looks. | + +With none of them set the script leaves the file alone and exits 0, so a build +on a machine with no certificate still produces an installer. With one of them +set and signing failing, the **build fails** — deliberately. An installer that +was not signed must never leave the machine believing it was, because it then +ships looking exactly like the signed one. + +A self-signed certificate is not an option and is not offered here. It signs +nothing anybody's computer trusts, it does not remove the SmartScreen warning, +and calling the result "signed" is worse than being plainly unsigned. + +### Auto-update: wired, and switched off until there is somewhere to look + +`tauri-plugin-updater` is registered, `src-tauri/src/updates.rs` does the check +and the install, and the tray grows a **Check for updates…** item when there is +an endpoint. **None of it has been run end to end**, for the plain reason that +there is nothing to run it against — and the two things missing are +configuration rather than code: + +```json +"plugins": { "updater": { + "endpoints": ["https://…/latest.json"], + "pubkey": "…the public half of the signing key…" +} } +``` + +Neither can be invented in a repository. A key pair would have to have its +private half somewhere, and the only somewhere available to a commit is the +commit — at which point anybody who has cloned this can sign an "update" that +every installed copy downloads and runs. So `endpoints` is empty, and while it +is empty the tray item is not built at all: a button that can only ever answer +"this was built without an update server" teaches people that the menu is +decorative. + +What a person does once there is somewhere to publish to: + +1. `npx tauri signer generate -w ~/.tauri/contentos.key` — and keep the private + half out of this repository. +2. Put the public half in `plugins.updater.pubkey` and the URL of a + `latest.json` in `plugins.updater.endpoints`. +3. Build with `TAURI_SIGNING_PRIVATE_KEY` and + `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` set, and updater artifacts on: + `npx tauri build --config "{\"bundle\":{\"createUpdaterArtifacts\":true}}"`. + That produces the installer plus a `.sig` beside it. +4. Serve a `latest.json` naming the version, the notes, the date, and each + platform's URL and signature. A static file on the console's own server is + enough; nothing here needs a release service. + +What the check does is deliberately small, and worth knowing before it is +switched on. It asks only when somebody clicks, never silently — a machine four +minutes into writing a section should not restart itself because a release +happened. It stops the worker *before* handing over to the installer, because +installing ends this process without going through the exit handler that would +otherwise have killed it, and an orphaned worker would keep claiming jobs and +spending somebody's model subscription while a second one started beside it. +And the answer replaces the menu item's own label, because the window is +usually closed — which is what the tray is for. ### What is still missing -- **Node is not bundled.** The app spawns whatever `node` is on the machine and - says so plainly when there is none, or when it is too old to strip types - (22.18 is the floor; see `worker/resolve-ts.mjs`). Bundling it as a Tauri - sidecar means shipping `node.exe` (~50 MB) plus `worker/` and the `lib/server` - files it imports as bundle resources, pointing the spawn at - `resource_dir()`, and re-pinning the Node version in the same catalogue - `worker/setup.ts` uses for everything else. The worker imports nothing from - `node_modules`, so it is genuinely those files and no install step. - **The tools installer is not wired to a button.** `npm run worker:setup` is still a terminal command, and the audience for this app is exactly the - audience that should not have to run it. -- **No auto-update and no start-at-login.** Both are the difference between a - machine that is available and a machine that is available when somebody - remembers. + audience that should not have to run it. It is also the one thing an + installed copy cannot do for itself yet: the app points the worker at + `%APPDATA%\com.contentos.desktop\tools`, and nothing fills it. +- **No start-at-login.** The difference between a machine that is available and + a machine that is available when somebody remembers. - **`workers.max_concurrency`, the per-machine tool switches and the machine's name are not exposed.** They are environment variables the worker already reads, and they belong in the setup window next to the token. @@ -1099,5 +1252,6 @@ whose wifi arrives thirty seconds after the login screen start working anyway. its lease. A Windows Job Object would close that hole. - **Only Windows has been built and run.** The credential store is wired per platform in `Cargo.toml` (Keychain on macOS, Secret Service on Linux) so that - neither silently falls back to keyring's non-persisting mock, but nothing on - either has been compiled, let alone used. + neither silently falls back to keyring's non-persisting mock, and the Node + catalogue has entries for Windows only, but nothing on either has been + compiled, let alone used. diff --git a/package.json b/package.json index ddd8080..6ac1ac8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "worker:setup": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --import ./worker/register.mjs worker/setup.ts", "desktop": "cargo run --manifest-path src-tauri/Cargo.toml", "desktop:build": "cargo build --release --manifest-path src-tauri/Cargo.toml", + "desktop:node": "node src-tauri/scripts/fetch-node.mjs", + "desktop:installer": "npm run desktop:node && tauri build", "lint": "eslint" }, "dependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1a91d01..3d990c4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atk" version = "0.18.2" @@ -340,6 +349,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-updater", ] [[package]] @@ -579,6 +589,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -780,6 +801,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -805,6 +836,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -1331,6 +1372,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1349,9 +1405,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1634,6 +1692,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1779,6 +1867,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1832,6 +1926,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2074,6 +2174,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2089,6 +2190,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2152,12 +2265,32 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "pango" version = "0.18.3" @@ -2533,15 +2666,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2553,6 +2691,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2568,6 +2720,92 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2583,6 +2821,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -2887,6 +3134,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -2999,6 +3262,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -3062,6 +3331,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3093,7 +3383,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3126,6 +3416,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3149,7 +3450,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3245,6 +3546,55 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3255,7 +3605,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3278,7 +3628,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3345,6 +3695,19 @@ dependencies = [ "toml 1.1.5+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -3463,6 +3826,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3753,6 +4126,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3996,6 +4375,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4181,6 +4569,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4226,6 +4625,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4521,7 +4929,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -4568,6 +4976,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -4665,6 +5083,18 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + [[package]] name = "zlib-rs" version = "0.6.7" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9eea527..65ae579 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tauri-plugin-updater = "2.11.0" # The worker token is a credential, so it goes in the OS credential store # rather than beside the settings file. Windows-first, deliberately: this is diff --git a/src-tauri/scripts/fetch-node.mjs b/src-tauri/scripts/fetch-node.mjs new file mode 100644 index 0000000..6541539 --- /dev/null +++ b/src-tauri/scripts/fetch-node.mjs @@ -0,0 +1,226 @@ +/** + * The Node the installer carries, fetched before the bundle is built. + * + * The app this repository ships is for a teammate who is not a developer. Every + * other requirement follows from that, and so does this one: "install Node + * first" is precisely the instruction the desktop app exists to delete. So the + * runtime travels inside the installer, as a Tauri sidecar, and this is the + * script that puts it where the bundler will find it. + * + * It is deliberately the same shape as `worker/setup.ts`, which fetches yt-dlp, + * ffmpeg and whisper.cpp onto a colleague's laptop — one catalogue, an exact + * version, a URL and a sha256 beside it, printed before anything is fetched, + * checked before anything is used, and staged under a `.part` name so a dropped + * connection cannot leave a half-written `node.exe` where the next build will + * bundle it. Inventing a second, weaker convention next to that one would mean + * two answers to "how do we know this binary is the one we think it is". + * + * Two things are different from `worker/setup.ts`, both because of who runs it: + * + * - **It does not ask.** `worker/setup.ts` prints its plan and waits for a + * yes, because it is running on somebody else's computer and "nobody was + * there" is not consent. This runs on the build machine, for a person who + * just typed a build command, and a prompt in the middle of `npm run + * desktop:installer` would only be a thing to answer. + * - **It fetches for one target triple**, because that is what `externalBin` + * consumes: Tauri looks for `binaries/node-<.exe>` and silently + * bundles nothing when the name does not match. + * + * node src-tauri/scripts/fetch-node.mjs [--target ] [--force] + */ + +import { createHash } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; + +/* ── What gets downloaded, pinned ───────────────────────────────────────── */ + +/** + * Node 24.20.0 — the active LTS line, released 2026-08-26. + * + * Two constraints decide this, and only one of them is "recent". The floor is + * **22.18**, the first release that strips types out of a `.ts` file on its + * own; below it the worker does not run at all, which is the `NodeTooOld` + * state in `src-tauri/src/worker.rs`. The ceiling is judgement: an LTS line + * gets security fixes for years, and a machine in somebody's studio should not + * be carrying a runtime that stops being patched in six months. + * + * Bumping it is this constant, the two digests below, and nothing else — the + * digests come from `https://nodejs.org/dist/v/SHASUMS256.txt`, which + * is the file the Node project signs. + */ +const NODE = "24.20.0"; + +/** + * The bare `node.exe`, not the zip. + * + * Node on Windows is a single statically linked executable — no DLLs, no + * `npm/`, no install step — so the one file *is* the runtime, and unpacking a + * 50 MB archive to throw away everything but one member would be work done to + * arrive at the same place. The worker imports nothing from `node_modules` + * (verified: every import in `worker/*.ts` is either `node:*`, another + * `worker/` file, or one of the three `lib/server` files bundled beside it), + * which is the fact that makes this true. + * + * `bytes` is the exact `Content-Length` nodejs.org serves; a digest already + * catches a truncated file, but a length that is checked as the bytes go past + * fails a dropped connection immediately rather than after hashing 90 MB. + * + * Windows only, and said out loud rather than half-attempted. `worker/setup.ts` + * takes the same line for ffmpeg and whisper.cpp, for the same reason: this + * team runs Windows, and a macOS or Linux entry here would be a tarball to + * unpack, a `bin/node` to chmod, and a bundle nobody has ever built or run. + * Adding one is a catalogue entry and an extract step, on the day somebody + * needs it. + */ +const ASSETS = { + "x86_64-pc-windows-msvc": { + url: `https://nodejs.org/dist/v${NODE}/win-x64/node.exe`, + sha256: "5c976096e04e5c2c1f091938926234cc9fbebfe9787ddd149351b3b0ecc707b5", + bytes: 93_381_448, + about: "89 MB", + }, + "aarch64-pc-windows-msvc": { + url: `https://nodejs.org/dist/v${NODE}/win-arm64/node.exe`, + sha256: "92949e7764e56e305cb84ea3d575912e822c79e85599362e8d408b04b9ffd326", + bytes: 81_727_304, + about: "78 MB", + }, +}; + +/** Resolved from this file, not from the working directory: the bundler reads + * `binaries/` relative to `src-tauri`, wherever the build was started from. */ +const here = path.dirname(fileURLToPath(import.meta.url)); +const BINARIES = path.join(here, "..", "binaries"); + +/** The triple Tauri will look for, which is the one this machine compiles to. */ +function hostTriple() { + const asked = process.argv.indexOf("--target"); + if (asked !== -1 && process.argv[asked + 1]) return process.argv[asked + 1]; + try { + const line = execFileSync("rustc", ["-vV"], { encoding: "utf8" }) + .split(/\r?\n/) + .find((l) => l.startsWith("host: ")); + if (line) return line.slice("host: ".length).trim(); + } catch { + // rustc is not on PATH in this shell. The guess below is right on every + // machine that can build this app anyway, and `--target` overrides it. + } + return process.arch === "arm64" + ? "aarch64-pc-windows-msvc" + : "x86_64-pc-windows-msvc"; +} + +/** + * Read an existing file's digest, so a build that has already fetched this + * runtime does not fetch it again — and so a file of the *wrong* digest, left + * by an older pin or an interrupted copy, is replaced rather than trusted. + * + * `createReadStream` rather than a `FileHandle`, because the caller may delete + * this file a moment later and an open handle on Windows is how that turns + * into EBUSY. + */ +async function sha256Of(file) { + const hash = createHash("sha256"); + await pipeline(createReadStream(file), hash); + return hash.digest("hex"); +} + +async function main() { + const force = process.argv.includes("--force"); + const triple = hostTriple(); + const asset = ASSETS[triple]; + if (!asset) { + console.error( + `This build targets ${triple}, and the desktop app only carries a Node for ` + + `${Object.keys(ASSETS).join(" and ")}.\n` + + `Add it to the catalogue in ${path.relative(process.cwd(), fileURLToPath(import.meta.url))} ` + + `with the digest from https://nodejs.org/dist/v${NODE}/SHASUMS256.txt.`, + ); + process.exit(1); + } + + // The name is not cosmetic: Tauri resolves `externalBin: ["binaries/node"]` + // to exactly this, and a file under any other name means an installer that + // builds cleanly and ships no runtime at all. + const final = path.join(BINARIES, `node-${triple}.exe`); + + console.log(`Node ${NODE} for ${triple}`); + console.log(` from ${asset.url}`); + console.log(` ${asset.about}, sha256 ${asset.sha256}`); + console.log(` into ${final}`); + + if (!force) { + const digest = await sha256Of(final).catch(() => ""); + if (digest === asset.sha256) { + console.log(" already here and verified — nothing to fetch."); + return; + } + if (digest) { + console.log(" a different file is here; replacing it."); + } + } + + await fs.mkdir(BINARIES, { recursive: true }); + + /* + * Bytes land under `.part` and are moved into place only after they pass. + * + * The staging is the whole point, and it is the same reasoning as + * `worker/setup.ts`: a captive portal that answers with a login page and a + * 200, a proxy that gives up half way, a dropped connection — every one of + * those otherwise leaves a plausible-looking `node-.exe` in + * `binaries/`, which the next build bundles into an installer that ships a + * runtime Windows refuses to start. `rename` is one filesystem operation and + * cannot half-happen. + */ + const part = `${final}.part`; + const response = await fetch(asset.url, { redirect: "follow" }); + if (!response.ok || !response.body) { + throw new Error(`nodejs.org answered ${response.status} ${response.statusText}.`); + } + + const hash = createHash("sha256"); + let got = 0; + await pipeline( + Readable.fromWeb(response.body), + async function* (chunks) { + for await (const chunk of chunks) { + hash.update(chunk); + got += chunk.length; + yield chunk; + } + }, + createWriteStream(part), + ); + + const digest = hash.digest("hex"); + const wrong = + got !== asset.bytes + ? `it is ${got.toLocaleString()} bytes and this expects ${asset.bytes.toLocaleString()}` + : digest !== asset.sha256 + ? `its sha256 is ${digest} and this expects ${asset.sha256}` + : ""; + if (wrong) { + await fs.rm(part, { force: true }); + throw new Error( + `The download from nodejs.org is not the file this build expects: ${wrong}. ` + + `Nothing was kept. If nodejs.org has re-published v${NODE}, take the new digest ` + + `from its SHASUMS256.txt and change it in this file, where it is reviewed.`, + ); + } + + await fs.rm(final, { force: true }); + await fs.rename(part, final); + console.log(` verified (sha256 ok, ${got.toLocaleString()} bytes).`); +} + +main().catch((e) => { + console.error(`Could not fetch Node: ${e.message ?? e}`); + process.exit(1); +}); diff --git a/src-tauri/scripts/sign.ps1 b/src-tauri/scripts/sign.ps1 new file mode 100644 index 0000000..1505712 --- /dev/null +++ b/src-tauri/scripts/sign.ps1 @@ -0,0 +1,85 @@ +# Authenticode signing, when there is a certificate to sign with. +# +# `bundle.windows.signCommand` in tauri.conf.json points at this file, so the +# bundler runs it once per binary it produces — the app, the Node sidecar and +# the NSIS installer — with the file's path as the only argument. +# +# It exists because of what a certificate is: not a setting, but a private key +# that belongs to whoever is publishing the app. It cannot live in this +# repository, it cannot be generated (a certificate you made yourself signs +# nothing anybody's computer will trust, and calling that "signed" is worse +# than being plainly unsigned), and it must not be a reason the build fails on +# a machine that does not have one. So the certificate arrives through the +# environment, and this script has exactly two behaviours: +# +# nothing configured -> say so, once per file, and leave the file alone. +# configured -> sign, and FAIL THE BUILD if signing does not work. +# +# The second half is the important one. A build that thinks it signed and did +# not is the one outcome worse than an unsigned build, because the installer +# then ships looking exactly like the signed one. +# +# What to set, on the machine that publishes releases: +# +# CONTENTOS_SIGN_THUMBPRINT the SHA1 thumbprint of a code signing +# certificate in this user's certificate +# store. This is the shape an EV or hardware +# token certificate takes — the key never +# leaves the token, so there is no file. +# CONTENTOS_SIGN_PFX …or the path to a .pfx, for a certificate +# CONTENTOS_SIGN_PFX_PASSWORD that is a file. Prefer the thumbprint. +# CONTENTOS_SIGN_TIMESTAMP_URL an RFC3161 timestamp server. Defaults to +# DigiCert's. Timestamping is not optional in +# practice: without it every signature stops +# verifying the day the certificate expires, +# including on installers already downloaded. +# CONTENTOS_SIGN_TOOL signtool.exe, when it is not found below. + +param([Parameter(Mandatory = $true)][string]$File) + +$ErrorActionPreference = "Stop" + +$thumbprint = $env:CONTENTOS_SIGN_THUMBPRINT +$pfx = $env:CONTENTOS_SIGN_PFX + +if (-not $thumbprint -and -not $pfx) { + Write-Host "sign.ps1: no certificate configured, so $(Split-Path -Leaf $File) is unsigned. Windows SmartScreen will warn the people who install it. Set CONTENTOS_SIGN_THUMBPRINT (or CONTENTOS_SIGN_PFX) to sign." + exit 0 +} + +# signtool ships with the Windows SDK and is not on PATH by default. The SDK +# installs one per version, so the newest is taken rather than the first found. +$tool = $env:CONTENTOS_SIGN_TOOL +if (-not $tool) { + $tool = Get-Command signtool.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source +} +if (-not $tool) { + $arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" } + $tool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\$arch\signtool.exe" -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName +} +if (-not $tool) { + throw "A certificate is configured but signtool.exe is not here. Install the Windows SDK's signing tools, or set CONTENTOS_SIGN_TOOL to signtool.exe." +} + +$timestamp = $env:CONTENTOS_SIGN_TIMESTAMP_URL +if (-not $timestamp) { $timestamp = "http://timestamp.digicert.com" } + +$signArgs = @("sign", "/fd", "sha256", "/td", "sha256", "/tr", $timestamp) +if ($thumbprint) { + $signArgs += @("/sha1", $thumbprint) +} else { + if (-not (Test-Path $pfx)) { throw "CONTENTOS_SIGN_PFX points at $pfx, which is not there." } + $signArgs += @("/f", $pfx) + if ($env:CONTENTOS_SIGN_PFX_PASSWORD) { $signArgs += @("/p", $env:CONTENTOS_SIGN_PFX_PASSWORD) } +} +$signArgs += $File + +& $tool @signArgs +if ($LASTEXITCODE -ne 0) { + # Loudly, and fatally. See the note at the top: an installer that was not + # signed must never leave this machine believing it was. + throw "signtool refused to sign $File (exit $LASTEXITCODE)." +} +Write-Host "sign.ps1: signed $(Split-Path -Leaf $File)." diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 20da40a..3a51115 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -18,6 +18,7 @@ mod settings; mod tray; +mod updates; mod windows; mod worker; @@ -74,6 +75,15 @@ fn open_console(app: AppHandle) { fn main() { tauri::Builder::default() + /* + * Registered even in a build with nowhere to look, because the plugin + * is where `updater()` comes from and the alternative is a `cfg` that + * would have to be flipped in Rust to turn updating on. The whole + * point of src/updates.rs is that turning it on is two entries in + * tauri.conf.json; with no endpoint the check simply says so and the + * menu item that would run it is never built. + */ + .plugin(tauri_plugin_updater::Builder::new().build()) .manage(Worker::default()) .invoke_handler(tauri::generate_handler![ status, diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 787daf8..a2cd9d8 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -11,10 +11,11 @@ //! The first line of the menu is therefore the status itself, disabled: it is //! a statement, not a button. -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIconBuilder; use tauri::{AppHandle, Manager, Wry}; +use crate::updates; use crate::windows; use crate::worker::{View, Worker}; @@ -23,6 +24,11 @@ use crate::worker::{View, Worker}; /// having to be handed the menu. struct StatusItem(MenuItem); +/// The same trick for the update check, which also answers from another +/// thread. There is no such item at all in a build with no update endpoint, +/// hence the `Option`-shaped access in `updates_say`. +struct UpdateItem(MenuItem); + pub fn install(app: &AppHandle) -> tauri::Result<()> { let status = MenuItem::with_id(app, "status", "Starting…", false, None::<&str>)?; let console = MenuItem::with_id(app, "console", "Open the console", true, None::<&str>)?; @@ -30,18 +36,26 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let restart = MenuItem::with_id(app, "restart", "Restart the worker", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit Content OS", true, None::<&str>)?; - let menu = Menu::with_items( - app, - &[ - &status, - &PredefinedMenuItem::separator(app)?, - &console, - &setup, - &restart, - &PredefinedMenuItem::separator(app)?, - &quit, - ], - )?; + // Only when this build was given somewhere to look. An item that could + // only ever say "no update server was configured" teaches people that the + // menu is decorative; see src/updates.rs for what is missing and why. + let update = updates::configured(app) + .then(|| MenuItem::with_id(app, "updates", "Check for updates…", true, None::<&str>)) + .transpose()?; + + // Assembled rather than written out, because one of the items is there or + // is not. The separators have to be bound to names for the same reason. + let under_status = PredefinedMenuItem::separator(app)?; + let over_quit = PredefinedMenuItem::separator(app)?; + let mut items: Vec<&dyn IsMenuItem> = + vec![&status, &under_status, &console, &setup, &restart]; + if let Some(update) = &update { + items.push(update); + } + items.push(&over_quit); + items.push(&quit); + + let menu = Menu::with_items(app, &items)?; TrayIconBuilder::with_id("main") // Compiled in from bundle.icon in tauri.conf.json, so this is only @@ -60,6 +74,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { "console" => windows::console(app), "setup" => windows::setup(app), "restart" => app.state::().start(app), + "updates" => updates::check(app), "quit" => { // Order matters. The worker is killed while there is still an // app to kill it from; `app.exit` unwinds the event loop, and @@ -72,6 +87,9 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { .build(app)?; app.manage(StatusItem(status)); + if let Some(update) = update { + app.manage(UpdateItem(update)); + } Ok(()) } @@ -82,3 +100,14 @@ pub fn say(app: &AppHandle, view: &View) { let _ = item.0.set_text(view.title); } } + +/// What the update check is doing, in the item that was clicked to start it. +/// +/// The answer replaces the label and stays there — "Up to date (0.1.0)" is +/// worth leaving on screen, and clicking it asks again. There is nothing to +/// update when the item does not exist, which is every build with no endpoint. +pub fn updates_say(app: &AppHandle, text: &str) { + if let Some(item) = app.try_state::() { + let _ = item.0.set_text(text); + } +} diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs new file mode 100644 index 0000000..7e7bf49 --- /dev/null +++ b/src-tauri/src/updates.rs @@ -0,0 +1,115 @@ +//! Updating the app itself — wired, and switched off until somebody points it +//! at a server. +//! +//! Everything a teammate sees in Content OS is served by the console, so the +//! usual reason to update a desktop app does not apply: a template change or a +//! bug fix in the UI reaches every machine the moment the server is deployed. +//! What is in the installer is the shell around it — the worker, the runtime +//! it spawns, the tray, this file — and that changes rarely and matters when +//! it does. A machine running last quarter's worker against this quarter's +//! server is exactly the failure nobody notices until a job behaves oddly on +//! one desktop. +//! +//! **What is here and what is not.** The plugin is registered, the check runs, +//! and the result lands in the tray where a background app should report. What +//! is missing is not code: it is a URL to ask and a key to verify with, and +//! both are entries in `tauri.conf.json`: +//! +//! ```json +//! "plugins": { "updater": { +//! "endpoints": ["https://…/latest.json"], +//! "pubkey": "…the public half of the signing key…" +//! } } +//! ``` +//! +//! Nothing here can be honestly finished without them, and neither one can be +//! invented in a repository. A key pair would have to have its private half +//! somewhere, and the only somewhere available to a commit is the commit — at +//! which point anybody who has cloned this can sign an "update" that every +//! installed copy will accept and run. So the endpoint stays empty, the menu +//! item below does not appear, and `docs/WORKER.md` says exactly what a person +//! has to add. **This path has never been exercised end to end**, for the +//! plain reason that there is no endpoint to exercise it against. +//! +//! What the check would do is deliberately small: ask, and if there is +//! something newer, download it, stop the worker, and hand over to the +//! installer. No silent updates — a machine that is halfway through writing a +//! section should not restart itself because a release happened. + +use tauri::{AppHandle, Manager}; +use tauri_plugin_updater::UpdaterExt; + +use crate::tray; +use crate::worker::Worker; + +/// Whether this build was given somewhere to look for updates. +/// +/// Read from the config at run time rather than decided at compile time, so +/// that adding an endpoint really is a configuration change: nothing in this +/// file has to be touched to turn updating on. Without one the tray item is +/// absent altogether, because a button that can only ever answer "this was +/// built without an update server" is a button that teaches people the menu +/// lies. +pub fn configured(app: &AppHandle) -> bool { + app.config() + .plugins + .0 + .get("updater") + .and_then(|updater| updater.get("endpoints")) + .and_then(|endpoints| endpoints.as_array()) + .is_some_and(|endpoints| !endpoints.is_empty()) +} + +/// Ask, and install whatever comes back. +/// +/// Returns immediately; everything after it happens on the async runtime and +/// reports through the tray, because the window is usually closed — that is +/// the whole point of the tray — and an update that only announced itself in a +/// window nobody has open would be an update nobody ever runs. +pub fn check(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + tray::updates_say(&app, "Checking for updates…"); + + let updater = match app.updater() { + Ok(updater) => updater, + Err(e) => return tray::updates_say(&app, &format!("Cannot check for updates: {e}")), + }; + + match updater.check().await { + Err(e) => tray::updates_say(&app, &format!("Could not check for updates: {e}")), + Ok(None) => tray::updates_say( + &app, + &format!("Up to date ({})", app.package_info().version), + ), + Ok(Some(update)) => { + let version = update.version.clone(); + tray::updates_say(&app, &format!("Downloading Content OS {version}…")); + + /* + * The worker goes first, and it has to. + * + * Installing hands over to the NSIS installer and ends this + * process without going through `RunEvent::Exit`, so the code + * in main.rs that kills the worker on the way out never runs. + * A Node process that outlived the app would keep claiming + * jobs from the console and spending somebody's model + * subscription, with nothing on screen it could be stopped + * from — and the new copy would start a second one beside it. + */ + app.state::().stop(&app); + + match update.download_and_install(|_, _| {}, || {}).await { + Ok(()) => tray::updates_say(&app, &format!("Installing Content OS {version}…")), + Err(e) => { + tray::updates_say(&app, &format!("Update to {version} failed: {e}")); + // The worker was stopped for an install that did not + // happen, so this machine would otherwise sit there + // taking no jobs until somebody noticed. + app.state::().start(&app); + } + } + } + } + }); +} diff --git a/src-tauri/src/worker.rs b/src-tauri/src/worker.rs index 8b07a62..af4328b 100644 --- a/src-tauri/src/worker.rs +++ b/src-tauri/src/worker.rs @@ -7,10 +7,17 @@ //! arrive somewhere a person can see it, and that is what most of this file //! is: spawn it, read what it says, and turn that into a state with a name. //! -//! **It spawns the Node that is already on the machine.** Bundling a Node -//! runtime as a Tauri sidecar is the next step and not this one; what makes -//! that acceptable now is that Node's absence is *detected and named* rather -//! than being a process that silently never started. +//! **It brings its own Node, and its own copy of the worker.** The installer +//! carries a pinned `node.exe` as a Tauri sidecar and `worker/` plus the three +//! `lib/server` files it imports as bundle resources, so a teammate installs +//! one thing and their machine starts taking jobs. "Install Node first" is the +//! exact instruction this app exists to delete, and a runtime the app does not +//! control is also a runtime that can be upgraded out from under it. +//! +//! A checkout still wins when there is one. Running the debug build from +//! inside the repository uses the repository's `worker/` — because that is how +//! anybody debugs this, and a developer editing `worker/index.ts` and watching +//! an installed copy run instead would lose an afternoon to it. //! //! Nothing here is persisted. A job this machine was holding when the app //! quits is not lost: its lease expires on the server, the reaper puts it back @@ -52,9 +59,15 @@ const CREATE_NO_WINDOW: u32 = 0x0800_0000; /// The names are the point. Every one of these was a line in a terminal that /// somebody had to be a developer to read, and the whole reason this app /// exists is that the people running it are not: "cannot reach the console" is -/// wifi, "token refused" is a trip to Settings → Machines, and "Node missing" -/// is one download. A single "error" state would put all three behind the same -/// shrug. +/// wifi, "token refused" is a trip to Settings → Machines. A single "error" +/// state would put both behind the same shrug. +/// +/// `NodeMissing` changed meaning when Node started travelling inside the +/// installer. It used to say "this computer has no Node", which was a download +/// from nodejs.org; now the installer carries one, so it can only mean the +/// installation is damaged — or that somebody is running a checkout without a +/// Node on PATH. Both keep the state, and `start` writes whichever sentence is +/// true, because the actions could not be further apart. #[derive(Clone, Copy, Default, PartialEq, Eq)] pub enum Health { #[default] @@ -91,7 +104,7 @@ impl Health { Health::Starting => "Starting…", Health::Connected => "Connected", Health::Offline => "Cannot reach the console", - Health::NodeMissing => "Node is not installed", + Health::NodeMissing => "This app's copy of Node is missing", Health::NodeTooOld => "Node is too old", Health::WorkerMissing => "The worker's files are missing", Health::BadToken => "This machine's token was refused", @@ -179,13 +192,9 @@ impl Worker { ); } - let Some(root) = worker_root(app) else { - return self.set( - app, - Health::WorkerMissing, - "This app cannot find worker/index.ts. It looks for the Content OS folder around itself; \ - set CONTENTOS_WORKER_DIR to that folder if it is somewhere else.", - ); + let layout = match Layout::find(app) { + Ok(layout) => layout, + Err((health, why)) => return self.set(app, health, why), }; /* @@ -195,9 +204,12 @@ impl Worker { * worker that has to be kept in step with package.json, and the day * they drift is the day this app runs a worker nobody can reproduce * from a terminal. + * + * The only difference an installed app makes is *which* Node and + * *which* copy of `worker/`, and both are decided in `Layout::find`. */ - let mut cmd = Command::new("node"); - cmd.current_dir(&root) + let mut cmd = Command::new(&layout.node); + cmd.current_dir(&layout.root) .arg("--disable-warning=MODULE_TYPELESS_PACKAGE_JSON") .arg("--import") .arg("./worker/register.mjs") @@ -209,6 +221,7 @@ impl Worker { .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + layout.give_it_somewhere_to_write(app, &mut cmd); #[cfg(windows)] { use std::os::windows::process::CommandExt; @@ -219,17 +232,17 @@ impl Worker { Ok(child) => child, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { /* - * The one failure a non-developer is guaranteed to hit, and - * the one that must never be silent. Naming the program and - * where to get it is the difference between a person fixing - * their own machine in five minutes and a message to whoever - * set up the server. + * Only reachable from a checkout: an installed app has already + * been told in `Layout::find` that its own `node.exe` is not + * there. This is the developer case, and it deserves the + * developer's sentence rather than "reinstall". */ return self.set( app, Health::NodeMissing, - "The worker needs Node on this computer and there is none. Install Node 22.18 or newer \ - from nodejs.org — take the default options — then choose Restart the worker.", + "This is a checkout of Content OS rather than an installed copy, so it has no Node of \ + its own and there is none on this computer either. Install Node 22.18 or newer from \ + nodejs.org, then choose Restart the worker.", ); } Err(e) => { @@ -456,50 +469,174 @@ fn classify(line: &str) -> Option<(Health, String)> { } if line.contains("Unknown file extension") || line.contains("ERR_UNKNOWN_FILE_EXTENSION") { /* - * Node is installed but predates type stripping, so it will not run - * the worker's .ts files. It looks like a crash and is a version - * number; see worker/resolve-ts.mjs for why 22.18 is the floor. + * Node is there but predates type stripping, so it will not run the + * worker's .ts files. It looks like a crash and is a version number; + * see worker/resolve-ts.mjs for why 22.18 is the floor. + * + * Only a checkout can reach this now — the Node inside the installer + * is pinned well above the floor by src-tauri/scripts/fetch-node.mjs — + * so the advice is a developer's, and the state stays separate from + * "missing" because the fix is an upgrade rather than an install. */ return Some(( Health::NodeTooOld, - "The Node on this computer is too old to run the worker. Install Node 22.18 or newer from \ - nodejs.org, then choose Restart the worker." + "The Node this checkout is using is too old to run the worker. Install Node 22.18 or newer \ + from nodejs.org, then choose Restart the worker." .into(), )); } None } -/// The Content OS folder — the one with `worker/` in it. +/// Which Node runs which copy of the worker, and where it may write. +/// +/// Two shapes, and they are not variations of one another: +/// +/// - **Installed.** Everything came out of the installer. `worker/` and the +/// three `lib/server` files it imports are under Tauri's resource directory, +/// and a pinned `node.exe` sits beside the app's own executable as a +/// sidecar. Nothing is required of the computer. +/// - **A checkout.** `worker/` is the repository's, which is the copy somebody +/// is editing, and the Node is whatever they have. This is how the app is +/// debugged, and it has to keep working or a fix would have to be installed +/// to be tried. +struct Layout { + /// The folder with `worker/` in it. Also the child's working directory, + /// because that is where `npm run worker` starts from. + root: PathBuf, + /// The program to run: an absolute path to the app's own Node when there + /// is one, and otherwise the bare name, which means PATH. + node: PathBuf, + /// Whether `root` came out of the installer. It decides one thing — where + /// the worker is allowed to write — and nothing else. + installed: bool, +} + +impl Layout { + fn find(app: &AppHandle) -> Result { + let (root, installed) = worker_root(app).ok_or_else(|| { + ( + Health::WorkerMissing, + "This app cannot find worker/index.ts. An installed copy carries its own, so if this one \ + is damaged, install Content OS again. Running from a checkout it looks for the Content OS \ + folder around itself — set CONTENTOS_WORKER_DIR to that folder if it is somewhere else." + .to_string(), + ) + })?; + + /* + * The app's own Node wherever there is one, including in a checkout: + * it is the version this worker is pinned to, fetched and checksummed + * by src-tauri/scripts/fetch-node.mjs, and a debug session that runs + * the same runtime as the installed app is a debug session whose + * findings transfer. + */ + let node = match shipped_node() { + Some(node) => node, + None if installed => { + return Err(( + Health::NodeMissing, + "Content OS installs its own copy of Node and that copy is not here, so this \ + installation is damaged — nothing is missing from your computer. Install Content OS \ + again over the top of this one; the console address and this machine's token are kept." + .to_string(), + )) + } + None => PathBuf::from("node"), + }; + + Ok(Layout { + root, + node, + installed, + }) + } + + /// Give the worker somewhere it is allowed to write. + /// + /// This is not a nicety, it is the difference between an installed app + /// that works and one that fails its first job. `cliHome()` in + /// `lib/server/tools.ts` does a `mkdirSync` under `CONTENTOS_DATA_DIR`, or + /// under the working directory when that is unset — and an installed app's + /// working directory is inside Program Files, where a normal user account + /// cannot create anything. The failure would land on the machine where + /// nobody is reading a terminal. + /// + /// A checkout is deliberately left alone. `.data/` beside `worker/` is + /// what `npm run worker` uses and what `npm run worker:setup` fills, and an + /// app quietly preferring a different folder would make a bug seen here + /// impossible to reproduce there. + /// + /// An explicit setting always wins, for the same reason it does in + /// `worker/config.ts`: somebody put a three-gigabyte whisper model on the + /// drive with room for it. + fn give_it_somewhere_to_write(&self, app: &AppHandle, cmd: &mut Command) { + if !self.installed { + return; + } + let Ok(dir) = app.path().app_data_dir() else { + return; + }; + // Two variables rather than one, because they mean different things: + // the first is where `cliHome()` makes the deliberately empty folder + // the model CLIs are started in, the second is where the tools + // installer puts yt-dlp, ffmpeg and whisper.cpp. + for (name, value) in [ + ("CONTENTOS_DATA_DIR", dir.join("data")), + ("CONTENTOS_TOOLS_DIR", dir.join("tools")), + ] { + if std::env::var_os(name).is_none() { + cmd.env(name, value); + } + } + } +} + +/// The Content OS folder — the one with `worker/` in it — and whether it came +/// out of the installer. /// /// Searched for rather than configured, because in development this app is /// started from `src-tauri` and the worker is two directories up, and asking /// somebody to set a path to a folder that is already right there is the kind /// of setup step that makes a tool feel broken before it has run once. /// -/// The resource directory is tried first among the automatic candidates -/// because that is where an installed build will keep its copy the day the -/// worker is bundled; today it simply is not there and the walk upwards wins. -fn worker_root(app: &AppHandle) -> Option { - let mut tried: Vec = Vec::new(); +/// The resource directory is tried before the walk upwards, and on Windows +/// that ordering is load-bearing rather than tidy: an installed app's +/// executable lives *in* its resource directory, so the walk would find the +/// same folder a step later and call it a checkout, and the worker would then +/// be pointed at Program Files to write in. +fn worker_root(app: &AppHandle) -> Option<(PathBuf, bool)> { + let mut tried: Vec<(PathBuf, bool)> = Vec::new(); if let Ok(set) = std::env::var("CONTENTOS_WORKER_DIR") { - tried.push(PathBuf::from(set)); + tried.push((PathBuf::from(set), false)); } if let Ok(resources) = app.path().resource_dir() { - tried.push(resources); + tried.push((resources, true)); } if let Ok(exe) = std::env::current_exe() { - tried.extend(exe.ancestors().map(PathBuf::from)); + tried.extend(exe.ancestors().map(|p| (p.to_path_buf(), false))); } if let Ok(cwd) = std::env::current_dir() { - tried.extend(cwd.ancestors().map(PathBuf::from)); + tried.extend(cwd.ancestors().map(|p| (p.to_path_buf(), false))); } - tried.into_iter().find(|root| { + tried.into_iter().find(|(root, _)| { root.join("worker").join("index.ts").is_file() && root.join("worker").join("register.mjs").is_file() }) } +/// The Node that came out of the installer, if it is there. +/// +/// Tauri puts an `externalBin` next to the app's own executable and takes the +/// target triple back off the name, so this is one `is_file` on a path that is +/// known rather than a search. Absolute on purpose: the entire point of +/// shipping a runtime is not to depend on what PATH happens to say. +fn shipped_node() -> Option { + let exe = std::env::current_exe().ok()?; + let node = exe.with_file_name(if cfg!(windows) { "node.exe" } else { "node" }); + node.is_file().then_some(node) +} + /// Kill the worker and everything it started. /// /// The child that outlives its window is the bug that makes people restart diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d8f094a..91337dd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,11 +10,39 @@ "withGlobalTauri": true, "windows": [] }, + "plugins": { + "updater": { + "endpoints": [], + "pubkey": "" + } + }, "bundle": { "active": true, "targets": ["nsis"], "icon": ["icons/32x32.png", "icons/icon.png", "icons/icon.ico"], "publisher": "Content OS", - "shortDescription": "The Content OS console, and the worker for this machine" + "shortDescription": "The Content OS console, and the worker for this machine", + "externalBin": ["binaries/node"], + "resources": { + "../worker/*.ts": "worker/", + "../worker/*.mjs": "worker/", + "../lib/server/tools.ts": "lib/server/tools.ts", + "../lib/server/brain-defs.ts": "lib/server/brain-defs.ts", + "../lib/server/brain-transports.ts": "lib/server/brain-transports.ts", + "../package.json": "package.json" + }, + "windows": { + "signCommand": { + "cmd": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/sign.ps1", + "%1" + ] + } + } } } From e03560c7a171156213274d76943b974e7388e8d5 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 23:12:45 +0600 Subject: [PATCH 15/30] Only switch on tools a machine actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/server/repos/workers.ts | 16 +++++++++++++++- tests/queue.test.ts | 8 +++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/server/repos/workers.ts b/lib/server/repos/workers.ts index fbbdaf1..e303bc0 100644 --- a/lib/server/repos/workers.ts +++ b/lib/server/repos/workers.ts @@ -122,7 +122,21 @@ export async function registerWorker(patch: { enabled?: string[]; }): Promise { const hash = hashToken(patch.token); - const reported = (patch.tools ?? []).map((t) => t.id as string); + /* + * Only what is actually THERE counts as reportable. + * + * A machine 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` is a different question — what may be USED — and + * seeding it from the whole report switched on tools the machine does not + * have. `routeFor` checks presence before it routes, so nothing was + * misrouted; 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. + */ + const reported = (patch.tools ?? []) + .filter((t) => t.present) + .map((t) => t.id as string); /* * What stays switched on, when a machine reports itself again. diff --git a/tests/queue.test.ts b/tests/queue.test.ts index 042792d..eb1bba1 100644 --- a/tests/queue.test.ts +++ b/tests/queue.test.ts @@ -186,9 +186,15 @@ describe.skipIf(!up)("the queue", () => { }); const tok = token(); + // A probed-but-absent tool is reported (the page has to be able to say + // "not installed on that computer") and must NOT be switched on: `enabled` + // answers "may be used", and the claim query matches `needs` against it + // alone. + const missing = (tid: string) => ({ ...tool(tid), present: false }); + const first = await registerWorker({ userId: uid, name: "re-reg", token: tok, - tools: [tool("claude"), tool("ffmpeg")] as never, + tools: [tool("claude"), tool("ffmpeg"), missing("codex")] as never, }); expect([...first.enabled].sort()).toEqual(["claude", "ffmpeg"]); From 82189033cbcc3a9b3c45feccbb5d556f7e27e381 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 23:16:22 +0600 Subject: [PATCH 16/30] Record that unsigned and no-auto-update are the answer, not a gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/DECISIONS.md | 31 +++++++++++++++++++++++++++++++ docs/WORKER.md | 43 ++++++++++++++++++++++++++++++------------- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 47961e5..0a6c63b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -687,3 +687,34 @@ Three parts of that are load-bearing and are the ones to protect: The API key did not go away; it stopped being automatic. `workspaces.api_fallback` is off by default, because a fallback that fired by itself would spend money at exactly the moment nobody was watching. + +## The desktop app is unsigned and does not update itself + +Both were considered and both were declined, on 2026-09-05, because this +console is an internal tool for one team. + +**No code-signing certificate.** An OV or EV certificate costs money every +year and a verified legal identity, to remove a warning that a handful of +people see once each. The installer is handed over in person or through a link +the team already trusts. What they see is SmartScreen's *"Windows protected +your PC"* with Run hidden behind **More info**, and whoever sends the file +should say so in the same message — otherwise somebody decides on their own +that it is unsafe. + +**No update endpoint.** Updating is being handed a new installer, which for +five people is a message with a file in it. An endpoint, a signing key and an +honest `latest.json` is real infrastructure to save that message. + +Both are wired anyway and neither is pretended-finished. `sign.ps1` takes a +real certificate through the environment and prints one line per file saying +it is unsigned when there is none; a build with a certificate configured and +signing failing fails deliberately. The updater plugin is registered and its +tray item is built **only** when an endpoint is configured, so today it does +not appear rather than being a button that does nothing. + +That is the part worth protecting. Do not delete the hooks to tidy up: the +answer changes the day this reaches somebody outside the team — a client, a +contractor, a download page — and on that day it should be configuration, not +a rewrite. Do not generate a self-signed certificate either. It removes no +warning, nothing trusts it, and calling the result "signed" is worse than +being plainly unsigned. diff --git a/docs/WORKER.md b/docs/WORKER.md index 2248c4f..24b990b 100644 --- a/docs/WORKER.md +++ b/docs/WORKER.md @@ -1143,7 +1143,7 @@ left alone: `.data/` beside `worker/` is what `npm run worker` uses and what `npm run worker:setup` fills, and an app quietly preferring somewhere else would make a bug seen here impossible to reproduce there. -### Signing, and what it costs not to +### Signing: decided against, deliberately **The installer this repository produces is unsigned**, and every build says so once per file: @@ -1152,12 +1152,21 @@ once per file: sign.ps1: no certificate configured, so Content OS_0.1.0_x64-setup.exe is unsigned. ``` -What that means for whoever installs it: SmartScreen shows *"Windows protected -your PC"* and hides the Run button behind **More info**. For an internal team -of a handful of people that is a decision rather than a blocker — somebody says -"click More info, then Run anyway" once. For anything wider it is not, because -that is also exactly what malware looks like, and teaching people to click -through the warning is teaching them the wrong reflex. +**That is the call, and it has been made.** This console is an internal tool +for one team, handed over in person or through a link they already trust, and a +certificate costs money and a verified legal identity to remove a warning that +five people will see once each. + +What they will see: SmartScreen shows *"Windows protected your PC"* and hides +the Run button behind **More info**. Whoever hands the installer over should +say so in the same message, because the alternative is somebody deciding on +their own that the file is unsafe. + +The moment this stops being right is the moment it leaves the team — a client, +a contractor, anyone downloading it from a page. That is exactly what malware +looks like, and teaching people to click through the warning is teaching them +the wrong reflex. The hook below is what makes changing the answer a +configuration change rather than a rewrite. Fixing it costs money and identity rather than code: an **OV or EV code signing certificate** from a certificate authority, issued to a verified legal entity, @@ -1188,13 +1197,21 @@ A self-signed certificate is not an option and is not offered here. It signs nothing anybody's computer trusts, it does not remove the SmartScreen warning, and calling the result "signed" is worse than being plainly unsigned. -### Auto-update: wired, and switched off until there is somewhere to look +### Auto-update: decided against too, and switched off in a way that shows + +The team updates by being handed a new installer, which for a handful of people +in one company is a message with a file in it. Hosting an update endpoint, +holding a signing key and keeping a `latest.json` honest is real infrastructure +to save that message. + +`tauri-plugin-updater` is registered and `src-tauri/src/updates.rs` does the +check and the install, because the code costs nothing to keep and the answer +changes the day the team is spread across offices. **None of it has been run +end to end** — there is nothing to run it against. The tray item is built only +when an endpoint is configured, so today it does not appear at all rather than +sitting there as a button that does nothing. -`tauri-plugin-updater` is registered, `src-tauri/src/updates.rs` does the check -and the install, and the tray grows a **Check for updates…** item when there is -an endpoint. **None of it has been run end to end**, for the plain reason that -there is nothing to run it against — and the two things missing are -configuration rather than code: +Turning it on is two configuration values, not code: ```json "plugins": { "updater": { From bd132b6e325b6b35a1c5201c02909ce00a751e73 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sat, 5 Sep 2026 23:20:02 +0600 Subject: [PATCH 17/30] Delete the route nothing calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- api/src/runs/runs.controller.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/api/src/runs/runs.controller.ts b/api/src/runs/runs.controller.ts index 56d69d7..f4e7d36 100644 --- a/api/src/runs/runs.controller.ts +++ b/api/src/runs/runs.controller.ts @@ -175,12 +175,6 @@ export class RunsController { }; } - /** Is this run being written right now, by anyone, on any machine? */ - @Get(":id/start") - async running(@Param("id") id: string) { - return { running: await isRunning(id) }; - } - /** * The queue's own record of this run, one entry per job, with the machine. * From 97c6a81a4b7c64466e2a125827565ee3c4dc12bd Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sun, 6 Sep 2026 10:38:14 +0600 Subject: [PATCH 18/30] Start the worker with everything else, and stop it renaming the machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `./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 --- docs/DEVELOPING.md | 35 ++++-- lib/server/repos/workers.ts | 10 +- start.sh | 223 +++++++++++++++++++++--------------- tests/queue.test.ts | 9 ++ 4 files changed, 175 insertions(+), 102 deletions(-) diff --git a/docs/DEVELOPING.md b/docs/DEVELOPING.md index 2e57079..bf341ec 100644 --- a/docs/DEVELOPING.md +++ b/docs/DEVELOPING.md @@ -21,24 +21,37 @@ the API's ts-node on the next request, no restart needed. `npm run dev` is plain `next dev` on port 3000 — the app is normally on **3333** because `start.sh` puts it there. Assume 3333 unless told otherwise. -## A run needs a worker, and `start.sh` does not start one +## A run needs a worker, and `start.sh` starts one -This is the thing that will waste your afternoon otherwise. The API no longer -writes sections: `advance()` queues a job and returns, and a **worker** claims -it. With no worker running, pressing Run leaves every section sitting there — -correctly, and the page says which machine it is waiting for — and nothing is -broken except that nobody is listening. +The API no longer writes sections: `advance()` queues a job and returns, and a +**worker** claims it. With no worker running, pressing Run leaves every section +sitting there — correctly, and the page says which machine it is waiting for — +and nothing is broken except that nobody is listening. -So, once per machine: **Settings → Machines → Enrol**, take the token, then +So `start.sh` starts one, and it needs the one thing it cannot work out for +itself: a token. Tokens are minted in **Settings → Machines**, and only their +sha256 is kept, so nothing can look one up afterwards. Put it in `.env`: + +```bash +CONTENTOS_WORKER_TOKEN=wrk_… +``` + +Without it `start.sh` says so in one line and carries on — the console still +comes up, because signing in is where you go to fix it. `WORKER=0 ./start.sh` +skips the worker on purpose, for working on a screen that has no runs in it. + +The worker needs no build step — Node runs the TypeScript directly — and it +picks up an edit under `worker/` on restart, not on the next job. To run it +by hand instead, in its own terminal: ```bash CONTENTOS_API_URL=http://localhost:4000 CONTENTOS_WORKER_TOKEN= npm run worker ``` -It registers, reports the tools it found on this computer, and starts asking -for work. Leave it running in its own terminal beside `start.sh`. It needs no -build step — Node runs the TypeScript directly — and it picks up an edit under -`worker/` on restart, not on the next job. +The **desktop app is not a service and `start.sh` does not start it**. It is +what a teammate installs, and it carries its own worker inside it — running it +here would put two workers on one machine, both claiming. `npm run desktop` +when you want to look at it. `docs/WORKER.md` is the whole design, including what each job kind carries and why the worker may not import anything that touches the database. diff --git a/lib/server/repos/workers.ts b/lib/server/repos/workers.ts index e303bc0..f3ca6e9 100644 --- a/lib/server/repos/workers.ts +++ b/lib/server/repos/workers.ts @@ -181,7 +181,15 @@ export async function registerWorker(patch: { last_seen_at) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, now()) ON CONFLICT (token_hash) DO UPDATE SET - name = EXCLUDED.name, + -- The NAME is the owner's, and a machine may not rename itself. It is + -- what a person reads in the machine picker when they decide where a + -- run happens ("Shakhawat's desktop", not a hostname), and it is typed + -- in Settings -> Machines at enrolment. Taking it from the report meant + -- the worker's next check-in replaced it with whatever hostname says: + -- a machine somebody had named carefully came back as "Shifa", and the + -- picker stopped matching the words the team had agreed on. Same rule + -- and same reason as can_read_frames, max_concurrency and enabled: + -- what the console sets, the console keeps. platform = EXCLUDED.platform, version = EXCLUDED.version, tools = EXCLUDED.tools, diff --git a/start.sh b/start.sh index 3f6dfc8..607a6e9 100644 --- a/start.sh +++ b/start.sh @@ -1,90 +1,133 @@ -#!/usr/bin/env bash -# Start Content OS on port 3333. -# -# ./start.sh dev server with hot reload -# ./start.sh prod production build, then serve it -# PORT=4000 ./start.sh use a different port - -set -euo pipefail - -cd "$(dirname "$0")" - -PORT="${PORT:-3333}" -MODE="${1:-dev}" - -if [ ! -d node_modules ]; then - echo "→ installing dependencies" - npm install -fi - -# The database, before the app that needs it. -# -# Every screen reads through Postgres, so without it the app starts, serves a -# blank shell and 500s in a log nobody has open. Bringing it up here — or -# saying plainly that it cannot be — is the difference between a fix and ten -# minutes wondering what broke. -if ! docker compose ps --status running 2>/dev/null | grep -q contentos-postgres; then - echo "→ starting Postgres" - if ! docker compose up -d --wait 2>/dev/null; then - echo - echo " Postgres could not be started." - echo " Start Docker Desktop, then run: npm run db:up" - echo - echo " Content OS will run without it, but every screen will be empty." - echo - fi -fi - -# Whisper, if this machine has not got it. -# -# No tools are installed here any more. -# -# This used to pip-install openai-whisper, because whisper was the one thing -# standing between a source video and a transcript AND it ran in this process. -# Neither half is true now: transcription is a job, it runs on a worker's -# machine, and the server never needs whisper at all. Installing a gigabyte of -# torch to start a web app would be a gigabyte spent on nothing. -# -# The tools a WORKER needs — yt-dlp, ffmpeg, whisper.cpp and a model — come -# from `npm run worker:setup`, pinned and checksummed, into .data/tools. That -# is a different machine's concern even when it happens to be this one, and it -# is the only route somebody who is not a developer has. -# -# This script does NOT start a worker either. Nothing writes a section until -# one is running — see docs/DEVELOPING.md. - -# The API is its own process now, on API_PORT (4000). It is started here so -# that one command still brings the whole thing up; stopping this script -# stops both. -API_PORT="${API_PORT:-4000}" -if [ ! -d api/node_modules ]; then - echo "→ installing the API's dependencies" - (cd api && npm install) -fi - -case "$MODE" in - dev) - echo "→ Content OS API (dev) on http://localhost:$API_PORT/api" - (cd api && PORT="$API_PORT" WEB_ORIGIN="http://localhost:$PORT" npm run dev) & - API_PID=$! - trap 'kill $API_PID 2>/dev/null' EXIT - echo "→ Content OS (dev) on http://localhost:$PORT" - NEXT_PUBLIC_API_URL="http://localhost:$API_PORT" npm run dev -- --port "$PORT" - ;; - prod | start) - echo "→ building the API" - (cd api && npm run build) - echo "→ building Content OS" - NEXT_PUBLIC_API_URL="http://localhost:$API_PORT" npm run build - echo "→ Content OS API on http://localhost:$API_PORT/api" - (cd api && PORT="$API_PORT" WEB_ORIGIN="http://localhost:$PORT" CONTENTOS_DATA_DIR=../.data npm run start) & - API_PID=$! - trap 'kill $API_PID 2>/dev/null' EXIT - echo "→ Content OS (production) on http://localhost:$PORT" - npm run start -- --port "$PORT" - ;; - *) - echo "usage: ./start.sh [dev|prod] (PORT overrides the default 3333)" >&2 - exit 1 - ;; -esac +#!/usr/bin/env bash +# Start Content OS on port 3333. +# +# ./start.sh dev server with hot reload +# ./start.sh prod production build, then serve it +# PORT=4000 ./start.sh use a different port + +set -euo pipefail + +cd "$(dirname "$0")" + +PORT="${PORT:-3333}" +MODE="${1:-dev}" + +if [ ! -d node_modules ]; then + echo "→ installing dependencies" + npm install +fi + +# The database, before the app that needs it. +# +# Every screen reads through Postgres, so without it the app starts, serves a +# blank shell and 500s in a log nobody has open. Bringing it up here — or +# saying plainly that it cannot be — is the difference between a fix and ten +# minutes wondering what broke. +if ! docker compose ps --status running 2>/dev/null | grep -q contentos-postgres; then + echo "→ starting Postgres" + if ! docker compose up -d --wait 2>/dev/null; then + echo + echo " Postgres could not be started." + echo " Start Docker Desktop, then run: npm run db:up" + echo + echo " Content OS will run without it, but every screen will be empty." + echo + fi +fi + +# No tools are installed here. +# +# This used to pip-install openai-whisper, because whisper was the one thing +# standing between a source video and a transcript AND it ran in this process. +# Neither half is true now: transcription is a job, it runs on a worker's +# machine, and the server never needs whisper at all. Installing a gigabyte of +# torch to start a web app would be a gigabyte spent on nothing. +# +# The tools a WORKER needs — yt-dlp, ffmpeg, whisper.cpp and a model — come +# from `npm run worker:setup`, pinned and checksummed, into .data/tools. + +# Anything this machine has been told, the same way every other script here +# learns it. The worker's token is the only value start.sh needs that it +# cannot work out for itself. +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi + +# The API is its own process now, on API_PORT (4000). It is started here so +# that one command still brings the whole thing up; stopping this script +# stops both. +API_PORT="${API_PORT:-4000}" + +# The worker, which is the thing that actually writes. +# +# Without one, pressing Run queues a job and nothing ever claims it. The page +# says so — it names the machine it is waiting for — but a dev server that +# cannot write a section is not a dev server anybody can work against, so it +# starts here with the rest. +# +# It needs a token, and a token cannot be invented: it is minted once in +# Settings → Machines and only its sha256 is kept, so nothing here can look it +# up. Put it in .env as CONTENTOS_WORKER_TOKEN. Without one this says how to +# get one and carries on, because a missing worker must not stop the console +# coming up — signing in is where you go to fix it. +# +# WORKER=0 ./start.sh skips it, for working on a screen that has no runs in it. +start_worker() { + if [ "${WORKER:-1}" = "0" ]; then + return + fi + if [ -z "${CONTENTOS_WORKER_TOKEN:-}" ]; then + echo "→ no worker: set CONTENTOS_WORKER_TOKEN in .env (Settings → Machines mints one)" + echo " Runs will queue and wait. Everything else works." + return + fi + echo "→ worker on this machine, claiming from http://localhost:$API_PORT" + # No name is passed: the machine is named in Settings -> Machines when its + # token is minted, and the worker is not allowed to rename it. + CONTENTOS_API_URL="http://localhost:$API_PORT" npm run worker & + WORKER_PID=$! +} + +# Everything this script started dies with it. A worker left behind would keep +# claiming jobs against a console that is no longer there, and the section it +# was three minutes into would be written by a process nobody can see. +stop_all() { + kill ${API_PID:-} ${WORKER_PID:-} 2>/dev/null || true +} + +if [ ! -d api/node_modules ]; then + echo "→ installing the API's dependencies" + (cd api && npm install) +fi + +case "$MODE" in + dev) + echo "→ Content OS API (dev) on http://localhost:$API_PORT/api" + (cd api && PORT="$API_PORT" WEB_ORIGIN="http://localhost:$PORT" npm run dev) & + API_PID=$! + trap stop_all EXIT + start_worker + echo "→ Content OS (dev) on http://localhost:$PORT" + NEXT_PUBLIC_API_URL="http://localhost:$API_PORT" npm run dev -- --port "$PORT" + ;; + prod | start) + echo "→ building the API" + (cd api && npm run build) + echo "→ building Content OS" + NEXT_PUBLIC_API_URL="http://localhost:$API_PORT" npm run build + echo "→ Content OS API on http://localhost:$API_PORT/api" + (cd api && PORT="$API_PORT" WEB_ORIGIN="http://localhost:$PORT" CONTENTOS_DATA_DIR=../.data npm run start) & + API_PID=$! + trap stop_all EXIT + start_worker + echo "→ Content OS (production) on http://localhost:$PORT" + npm run start -- --port "$PORT" + ;; + *) + echo "usage: ./start.sh [dev|prod] (PORT overrides the default 3333)" >&2 + exit 1 + ;; +esac diff --git a/tests/queue.test.ts b/tests/queue.test.ts index eb1bba1..cb25f9f 100644 --- a/tests/queue.test.ts +++ b/tests/queue.test.ts @@ -208,6 +208,15 @@ describe.skipIf(!up)("the queue", () => { }); expect((await getWorker(first.id))!.enabled).toEqual(["claude"]); + // The machine may not rename itself either. The name is what a person + // reads in the picker when choosing where a run happens, and it is typed + // in Settings -> Machines; a check-in that carried a hostname replaced it. + await registerWorker({ + userId: uid, name: "shifa-hostname", token: tok, + tools: [tool("claude"), tool("ffmpeg")] as never, + }); + expect((await getWorker(first.id))!.name).toBe("re-reg"); + // Now whisper is installed on that machine. It is new, so it arrives on — // and ffmpeg is still off, because that was a decision. await registerWorker({ From eb437a1b27e9f0d4ccddca7d427355303e8dec53 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sun, 6 Sep 2026 11:43:35 +0600 Subject: [PATCH 19/30] Bake both addresses in, so nobody types a hostname to install this MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/DEPLOYING.md | 25 ++ docs/WORKER.md | 200 ++++++++++++-- package.json | 4 +- src-tauri/build.rs | 21 ++ src-tauri/scripts/dev.mjs | 76 +++++ src-tauri/scripts/installer.mjs | 207 ++++++++++++++ src-tauri/src/main.rs | 64 ++++- src-tauri/src/settings.rs | 475 +++++++++++++++++++++++++++++++- src-tauri/src/worker.rs | 113 +++++++- src-tauri/ui/setup.html | 213 +++++++++++++- 10 files changed, 1329 insertions(+), 69 deletions(-) create mode 100644 src-tauri/scripts/dev.mjs create mode 100644 src-tauri/scripts/installer.mjs diff --git a/docs/DEPLOYING.md b/docs/DEPLOYING.md index 6996c05..24ebcea 100644 --- a/docs/DEPLOYING.md +++ b/docs/DEPLOYING.md @@ -104,6 +104,31 @@ One machine is enough to start. A second is how a run gets faster: sections in the same dependency wave go to different computers, which is why `max_concurrency` defaults to 1 — one machine is one CLI login. +### The desktop app, for people who do not use a terminal + +The command above is the developer's way in. Everyone else gets an installer, +and the two addresses on this page are **baked into it at build time** so that +nobody is asked to type a hostname: + +```bash +CONTENTOS_CONSOLE_URL=https://APP_HOST \ +CONTENTOS_API_URL=https://API_HOST \ + npm run desktop:installer +``` + +Both, and neither derived from the other. `api.` in front of the web host is +this repo's compose convention rather than a rule — this page says plainly the +two may live anywhere — and a guess that is usually right fails on somebody +else's DNS long after whoever guessed has stopped looking. + +Build it without them and it still works, but every teammate is asked for the +addresses at install time, which is the thing this removes. The script says so, +loudly, before and after the build. + +What a teammate then does: run the installer, click through SmartScreen's +warning (it is unsigned — see `docs/DECISIONS.md`), and paste the one token +minted for their machine. Nothing else. + Then, from your laptop, ask the API the questions that matter: ```bash diff --git a/docs/WORKER.md b/docs/WORKER.md index 24b990b..2a53274 100644 --- a/docs/WORKER.md +++ b/docs/WORKER.md @@ -767,7 +767,7 @@ npm run worker | Variable | | |---|---| -| `CONTENTOS_API_URL` | **Required.** The console this machine works for. | +| `CONTENTOS_API_URL` | **Required.** The **API** this machine works for — NestJS, on its own hostname (`API_HOST` in `docs/DEPLOYING.md`, `:4000` in a checkout). Not the address of the console you open in a browser; there is no `/api` under Next, so that one 404s everything. | | `CONTENTOS_WORKER_TOKEN` | **Required.** Minted in Settings → Machines and shown once. | | `CONTENTOS_WORKER_NAME` | What the machine picker calls it. Defaults to the hostname; "Shakhawat's desktop" reads better than `DESKTOP-7F2K1`. | | `CONTENTOS_WORKER_ALLOW_FRAME_READ` | `1` to let Claude's CLI be granted `Read` for frame files **on this machine**. Off by default, and checked in addition to `workers.can_read_frames` — the grant lands on this filesystem, so whoever owns it gets the last word. | @@ -995,8 +995,9 @@ with music and no speech genuinely has no words in it. Built, and small on purpose. It lives in `src-tauri/` — Tauri 2, Windows first — and it is two things in one process: **a window onto the console the team already runs**, and **the worker running beside it**. A teammate who is -not a developer installs one app, gives it two answers, and their machine -starts taking jobs. Nobody opens a terminal. +not a developer installs one app, pastes in the token for their machine, and it +starts taking jobs. Nobody opens a terminal, and nobody is asked where the +console is — the installer was built knowing. **What it is not**, and this is the part worth defending: @@ -1004,10 +1005,12 @@ starts taking jobs. Nobody opens a terminal. `src-tauri`, and there must not be. The console is served by the server, so a template change or a fix reaches every teammate the moment it is deployed instead of needing fifteen desktops updated. The one screen this app draws - itself is `src-tauri/ui/setup.html`, and it exists only because the console - address and this machine's token have to be given *before* there is a console - to ask, and because a worker that cannot start needs somewhere to say so that - is not a console window. + itself is `src-tauri/ui/setup.html`, and it exists only because this + machine's token has to be given *before* there is a console to ask — as do + the console and API addresses, on the builds that were not compiled with them + — and + because a worker that cannot start needs somewhere to say so that is not a + console window. - **Not a second identity system.** The webview signs in as a person, with the console's own cookie, exactly as a browser does. The worker holds the machine token. Two credentials, for the reasons in "The desktop app holds two @@ -1017,23 +1020,104 @@ starts taking jobs. Nobody opens a terminal. If the two ever drift, this app is running a worker nobody can reproduce from a terminal, which is the one thing that would make a bug report useless. -### Where the two settings live +### Where the settings live | | | |---|---| -| Console address | `%APPDATA%\com.contentos.desktop\settings.json`. Not a secret — it is the URL a browser would show — and a person debugging their own machine should be able to read it. | +| Console address | Compiled in, or in the environment, or in `%APPDATA%\com.contentos.desktop\settings.json` — three places in a fixed order, below. Not a secret in any of them: it is the URL a browser would show, and a person debugging their own machine has to be able to read it. | +| API address | The same three places, the same order, its own value. It is what the worker calls; the console address is not. Why they are two, and why neither is guessed from the other, is the next section. | | Worker token | The **OS credential store**: Windows Credential Manager, under the target `worker-token.com.contentos.desktop`. The console keeps only its sha256 so that a database dump is not a list of live credentials; writing the plaintext into `%APPDATA%` would undo that at the other end, where anything running as that user can read it and the first backup of the profile carries it off the machine. | | Session cookie | The webview's own data directory, which is WebView2's business and not this app's. | -The token is passed to the worker as `CONTENTOS_WORKER_TOKEN` in its -environment and nowhere else — never a file next to the binary, never an -argument, which is why `worker/config.ts` reads it from the environment in the -first place. +### Where the two addresses come from + +**There are two, and the app is told both.** + +| | What it is | Production (`docs/DEPLOYING.md`) | A checkout | +|---|---|---|---| +| `CONTENTOS_CONSOLE_URL` | The console — the pages the app opens in its webview. Next. | `https://content.yourcompany.com` (`APP_HOST`) | `http://localhost:3333` | +| `CONTENTOS_API_URL` | The API — what the worker calls for jobs, and the only one of the two it ever touches. NestJS. | `https://api.content.yourcompany.com` (`API_HOST`) | `http://localhost:4000` | + +**Why two.** `docs/DEPLOYING.md` puts the web app on `APP_HOST` and the API on +`API_HOST`, on two hostnames under one `COOKIE_DOMAIN`, and the browser calls +the API cross-origin — which is why `lib/api-base.ts` reads +`NEXT_PUBLIC_API_URL` and why `docker-compose.prod.yml` has to pass it as a +*build* arg. There is no `/api` under Next. An app that resolved one address +and handed the worker the console's origin would 404 every request that worker +made on any real deployment, while the window beside it worked perfectly — the +hardest kind of failure to report, because everything the person can see is +fine. + +**Why neither is derived from the other.** Putting `api.` in front of the +console's host is this repository's compose convention, not a rule; +`docs/DEPLOYING.md` says the two may be anywhere. A guess that is usually right +is worse than a value that is always stated, because it does not fail for the +person who made it — it fails months later, on somebody else's DNS, as a +teammate whose machine will not connect. + +**Nobody installing this app should have to type either.** The team has one +console and one API, whoever built the installer already knew both, and free +text in a setup window is a typo that ends as a support question weeks later on +somebody else's laptop. So both are compiled in, and the fields are gone from +the screen unless there is genuinely nothing else to go on. + +Three places each, highest first, and the first one with a usable answer wins. +`pick` in `src-tauri/src/settings.rs` is the whole rule, in **one** function +that takes which address it is resolving — the two differ in what they name, +not in how they are found — with its candidates passed in so it can be tested +without a window system. + +| | Where | Why it sits here | +|---|---|---| +| 1 | The variable in the **running app's** environment | Development. `npm run desktop` sets both, so a checkout works with no setup at all. It beats the two below on purpose: a debug build compiled against the team's servers, or one with a `settings.json` left from testing an installer, would otherwise point a debug worker at production and let it claim real jobs. | +| 2 | **Saved on this machine** — `consoleUrl` and `apiUrl` in `%APPDATA%\com.contentos.desktop\settings.json`, written by the setup window | The escape hatch: one machine pointed at a second team's servers, or at staging. It beats the compiled-in value so that it survives reinstalling the same installer — if it did not, moving one machine would mean building a second installer for it. Saving one address reads the file and puts the other back, so a machine cannot end up holding half a pair. | +| 3 | The variable at **compile time**, read with `option_env!` | What a production installer ships with, and what nearly everybody actually runs. The floor, not the ceiling. | + +Every candidate goes through the same `normalise` the setup window's fields do, +and one that is not a usable address is skipped rather than accepted — so an +`apiUrl: ""` in a `settings.json` written before there was one does not shadow +the address the installer was built with. + +**What the setup window shows.** Both addresses, one under the other, each with +its own provenance line — because "my machine will not connect" is answered by +knowing which server the worker was calling and who told it to call that one, +and the console can never answer it: the window the person is looking at is +proof that *that* address is fine. + +| The address came from | The row | +|---|---| +| The build | The address as text, under *Built in when this copy of Content OS was made*. | +| This machine | The address as text, under *Set on this machine*. If the copy was also built with this address and it differs, a line naming the built-in one and a *Go back to it* link — otherwise one typo in an override would hide the correct address with no way back but deleting the file. | +| The environment | The address as text, under *From CONTENTOS\_CONSOLE\_URL* (or *CONTENTOS\_API\_URL*) *in this window's environment — a development setting, which wins over everything below*. It names the variable it means, because that line exists to say which one has to go before anything saved here takes effect. | +| Nowhere | The row is not drawn at all. A heading over an empty line says "broken" without saying what to do; the field below, already open, says it. | + +*Use different addresses on this machine* is a disclosure under the pair: +closed and out of the way when both are known, and it writes to place 2. +Leaving a field blank means "leave that one alone" — nothing ever pre-fills +them, because a Save that also wrote a copy of the built-in addresses into +`settings.json` would pin the machine to today's answer forever. Both fields +are validated before either is written, so a typo in the second does not leave +the machine holding a saved first one. + +Two edges are drawn on purpose. When **neither** address is known the +disclosure stops being one — no summary, just the fields, which is the screen +as it was before any of this. When **one** is known and the other is not — an +installer built before this app knew there were two, or built by somebody who +set only the first — it keeps its summary but opens itself, and the worker says +*This copy knows the console but not the API*, so nobody goes to check the +address that was already right. + +The worker is handed exactly two things: `CONTENTOS_WORKER_TOKEN` and +`CONTENTOS_API_URL`, in its environment — the same two variables the +`npm run worker` line in `docs/DEPLOYING.md` sets by hand, which is the point. +Never the console's address, which it has no use for. And never a file next to +the binary and never an argument, which is why `worker/config.ts` reads them +from the environment in the first place. ### Running it in development ``` -npm run desktop # cargo run --manifest-path src-tauri/Cargo.toml +npm run desktop # src-tauri/scripts/dev.mjs, then cargo run npm run desktop:build # a release binary ``` @@ -1042,8 +1126,30 @@ It needs the Rust MSVC toolchain and the WebView2 runtime, which every Windows a devDependency and is needed only to produce an installer, which is the next section. -First run has no console address, so it opens the setup window. After that it -opens the console and starts the worker. +`npm run desktop` is a launcher rather than a line in package.json because npm +runs scripts through `sh` on a Mac and `cmd.exe` on Windows, and `VAR=x cargo +run` parses in only one of them. It sets two variables, and anything already +exported wins: + +| | | +|---|---| +| `CONTENTOS_CONSOLE_URL` | `http://localhost:3333` — the console, which is Next, on the port `start.sh` uses. The window opens this. | +| `CONTENTOS_API_URL` | `http://localhost:4000` — the API, which is NestJS on its own origin (`lib/api-base.ts`). The worker calls this. | + +It prints both on every start with what each is for beside it, because the two +lines look nearly identical and exporting one and not the other gives you a +hybrid — a window on staging beside a worker on localhost. + +It is deliberately **not** an `[env]` block in `src-tauri/.cargo/config.toml`, +which would have looked tidier. That applies to every cargo invocation under +`src-tauri`, including the one inside `tauri build` — and since both addresses +are read at compile time with `option_env!`, development addresses would have +been compiled into production installers, silently, which is the exact failure +this is all here to prevent. + +First run opens the setup window when it is missing the token or has either +address from nowhere; on a build that carries both, that is the token and +nothing else. After that it opens the console and starts the worker. **A checkout runs the checkout's worker.** The app looks for `worker/index.ts` in its own resource directory first — where an installed copy keeps it — and @@ -1054,7 +1160,7 @@ against the same files. `CONTENTOS_WORKER_DIR` names the folder when it is somewhere else. The status is in the tray menu and in the setup window, in these words: -*Connected*, *Cannot reach the console*, *This app's copy of Node is missing*, +*Connected*, *Cannot reach the server*, *This app's copy of Node is missing*, *Node is too old*, *This machine's token was refused*, *The worker's files are missing*, *Not set up yet*, *The worker is not running*. They are separate states because they are separate actions — one is wifi, one is a trip to @@ -1063,6 +1169,12 @@ would hide all of them behind the same shrug. Anything the worker prints that this app does not recognise is still shown verbatim, under "What the worker is saying". +*Cannot reach the server* is the API, not the console — that is the host the +worker calls, and a person reading the tray very often has the console open in +the window behind it, where "cannot reach the console" would be a sentence they +can see is false. The address it is calling is on the setup window above the +message. + Two of those changed meaning when Node moved inside the installer. *This app's copy of Node is missing* used to mean "this computer has no Node", which was a download from nodejs.org; on an installed copy it can only mean the @@ -1073,12 +1185,66 @@ all — the Node inside the installer is pinned well above the 22.18 floor. ### Building the installer +```powershell +# PowerShell. Both, every time — the pair from docs/DEPLOYING.md. +$env:CONTENTOS_CONSOLE_URL = "https://content.yourcompany.com" # APP_HOST +$env:CONTENTOS_API_URL = "https://api.content.yourcompany.com" # API_HOST +npm run desktop:installer ``` + +```bash +export CONTENTOS_CONSOLE_URL=https://content.yourcompany.com +export CONTENTOS_API_URL=https://api.content.yourcompany.com npm run desktop:installer ``` +**Set both addresses first.** They are the only things about the artifact that +cannot be seen by looking at it, and getting one wrong is not felt by the +person building — it is felt by fifteen people individually, later, each of +them typing a URL. `src-tauri/build.rs` emits +`cargo:rerun-if-env-changed` for **each** of them so that changing one actually +rebuilds; without those lines the second installer of the day carries the first +one's addresses and nothing says so. Two lines rather than one because moving +only `API_HOST` is an ordinary thing to do, and that build would otherwise look +fine while carrying the old API. + +Building **without** one — or without either — is a **loud warning, not a +refusal**, naming the variables that are missing and what each is for, printed +before the build and again after it, because `tauri build` puts several hundred +lines in between and a warning nobody scrolls back to was not given. When both +are set they are printed the same way, so the artifact's two addresses are on +screen at both ends of the build. Two reasons the absence is not a hard +failure: + +- An installer with no addresses still works. It falls back to asking, exactly + as this app did before, and that is sometimes the right build — a smoke test, + or a copy for a team whose servers this machine does not know. +- An installer with the **wrong** address is worse than one with none: the + missing one asks, the wrong one confidently sends a machine at a server that + is not its team's and folds away the field that would fix it. A hard failure + is one a hurried person gets past by typing any string that parses, which + manufactures exactly that. + +An address that is set but **malformed** does stop the build, for the same +reason — it can only be a mistake, and it is the wrong-address case. The +message names the variable it means, so nobody goes and corrects the one that +was already right. + +**Check what a build actually carries** rather than trusting that a variable +reached the compiler: + +``` +cargo test --manifest-path src-tauri/Cargo.toml -- --nocapture says_which_addresses +``` + +It prints both `option_env!` values through the same function the app resolves +them with, so a build that prints an address there is a build that ships one. + Two steps, and it is worth knowing which: `src-tauri/scripts/fetch-node.mjs` puts the pinned Node in `src-tauri/binaries/`, then `tauri build` bundles it. +`src-tauri/scripts/installer.mjs` runs both with this repository's own Node and +its own copy of the Tauri CLI, so neither step depends on a shell resolving a +name; extra arguments are passed through to `tauri build`. The fetch is a separate step because a 90 MB runtime is not a thing to keep in a repository — `src-tauri/binaries` is in `.gitignore`, and running the script is how a fresh clone gets one. Repeating it is cheap: a file that is already diff --git a/package.json b/package.json index 6ac1ac8..b5a264a 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,10 @@ "start": "next start", "worker": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --import ./worker/register.mjs worker/index.ts", "worker:setup": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --import ./worker/register.mjs worker/setup.ts", - "desktop": "cargo run --manifest-path src-tauri/Cargo.toml", + "desktop": "node src-tauri/scripts/dev.mjs", "desktop:build": "cargo build --release --manifest-path src-tauri/Cargo.toml", "desktop:node": "node src-tauri/scripts/fetch-node.mjs", - "desktop:installer": "npm run desktop:node && tauri build", + "desktop:installer": "node src-tauri/scripts/installer.mjs", "lint": "eslint" }, "dependencies": { diff --git a/src-tauri/build.rs b/src-tauri/build.rs index ac30c6c..023be86 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,25 @@ fn main() { + /* + * Both addresses are compiled in, so Cargo has to know that changing + * either one invalidates the build. + * + * `option_env!` in src/settings.rs reads the environment of the + * *compiler*, and a rebuild that Cargo thinks is unnecessary is a rebuild + * it does not do: without these lines, exporting a new address and running + * `npm run desktop:installer` again hands out an installer carrying + * yesterday's server, with nothing on screen or in the log to say so. The + * whole point of baking them in is that nobody downstream checks them, + * which is exactly why they must not be able to go stale here. + * + * Two lines rather than one because they are two independent values — + * `docs/DEPLOYING.md` puts the console on APP_HOST and the API on + * API_HOST, and moving only the second is an ordinary thing to do. A + * single line would leave that build carrying the old API and looking + * fine. + */ + println!("cargo:rerun-if-env-changed=CONTENTOS_CONSOLE_URL"); + println!("cargo:rerun-if-env-changed=CONTENTOS_API_URL"); + // Reads tauri.conf.json, resolves capabilities/, and — the part that // actually bites — compiles icons/icon.ico into the Windows executable's // resources. Without that file this build fails outright rather than diff --git a/src-tauri/scripts/dev.mjs b/src-tauri/scripts/dev.mjs new file mode 100644 index 0000000..c542a29 --- /dev/null +++ b/src-tauri/scripts/dev.mjs @@ -0,0 +1,76 @@ +/** + * `npm run desktop` — the app, pointed at the console on this laptop. + * + * A launcher rather than a line in package.json for one dull reason: npm runs + * scripts through `sh` on a Mac and `cmd.exe` on Windows, and `VAR=x cargo + * run` is a parse error in one of them. This repository is Windows-first and + * developed on both, so the variable is set here, where there is only one + * language. + * + * It sets **two** addresses, and they are not interchangeable: the window + * opens the first, the worker calls the second. See `settings.rs` for why the + * app refuses to derive either from the other. + * + * Why this is not `.cargo/config.toml`, which would have been the + * tidier-looking answer: an `[env]` block there applies to every cargo + * invocation under `src-tauri`, including `cargo build --release` and the one + * `tauri build` runs — and `option_env!` in `settings.rs` reads the compiler's + * environment. Development addresses would have been baked into production + * installers, silently, which is the exact failure the whole built-in-address + * change exists to prevent. + * + * Anything already exported wins, so pointing a debug build at a staging + * deployment is `CONTENTOS_CONSOLE_URL=… CONTENTOS_API_URL=… npm run desktop` + * and nothing else. Export one and not the other and you get a hybrid — a + * window on staging beside a worker on localhost — so they are printed on + * every start, with what each is for beside it, rather than being assumed. + */ + +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +/** + * The console, which is Next — port 3333, because that is where `start.sh` + * puts it and what `docs/DEVELOPING.md` tells everybody to assume. + */ +const CONSOLE = "http://localhost:3333"; + +/** + * The API, which is NestJS on its own origin — see `lib/api-base.ts`. The two + * are not the same server in a checkout, and the worker talks to the second + * one, so a development run has to say both or the window works and the + * machine beside it takes no jobs. + */ +const API = "http://localhost:4000"; + +const env = { ...process.env }; +env.CONTENTOS_CONSOLE_URL = (env.CONTENTOS_CONSOLE_URL ?? "").trim() || CONSOLE; +env.CONTENTOS_API_URL = (env.CONTENTOS_API_URL ?? "").trim() || API; + +// Named, not just listed. The two lines look nearly identical, and the whole +// class of mistake here is reading one for the other. +console.log(`CONTENTOS_CONSOLE_URL ${env.CONTENTOS_CONSOLE_URL} the window opens this (Next)`); +console.log(`CONTENTOS_API_URL ${env.CONTENTOS_API_URL} the worker calls this (NestJS)`); +console.log(""); + +const child = spawn( + "cargo", + ["run", "--manifest-path", "src-tauri/Cargo.toml", ...process.argv.slice(2)], + { cwd: root, env, stdio: "inherit" }, +); + +child.on("error", (e) => { + // The one failure worth a sentence: cargo is not a thing this repository can + // install for somebody, and "spawn cargo ENOENT" does not say that. + const why = + e.code === "ENOENT" + ? "cargo is not on PATH. The desktop app needs the Rust MSVC toolchain — rustup.rs, then a new terminal." + : e.message; + console.error(why); + process.exit(1); +}); + +child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 1))); diff --git a/src-tauri/scripts/installer.mjs b/src-tauri/scripts/installer.mjs new file mode 100644 index 0000000..00df24a --- /dev/null +++ b/src-tauri/scripts/installer.mjs @@ -0,0 +1,207 @@ +/** + * `npm run desktop:installer` — the two build steps, and the two questions the + * build must not skip. + * + * The app is compiled with **two** addresses, from `CONTENTOS_CONSOLE_URL` and + * `CONTENTOS_API_URL` (see `built_in` in `src-tauri/src/settings.rs`). They are + * two because the deployment is two: `docs/DEPLOYING.md` puts the web app on + * `APP_HOST` and the NestJS API on `API_HOST`, the browser calls the API + * cross-origin, and there is no `/api` under Next. The console is what the + * window opens; the API is what every worker calls for jobs. Neither is + * derivable from the other — `api.` in front of the console's host is this + * repository's compose convention, not a rule — so both have to be stated. + * + * That is the whole point of baking them in: whoever builds the installer + * already knows both, so none of the fifteen people who install it should have + * to be told, or be able to mistype them. Which means the failure worth + * catching is building an installer that is missing one — it works, so nothing + * downstream complains, and the cost lands on every teammate individually, + * weeks later, as a support question. An installer with a console and no API + * is the *quietest* version of it: the window opens, the person sees their + * team's Content OS, and only the worker is dead. + * + * **A warning, not a refusal**, and the reasoning is worth keeping: + * + * - An installer with no addresses is a real, working artifact. It falls back + * to asking, exactly as this app did before any of this existed, so refusing + * to produce one would take away a build that is sometimes the right one — a + * smoke test, or a copy handed to a second team whose servers this machine + * does not know. + * - An installer with the WRONG address is strictly worse than one with none. + * The missing one asks; the wrong one confidently sends a machine at a + * server that is not its team's and hides the field that would fix it. A + * hard failure here is a hard failure a hurried person gets past by typing + * any string that parses, which manufactures exactly that. + * + * So it says so loudly, twice — before the build and again after it, because + * `tauri build` prints several hundred lines in between and a warning nobody + * scrolls back to is a warning that was not given. + * + * A malformed address IS a hard failure, for the same reason: it can only have + * been a mistake, and baking it in produces the wrong-address case above. + * + * npm run desktop:installer # extra arguments are passed to `tauri build` + */ + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +const RULE = "─".repeat(72); + +/** + * The pair, in the order everything here prints them. One list rather than two + * variables threaded through, so that a build cannot end up checking one + * address and baking in two. + */ +const ADDRESSES = [ + { + name: "CONTENTOS_CONSOLE_URL", + what: "the console — the pages the app opens", + example: "https://contentos.yourteam.com", + }, + { + name: "CONTENTOS_API_URL", + what: "the API — the server every worker calls for jobs", + example: "https://api.contentos.yourteam.com", + }, +]; + +/** The width of the longest variable name, so the two columns line up. */ +const COLUMN = Math.max(...ADDRESSES.map((a) => a.name.length)); + +/** + * The same forgiveness `normalise` in `src-tauri/src/settings.rs` extends, and + * the same refusals — a host with no scheme becomes https, a trailing slash is + * dropped, and anything that is not a web address stops the build. Kept in + * step by hand, and small enough to stay that way; what it must not do is + * accept something the app would then reject. + * + * One function for both, named by whichever it was given, because a message + * that said `CONTENTOS_CONSOLE_URL` about the API value would send somebody to + * correct a variable that was already right. + */ +function address(spec, raw) { + const typed = raw.trim(); + if (!typed) return ""; + + const withScheme = typed.includes("://") ? typed : `https://${typed}`; + let url; + try { + url = new URL(withScheme); + } catch { + throw new Error( + `${spec.name} is "${typed}", which is not a web address. It should look like ${spec.example}.`, + ); + } + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error( + `${spec.name} is "${typed}", which has to start with https:// (or http:// on your own network).`, + ); + } + if (!url.hostname) { + throw new Error(`${spec.name} is "${typed}", which has no server name in it.`); + } + return withScheme.replace(/\/+$/, ""); +} + +/** + * What this installer will carry, and what it will not. + * + * Both lists every time, and both printed before and after the build. The + * addresses are the one thing about the artifact that cannot be seen by + * looking at it, and an installer built with one of the two is a build that + * looks entirely successful. + */ +function announce(set, missing, tense) { + if (set.length) { + console.log(`\n${tense} an installer for:\n`); + for (const [spec, url] of set) { + console.log(` ${spec.name.padEnd(COLUMN)} ${url}`); + } + console.log(""); + } + if (missing.length) warn(missing); +} + +function warn(missing) { + const lines = missing.map((spec) => ` ${spec.name.padEnd(COLUMN)} ${spec.what}`).join("\n"); + const it = missing.length === ADDRESSES.length ? "them" : "it"; + console.log(` +${RULE} + This installer is being built with no value for: + +${lines} + + Everyone who installs it will be asked to type ${it} in before their + machine can do anything — which is the typo, the support question, + and the piece of knowledge that building ${it} in exists to remove. + + The two are separate on purpose and neither can be guessed from the + other: docs/DEPLOYING.md puts the web app on APP_HOST and the NestJS + API on API_HOST, and says they may be anywhere. Set both: + + PowerShell $env:CONTENTOS_CONSOLE_URL = "https://contentos.yourteam.com" + $env:CONTENTOS_API_URL = "https://api.contentos.yourteam.com" + npm run desktop:installer + + bash export CONTENTOS_CONSOLE_URL=https://contentos.yourteam.com + export CONTENTOS_API_URL=https://api.contentos.yourteam.com + npm run desktop:installer + + Building anyway — an installer that asks still works. +${RULE} +`); +} + +function run(what, args) { + const done = spawnSync(what, args, { cwd: root, stdio: "inherit", env: process.env }); + if (done.error) throw done.error; + if (done.status !== 0) process.exit(done.status ?? 1); +} + +function main() { + const set = []; + const missing = []; + for (const spec of ADDRESSES) { + const url = address(spec, process.env[spec.name] ?? ""); + if (url) { + // Put back tidied, so what is compiled in is exactly what was checked + // and printed rather than something a slash apart from it. + process.env[spec.name] = url; + set.push([spec, url]); + } else { + missing.push(spec); + } + } + + announce(set, missing, "Building"); + + // The pinned Node the app carries, then the bundle. Both are run with this + // process's Node and this repository's own CLI rather than through `npm run` + // and `npx`, so that neither step depends on a shell resolving a name. + run(process.execPath, [path.join(root, "src-tauri", "scripts", "fetch-node.mjs")]); + + const tauri = path.join(root, "node_modules", "@tauri-apps", "cli", "tauri.js"); + if (!existsSync(tauri)) { + throw new Error( + "The Tauri CLI is not installed. It is a devDependency of this repository — run `npm install`.", + ); + } + run(process.execPath, [tauri, "build", ...process.argv.slice(2)]); + + // Said again, because several hundred lines of build output have gone past + // since the first time and this is the one thing about the artifact that + // cannot be seen by looking at it. + announce(set, missing, "Built"); +} + +try { + main(); +} catch (e) { + console.error(`\nCould not build the installer: ${e.message ?? e}\n`); + process.exit(1); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3a51115..d201d27 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -13,8 +13,8 @@ //! //! What it deliberately is not: a second frontend. `docs/WORKER.md` settles //! that the server keeps the UI and every decision, and this app keeps only -//! what cannot live there — where the console is, this machine's token, and -//! whether the worker beside it is actually running. +//! what cannot live there — where the console and the API are, this machine's +//! token, and whether the worker beside it is actually running. mod settings; mod tray; @@ -31,21 +31,55 @@ fn status(app: AppHandle, worker: State<'_, Worker>) -> View { worker.view(&app) } -/// Take the two answers, keep them, and start. +/// Take what the person filled in, keep it, and start. /// -/// A blank token means "leave the one you have". The console shows a worker -/// token exactly once, so a person who came back to correct a typo in the -/// address will not have it any more, and demanding it again would send them -/// to Settings → Machines to mint a new one for no reason. +/// Blank means "leave it as it is", for all three fields and for two different +/// reasons. +/// +/// The console shows a worker token exactly once, so a person who came back to +/// change something else will not have it any more, and demanding it again +/// would send them to Settings → Machines to mint a new one for no reason. +/// +/// The two addresses are blank far more often, because most people never see +/// those fields: they are behind a disclosure, and the app already knows both +/// from the build or from the environment. Writing an empty string over that +/// would turn "I did not touch it" into "point this machine at nothing". #[tauri::command] fn save( app: AppHandle, worker: State<'_, Worker>, console_url: String, + api_url: String, token: String, ) -> Result { - let url = settings::normalise(&console_url)?; - settings::set_console_url(&app, &url)?; + /* + * The same rule twice, over the pair, rather than once per address written + * out: they are saved by the same code so that a change to what "blank + * means" cannot land on one of them and not the other. + * + * Each is saved only when there is something to save, or when there is + * nothing to fall back on — and the second case is deliberately routed + * through `normalise` too, because the sentence explaining what that + * address is already lives there and should not be written twice. + * + * Both are checked before either is written, so a typo in the second field + * does not leave the machine holding a saved first one and an error + * message; the person fixes what they typed and Saves the pair they meant. + */ + let fields = [ + (settings::Which::Console, console_url), + (settings::Which::Api, api_url), + ]; + let mut to_save = Vec::new(); + for (which, raw) in &fields { + let raw = raw.trim(); + if !raw.is_empty() || settings::address(&app, *which).url.is_empty() { + to_save.push((*which, settings::normalise(*which, raw)?)); + } + } + for (which, url) in to_save { + settings::set_url(&app, which, &url)?; + } let typed = token.trim(); if !typed.is_empty() { @@ -95,10 +129,14 @@ fn main() { let app = app.handle().clone(); tray::install(&app)?; - // First run has nothing to load, so it gets the only screen this - // app owns. Every run after that opens the console itself, which - // is what somebody double-clicked the icon for. - if settings::console_url(&app).is_empty() || settings::token().is_empty() { + // First run has something missing, so it gets the only screen this + // app owns. On an installer built with both addresses that is the + // token and nothing else. Every run after that opens the console + // itself, which is what somebody double-clicked the icon for. + if settings::console_url(&app).is_empty() + || settings::api_url(&app).is_empty() + || settings::token().is_empty() + { windows::setup(&app); } else { windows::console(&app); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 15de23b..a4fd794 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,4 +1,4 @@ -//! The two things this app is told, kept in two different places on purpose. +//! The things this app is told, kept apart on purpose. //! //! `docs/WORKER.md` — "The desktop app holds two credentials, on purpose" — //! settles the shape: the webview signs in as a *person*, with the console's @@ -11,15 +11,37 @@ //! So this file owns exactly one of the two. The cookie is the webview's //! business and is never touched here. What lives here is: //! -//! - **the console address**, in a JSON file under the app's config directory. -//! It is not a secret — it is the URL a browser would show — and it has to be -//! readable by a person debugging their own machine. +//! - **the two addresses** — the console this app opens a window onto, and the +//! API the worker calls for jobs. Each comes from the build, from the +//! runtime environment, or from a JSON file under the app's config +//! directory; `pick` below is the order and the reasoning, and both go +//! through it. Neither is a secret — they are the URLs a browser would show +//! — and both have to be readable by a person debugging their own machine. //! - **the worker token**, in the operating system's credential store, never //! in that file. The console keeps only its sha256 precisely so a database //! dump is not a list of live credentials; writing the plaintext into //! `%APPDATA%` would undo that at the other end, where it is trivially //! readable by anything running as this user and would end up in the first //! backup somebody takes of their profile. +//! +//! **Why the addresses are two and not one.** `docs/DEPLOYING.md` puts the web +//! app on `APP_HOST` and the NestJS API on `API_HOST` — two hostnames, and the +//! browser calls the API cross-origin, which is why `lib/api-base.ts` reads +//! `NEXT_PUBLIC_API_URL` and why `docker-compose.prod.yml` has to pass it as a +//! *build* arg. There is no `/api` under Next to fall back on. An app that +//! resolved one address and handed the worker the console's origin would +//! therefore 404 every request that worker made on any real deployment; and a +//! checkout is no kinder, because there the console is Next on :3333 and the +//! API is NestJS on :4000. +//! +//! **And why neither is derived from the other.** Putting `api.` in front of +//! the console's host is this repository's compose convention, not a rule: +//! `docs/DEPLOYING.md` is explicit that the two may be anywhere, needing only +//! a shared `COOKIE_DOMAIN` between them. A guess that is usually right is +//! worse here than a value that is always stated, because it does not fail for +//! the person who made it — it fails months later, on somebody else's DNS, as +//! a teammate whose machine will not connect and who has nothing on their +//! screen that would explain why. use std::fs; use std::path::PathBuf; @@ -36,10 +58,73 @@ const SERVICE: &str = "com.contentos.desktop"; /// nothing here to key by a user name. const ACCOUNT: &str = "worker-token"; +/// Which of the two addresses is being talked about. +/// +/// They are found by the same `pick`, saved in the same file, and drawn by the +/// same code on the same screen, so almost everything about them is shared. +/// What differs is the name of the variable that carries one, the sentence +/// that says what it is for, and the example that shows its shape — and those +/// three live here, in one place, so that the rule for finding them can stay a +/// single function rather than becoming two that drift. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Which { + Console, + Api, +} + +impl Which { + /// The environment variable that carries it — deliberately the same name + /// at runtime and at compile time, and the same name the worker itself + /// reads in `worker/config.ts`, so that somebody who has learned it in one + /// place has learned it everywhere. + pub fn var(self) -> &'static str { + match self { + Which::Console => "CONTENTOS_CONSOLE_URL", + Which::Api => "CONTENTOS_API_URL", + } + } + + /// The shape of the answer, for the messages that have to show one. + fn example(self) -> &'static str { + match self { + Which::Console => "https://contentos.yourteam.com", + Which::Api => "https://api.contentos.yourteam.com", + } + } + + /// What to say to somebody who was asked for this address and left it + /// blank. + /// + /// The API's sentence is longer than the console's, and has to be: the + /// console is the thing they already open in a browser, whereas nobody has + /// a reason to know the worker calls a *second* host until they are told, + /// and "the API address" on its own would send them off to guess one. + fn missing(self) -> &'static str { + match self { + Which::Console => { + "The console address is where your team's Content OS is, for example \ + https://contentos.yourteam.com." + } + Which::Api => { + "The API address is the server this machine's worker asks for jobs. It is a \ + different host from the console — https://api.contentos.yourteam.com beside \ + https://contentos.yourteam.com on a server, http://localhost:4000 beside \ + http://localhost:3333 in a checkout. Whoever set up the server knows it." + } + } + } +} + +/// The file's shape. +/// +/// `#[serde(default)]` is what lets a `settings.json` written before there was +/// an API address in it parse into one that has an empty one, instead of +/// failing to parse and taking the saved console address down with it. #[derive(Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] struct Stored { console_url: String, + api_url: String, } fn file(app: &AppHandle) -> Option { @@ -49,23 +134,216 @@ fn file(app: &AppHandle) -> Option { .map(|d| d.join("settings.json")) } -pub fn console_url(app: &AppHandle) -> String { +/// Which of the three places the address in use came from. +/// +/// Carried all the way to the setup window, because it decides what the person +/// reading it is looking at and therefore what they should do about it. An +/// address that came out of the installer is not theirs to know or to check; +/// one saved here is something somebody did to this machine on purpose; one +/// out of the environment belongs to a checkout and will vanish with the +/// terminal it was exported in. Three different sentences, so three variants +/// rather than "is it set". +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Source { + Development, + Machine, + BuiltIn, + Nothing, +} + +impl Source { + /// What the setup page keys its markup off. + pub fn id(self) -> &'static str { + match self { + Source::Development => "development", + Source::Machine => "this-machine", + Source::BuiltIn => "built-in", + Source::Nothing => "none", + } + } + + /// Where it came from, in the app's own words, shown under the address. + /// + /// Only the development sentence needs to know which of the two it is + /// describing, and it needs to badly: the whole use of that line is to + /// name the variable that has to go before anything saved here will take + /// effect, and naming the wrong one of the two would send somebody hunting + /// a variable that was never the problem. + pub fn words(self, which: Which) -> &'static str { + match (self, which) { + (Source::Development, Which::Console) => { + "From CONTENTOS_CONSOLE_URL in this window's environment — a development setting, \ + which wins over everything below." + } + (Source::Development, Which::Api) => { + "From CONTENTOS_API_URL in this window's environment — a development setting, \ + which wins over everything below." + } + (Source::Machine, _) => "Set on this machine.", + (Source::BuiltIn, _) => "Built in when this copy of Content OS was made.", + (Source::Nothing, _) => "", + } + } +} + +/// One resolved address: which of the two it is, what it is, and where it came +/// from. +pub struct Address { + pub which: Which, + pub url: String, + pub source: Source, +} + +impl Address { + /// The sentence under it on the setup page — written here, where the + /// places are decided, rather than in the page, so that adding a fourth + /// place cannot leave the screen describing three. + pub fn words(&self) -> &'static str { + self.source.words(self.which) + } + + /// What this copy was *built* with, whatever is in use now. The page shows + /// it only to somebody who has overridden this address on their own + /// machine, so that one typo in an override cannot hide the correct + /// address with no way back to it but deleting settings.json. + pub fn built_in(&self) -> &'static str { + built_in(self.which).unwrap_or_default() + } +} + +/// The address this binary was compiled with, if it was compiled with one. +/// +/// `option_env!` bakes in whatever the *build* machine's environment said, so +/// a production installer carries the team's console and the team's API and +/// nobody installing it ever types an address. It is `option_env!` rather than +/// `env!` because a build without one has to keep working: the app then asks, +/// exactly as it always did, which is the only honest thing to do when there +/// is genuinely nothing to fall back on. +/// +/// The match is here rather than at the call sites because `option_env!` takes +/// a literal — there is no reading `which.var()` at compile time — and keeping +/// it in one place is what stops a third address, later, from being added to +/// the resolver and forgotten by the compiler. +/// +/// `build.rs` tells Cargo to rebuild when either variable changes. Without +/// those two lines, changing one and rebuilding gives you yesterday's address +/// in today's installer, and nothing on screen says so. +pub fn built_in(which: Which) -> Option<&'static str> { + match which { + Which::Console => option_env!("CONTENTOS_CONSOLE_URL"), + Which::Api => option_env!("CONTENTOS_API_URL"), + } +} + +/// The whole file, or an empty one. +/// +/// A file that is missing, unreadable or not JSON reads the same as "nothing +/// has been set on this machine": the two places below it are still there to +/// answer, and an app that refused to start over a stray character in +/// `%APPDATA%` would be worse than one that falls back to what it was built +/// with. +fn stored(app: &AppHandle) -> Stored { file(app) .and_then(|p| fs::read_to_string(p).ok()) .and_then(|raw| serde_json::from_str::(&raw).ok()) - .map(|s| s.console_url) .unwrap_or_default() } -pub fn set_console_url(app: &AppHandle, url: &str) -> Result<(), String> { +/// What is saved on this machine for one of the two, if anything is. +fn saved(app: &AppHandle, which: Which) -> String { + let stored = stored(app); + match which { + Which::Console => stored.console_url, + Which::Api => stored.api_url, + } +} + +/// One of the two addresses this app is using, and where it came from. +pub fn address(app: &AppHandle, which: Which) -> Address { + pick( + which, + std::env::var(which.var()).ok().as_deref(), + Some(saved(app, which).as_str()), + built_in(which), + ) +} + +/// The console: the page a person opens, and the only one of the two they have +/// ever seen in a browser. +pub fn console_url(app: &AppHandle) -> String { + address(app, Which::Console).url +} + +/// The API: what the worker calls for jobs. Never the console's — see the note +/// at the top of this file for why that 404s on any real deployment. +pub fn api_url(app: &AppHandle) -> String { + address(app, Which::Api).url +} + +/// The order, with nothing else in it so that it can be read — and tested — +/// on its own. +/// +/// One function for both addresses, deliberately. They differ in what they +/// name, not in how they are found, and a second copy of this rule is a second +/// copy that gets a fix the first one does not. +/// +/// Highest first, and each place beats the one below it for a reason: +/// +/// 1. **The runtime environment.** This is a checkout: somebody exported +/// `CONTENTOS_CONSOLE_URL` and `CONTENTOS_API_URL` and ran `npm run +/// desktop`. It has to beat both of the others, because a developer whose +/// binary was compiled against the team's servers — or who has a +/// `settings.json` left over from testing an installer — would otherwise +/// point a debug worker at production and have it claim real jobs. The +/// variable is the loudest thing in the room, so it wins. +/// 2. **What is saved on this machine.** The escape hatch, and the reason it +/// beats the built-in value: one person on a second team, or on a staging +/// console, sets it once and it survives every reinstall of the same +/// installer. If the built-in value won, the only way to move one machine +/// would be to build a second installer for it. +/// 3. **What was baked in at compile time.** The floor, not the ceiling: it is +/// what a teammate gets when they have done nothing at all, which is the +/// whole point — the person who built the installer already knew both +/// addresses, so nobody installing it should have to. +/// +/// Anything that is not a usable address is skipped rather than accepted, so a +/// blank `apiUrl` in a `settings.json` written before there was one falls +/// through to the built-in value instead of shadowing it with nothing. +fn pick(which: Which, env: Option<&str>, saved: Option<&str>, built_in: Option<&str>) -> Address { + for (raw, source) in [ + (env, Source::Development), + (saved, Source::Machine), + (built_in, Source::BuiltIn), + ] { + if let Some(url) = raw.and_then(|raw| normalise(which, raw).ok()) { + return Address { which, url, source }; + } + } + Address { + which, + url: String::new(), + source: Source::Nothing, + } +} + +/// Save one address on this machine, keeping the other. +/// +/// Read, change one field, write the whole file back. The file is one JSON +/// object, and a write built from a single value would drop the other address +/// silently — which is not hypothetical, because the setup window saves the +/// two one after the other and the second write of a single Save would erase +/// the first. +pub fn set_url(app: &AppHandle, which: Which, url: &str) -> Result<(), String> { let path = file(app).ok_or("This machine has no application data folder to write to.")?; if let Some(dir) = path.parent() { fs::create_dir_all(dir).map_err(|e| format!("Could not create {}: {e}", dir.display()))?; } - let body = serde_json::to_string_pretty(&Stored { - console_url: url.to_string(), - }) - .map_err(|e| e.to_string())?; + let mut stored = stored(app); + match which { + Which::Console => stored.console_url = url.to_string(), + Which::Api => stored.api_url = url.to_string(), + } + let body = serde_json::to_string_pretty(&stored).map_err(|e| e.to_string())?; fs::write(&path, body).map_err(|e| format!("Could not write {}: {e}", path.display())) } @@ -88,7 +366,7 @@ pub fn set_token(value: &str) -> Result<(), String> { .map_err(|e| format!("Could not save the token to this machine's credential store: {e}")) } -/// Turn what somebody typed into the origin the worker will be given. +/// Turn what somebody typed into the origin that will actually be used. /// /// Deliberately forgiving in one direction only. A person pastes /// `contentos.team.com` or a whole page's URL out of their browser bar, and @@ -97,10 +375,14 @@ pub fn set_token(value: &str) -> Result<(), String> { /// becomes `https://` rather than `http://`. Somebody working against a local /// API types the `http://` themselves, which is exactly the moment they should /// have to. -pub fn normalise(raw: &str) -> Result { +/// +/// `which` only ever chooses the words. The rule itself is one rule: the two +/// addresses are checked identically, because a difference between them here +/// would be a difference nobody could predict from the screen. +pub fn normalise(which: Which, raw: &str) -> Result { let typed = raw.trim(); if typed.is_empty() { - return Err("The console address is where your team's Content OS is, for example https://contentos.yourteam.com.".into()); + return Err(which.missing().into()); } let with_scheme = if typed.contains("://") { typed.to_string() @@ -110,7 +392,8 @@ pub fn normalise(raw: &str) -> Result { let url = Url::parse(&with_scheme).map_err(|_| { format!( - "\"{typed}\" is not a web address. It should look like https://contentos.yourteam.com." + "\"{typed}\" is not a web address. It should look like {}.", + which.example() ) })?; if !matches!(url.scheme(), "http" | "https") { @@ -126,3 +409,165 @@ pub fn normalise(raw: &str) -> Result { // slash, so a trailing one here would make every request double it. Ok(with_scheme.trim_end_matches('/').to_string()) } + +/// The order is the whole feature, so it is the thing that is tested. +/// +/// `pick` takes its three candidates as arguments rather than reading them +/// itself precisely so this can exist: a test that had to set process-wide +/// environment variables would race every other test in the binary, and one +/// that had to write a `settings.json` would need an `AppHandle`, which needs +/// a window system. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn development_beats_everything() { + let got = pick( + Which::Console, + Some("http://localhost:3333"), + Some("https://saved.example.com"), + Some("https://built-in.example.com"), + ); + assert_eq!(got.url, "http://localhost:3333"); + assert_eq!(got.source, Source::Development); + } + + #[test] + fn this_machine_beats_the_build() { + let got = pick( + Which::Console, + None, + Some("https://saved.example.com"), + Some("https://built-in.example.com"), + ); + assert_eq!(got.url, "https://saved.example.com"); + assert_eq!(got.source, Source::Machine); + } + + #[test] + fn the_build_is_the_floor() { + let got = pick( + Which::Console, + None, + None, + Some("https://built-in.example.com"), + ); + assert_eq!(got.url, "https://built-in.example.com"); + assert_eq!(got.source, Source::BuiltIn); + } + + /// A `settings.json` written before there was anything else to fall back + /// on holds `""`, and it must not shadow the address the installer was + /// built with. + #[test] + fn nothing_usable_falls_through() { + let got = pick( + Which::Console, + Some(""), + Some(" "), + Some("https://built-in.example.com"), + ); + assert_eq!(got.url, "https://built-in.example.com"); + assert_eq!(got.source, Source::BuiltIn); + } + + #[test] + fn a_build_told_nothing_asks() { + let got = pick(Which::Console, None, None, None); + assert!(got.url.is_empty()); + assert_eq!(got.source, Source::Nothing); + } + + /// Candidates are normalised on the way in, so a `CONTENTOS_CONSOLE_URL` + /// with a trailing slash cannot make every request the worker sends + /// contain a double one. + #[test] + fn candidates_are_tidied_not_taken_literally() { + assert_eq!( + pick(Which::Console, Some("contentos.team.com/"), None, None).url, + "https://contentos.team.com" + ); + } + + /// The API address is found by exactly the same rule as the console's, + /// which is the point of `pick` taking a `which` rather than there being + /// two of it. + #[test] + fn the_api_address_is_found_the_same_way() { + let got = pick( + Which::Api, + None, + Some("https://api.saved.example.com"), + Some("https://api.built-in.example.com"), + ); + assert_eq!(got.url, "https://api.saved.example.com"); + assert_eq!(got.source, Source::Machine); + } + + /// Neither address is ever invented from the other. A build that was told + /// where the console is and nothing else has no API address at all, and + /// has to say so: `api.` in front of the console's host is this + /// repository's compose convention, and `docs/DEPLOYING.md` is explicit + /// that the two hosts may be anywhere. + #[test] + fn one_address_is_never_invented_from_the_other() { + let console = pick( + Which::Console, + None, + None, + Some("https://contentos.team.com"), + ); + let api = pick(Which::Api, None, None, None); + assert_eq!(console.url, "https://contentos.team.com"); + assert!(api.url.is_empty()); + assert_eq!(api.source, Source::Nothing); + } + + /// A blank field says which address it wanted. The API's sentence carries + /// its own explanation, because somebody who has only ever opened the + /// console in a browser has no reason to know there is a second host. + #[test] + fn a_blank_field_says_what_it_was_asking_for() { + let console = normalise(Which::Console, " ").unwrap_err(); + let api = normalise(Which::Api, "").unwrap_err(); + assert!(console.contains("console address")); + assert!(api.contains("API address")); + assert!(api.contains("worker")); + } + + /// The environment variable is named in the development sentence, and it + /// has to be the right one of the two — that line exists to tell somebody + /// which variable to unset. + #[test] + fn the_development_line_names_its_own_variable() { + assert!(Source::Development + .words(Which::Console) + .contains("CONTENTOS_CONSOLE_URL")); + assert!(Source::Development + .words(Which::Api) + .contains("CONTENTOS_API_URL")); + } + + /// Not an assertion — a window onto the build. + /// + /// `cargo test -- --nocapture` prints the addresses this binary was + /// compiled with, which is how somebody checks that the values they + /// exported actually reached the compiler rather than trusting that they + /// did. It is the same `option_env!` the app itself resolves through, so a + /// build that prints an address here is a build that ships one — and it + /// prints both, because an installer carrying one of the two is exactly + /// the half-built artifact worth catching before it is handed out. + #[test] + fn says_which_addresses_this_build_carries() { + for which in [Which::Console, Which::Api] { + match built_in(which) { + Some(url) => println!("built in: {} = {url}", which.var()), + None => println!( + "built in: {} = nothing — this build will ask the person who installs it", + which.var() + ), + } + } + } +} diff --git a/src-tauri/src/worker.rs b/src-tauri/src/worker.rs index af4328b..cdab5e5 100644 --- a/src-tauri/src/worker.rs +++ b/src-tauri/src/worker.rs @@ -103,7 +103,12 @@ impl Health { Health::NotConfigured => "Not set up yet", Health::Starting => "Starting…", Health::Connected => "Connected", - Health::Offline => "Cannot reach the console", + // The API rather than the console, because that is the host the + // worker calls and they are two on any real deployment. A person + // reading this in the tray very often has the console open in the + // window behind it, and "cannot reach the console" would be a + // sentence they can see is false. + Health::Offline => "Cannot reach the server", Health::NodeMissing => "This app's copy of Node is missing", Health::NodeTooOld => "Node is too old", Health::WorkerMissing => "The worker's files are missing", @@ -121,6 +126,32 @@ pub struct View { pub title: &'static str, pub message: String, pub console_url: String, + /// Which of the three places that address came from. The page draws an + /// address it was given differently from one somebody typed here, and it + /// cannot tell them apart on its own. + pub console_source: &'static str, + /// And the sentence saying so, written where the places are decided rather + /// than in the page, so that adding a fourth one cannot leave the screen + /// describing three. + pub console_source_words: &'static str, + /// The console address this copy was built with, empty when it was built + /// with none. Shown only to somebody who has overridden it on this machine + /// — otherwise an override typed with a typo would hide the correct + /// address with no way back to it but deleting settings.json. + pub built_in_console_url: &'static str, + /// And the same four fields for the API, which is the host the worker + /// actually calls. + /// + /// It is on the screen for one reason, and it is the reason this app has a + /// screen at all: "my machine will not connect" is answered by knowing + /// which server it was trying and who told it to try that one. An app that + /// showed only the console would be showing the address that is working — + /// the window in front of them is proof of it — and hiding the one that is + /// not. + pub api_url: String, + pub api_source: &'static str, + pub api_source_words: &'static str, + pub built_in_api_url: &'static str, /// Never the token itself — only whether there is one. The page has no /// reason to hold a credential it cannot do anything with, and a webview's /// DOM is the last place it should be sitting. @@ -149,11 +180,20 @@ pub struct Worker { impl Worker { pub fn view(&self, app: &AppHandle) -> View { let inner = self.inner.lock().unwrap(); + let console = settings::address(app, settings::Which::Console); + let api = settings::address(app, settings::Which::Api); View { state: inner.health.id(), title: inner.health.title(), message: inner.message.clone(), - console_url: settings::console_url(app), + console_source: console.source.id(), + console_source_words: console.words(), + built_in_console_url: console.built_in(), + console_url: console.url, + api_source: api.source.id(), + api_source_words: api.words(), + built_in_api_url: api.built_in(), + api_url: api.url, has_token: !settings::token().is_empty(), log: inner.log.iter().cloned().collect(), } @@ -182,13 +222,46 @@ impl Worker { pub fn start(&self, app: &AppHandle) { self.halt(); - let url = settings::console_url(app); + let console = settings::console_url(app); + let api = settings::api_url(app); let token = settings::token(); - if url.is_empty() || token.is_empty() { + if console.is_empty() || api.is_empty() || token.is_empty() { + /* + * Four sentences rather than one, because they are four different + * things to go and do — and because a teammate with an installer + * that knows where both servers are has exactly one of them left, + * and asking them for an address the app already has would send + * them to find out something nobody needs them to know. + * + * The API gets its own sentence for the case that will actually + * happen: an installer built before this app knew about a second + * address, or built by somebody who set only the first. That copy + * has a console and no API, and "fill in the address" would send + * its owner to check the one that is already right. + */ return self.set( app, Health::NotConfigured, - "Fill in the console address and this machine's token below, then choose Save and start.", + match (console.is_empty(), api.is_empty()) { + (true, true) => { + "Fill in the console and API addresses and this machine's token below, then \ + choose Save and start." + } + (true, false) => { + "Fill in the console address and this machine's token below, then choose \ + Save and start." + } + (false, true) => { + "This copy knows the console but not the API — the server the worker asks \ + for jobs, which is a different host. Fill it in below, then choose Save \ + and start." + } + (false, false) => { + "Paste this machine's token below, then choose Save and start. On the \ + console it is Settings → Machines → add this machine, and it is shown \ + once." + } + }, ); } @@ -214,8 +287,26 @@ impl Worker { .arg("--import") .arg("./worker/register.mjs") .arg("worker/index.ts") - .env("CONTENTOS_API_URL", &url) .env("CONTENTOS_WORKER_TOKEN", &token) + /* + * The API's address, and never the console's. + * + * They are two hosts on every real deployment — `docs/DEPLOYING.md` + * puts the web app on APP_HOST and NestJS on API_HOST, and there is + * no `/api` under Next — so a worker handed the console's origin + * would 404 on everything while the window beside it worked + * perfectly, which is the failure that is hardest to report. A + * checkout is the same shape at :3333 and :4000, see + * `lib/api-base.ts`. + * + * An inherited `CONTENTOS_API_URL` still wins, and no longer needs + * a check of its own for it to: it is the first place + * `settings::api_url` looks, so this is the same value the child + * would have inherited, tidied. That check moved into `pick` + * rather than being deleted — one rule, in the one place, for both + * addresses. + */ + .env("CONTENTOS_API_URL", &api) // Nothing is ever asked of this process on stdin, and a child that // inherits a console's stdin can block on it forever. .stdin(Stdio::null()) @@ -261,7 +352,10 @@ impl Worker { inner.generation += 1; inner.pid = Some(child.id()); inner.health = Health::Starting; - inner.message = format!("Starting the worker for {url}."); + // The API, because that is the host this process is about to + // start calling and therefore the one a failure in the next few + // seconds will be about. + inner.message = format!("Starting the worker, which calls {api}."); inner.generation }; self.announce(app); @@ -455,7 +549,8 @@ fn classify(line: &str) -> Option<(Health, String)> { if line.contains("Cannot reach ") || line.contains("No answer from ") { return Some(( Health::Offline, - "The console is not answering. This is usually the network; it keeps trying, and nothing is lost." + "The server this machine's worker calls is not answering. This is usually the network; it keeps \ + trying, and nothing is lost. The address it is calling is shown above." .into(), )); } @@ -538,7 +633,7 @@ impl Layout { Health::NodeMissing, "Content OS installs its own copy of Node and that copy is not here, so this \ installation is damaged — nothing is missing from your computer. Install Content OS \ - again over the top of this one; the console address and this machine's token are kept." + again over the top of this one; both addresses and this machine's token are kept." .to_string(), )) } diff --git a/src-tauri/ui/setup.html b/src-tauri/ui/setup.html index 26b31cf..0e4721b 100644 --- a/src-tauri/ui/setup.html +++ b/src-tauri/ui/setup.html @@ -9,11 +9,20 @@ Everything else in Content OS is served by the team's console and shown in a webview, deliberately: a second copy of the UI here would be a second thing to keep in step, and it would be the copy nobody remembered to - update. But the two answers below have to be given before there IS a - console to load, so they cannot come from one — and a person who has to + update. But this machine's token has to be given before there IS a + console to load, so it cannot come from one — and a person who has to open a terminal to fix their own machine is the failure this whole app exists to prevent, so the worker's status is on this page too. + The addresses used to be the second question here and mostly are not any + more: an installer is built with both, so the fields are folded away + unless this machine is being pointed somewhere else, or unless the app + genuinely has one of them from nowhere. There are two of them because + there are two servers — the console and the API on its own hostname — + and both are shown, provenance and all, because the one that is broken + is by definition not the one whose page you are looking at. See + src-tauri/src/settings.rs. + Plain HTML and CSS on purpose. The colours are the console's own tokens from lib/theme.ts, copied rather than imported, because importing would mean a build step for a single page. @@ -93,6 +102,47 @@ #error:empty { display: none; } details { margin-top: 4px; } summary { color: var(--mute); cursor: pointer; } + /* + The address, when the app already knows it. Read-only text rather than + a filled-in field: a field invites a person to correct something that + is not theirs to correct, and this one is usually the installer's + answer, not theirs. + */ + .value { + margin: 0; + font: 13px/1.5 ui-monospace, "Cascadia Mono", Consolas, monospace; + -webkit-user-select: text; + user-select: text; + word-break: break-all; + } + /* + Nothing is known, so the disclosure stops being one: no summary to + open, just the label and the field, which is the screen as it was + before any of this. + */ + details.bare > summary { display: none; } + details.bare { margin-top: 0; } + .built-in { margin: 8px 0 0; } + .built-in code { -webkit-user-select: text; user-select: text; word-break: break-all; } + /* + The two addresses read as one thing with two rows, not as two + settings: they are always a pair, and a person who sees them as + unrelated is a person who moves one of them. + */ + .name { margin: 0 0 4px; font-weight: 500; } + .name .mute { color: var(--mute); font-weight: 400; } + .address + .address { margin-top: 14px; } + .addresses { margin-bottom: 4px; } + button.link { + padding: 0; + border: none; + background: none; + color: var(--accent-text); + font: inherit; + text-decoration: underline; + cursor: pointer; + } + button.link:hover { background: none; color: var(--text); } pre { margin: 12px 0 0; padding: 12px; @@ -125,9 +175,47 @@

This machine

- - -

Where your team's Content OS is. Ask whoever set up the server.

+ +
+
+

Console address — the pages this app opens

+

+

+
+
+

API address — what the worker calls for jobs

+

+

+
+
+ +
+ Use different addresses on this machine + + + +

+ + + + +

+ +
@@ -157,26 +245,125 @@

This machine

el("state").textContent = view.title; el("message").textContent = view.message; el("log").textContent = view.log.join("\n") || "Nothing yet."; - // Only ever fill a field the person is not currently typing in: a live - // status push must not overwrite a half-pasted token. - if (document.activeElement !== el("url") && !el("url").dataset.dirty) { - el("url").value = view.consoleUrl; - } + addresses(view); if (view.hasToken && !el("token").value) { el("token").placeholder = "Saved — leave blank to keep it"; } } - el("url").addEventListener("input", () => (el("url").dataset.dirty = "1")); + /* + The two addresses, described once. + + A table rather than two copies of the same twenty lines, because the + pair has to stay a pair: a change to how one of them is drawn that + misses the other is exactly the drift that leaves a person reading a + provenance line about the wrong server. Only the words differ, so only + the words are written twice. + */ + const ADDRESSES = [ + { + key: "console", + url: "consoleUrl", + source: "consoleSource", + words: "consoleSourceWords", + builtIn: "builtInConsoleUrl", + keep: "This machine only. Leave it blank to keep the console address above.", + ask: "Where your team's Content OS is. Ask whoever set up the server.", + }, + { + key: "api", + url: "apiUrl", + source: "apiSource", + words: "apiSourceWords", + builtIn: "builtInApiUrl", + keep: "This machine only. Leave it blank to keep the API address above.", + ask: "The server the worker asks for jobs. It is a different host from the console — usually api. in front of it, but not always. Ask whoever set up the server.", + }, + ]; + + /* + What the person is meant to do about them. + + Two genuinely different screens, not one screen with values in it. When + the app already knows an address — because it was built in, saved here, + or exported into a development environment — it is shown as text with a + line saying where it came from, and the field that would change it is + folded away behind a summary: it is not a question, and a filled field + reads like one that needs checking. When neither is known there is no + summary to open and the fields are exactly what they always were, + because then they genuinely are the question. + + The in-between is real and worth drawing properly: a copy built before + this app knew there were two addresses, or built by somebody who set + only one, knows its console and not its API. That one keeps the + summary — something here IS settled — but opens itself, so the field + that is still a question is on screen without being hunted for. + + Nothing here ever writes a resolved address into a field. Blank means + "leave it alone" on the way back to Rust, and pre-filling would turn + every Save into a save of the addresses as well — which on a build that + knows its own would quietly write a copy of them into settings.json and + pin this machine to today's answer forever. + */ + function addresses(view) { + let anyMissing = false; + let allMissing = true; + + for (const spec of ADDRESSES) { + const known = view[spec.source] !== "none"; + anyMissing = anyMissing || !known; + allMissing = allMissing && !known; + + // The whole row goes, heading and all. A label over an empty line + // says "this is broken" without saying what to do; the field below, + // which is open and waiting, says it. + el(`${spec.key}-block`).hidden = !known; + el(`${spec.key}-known`).textContent = view[spec.url]; + el(`${spec.key}-source`).textContent = view[spec.words]; + el(`${spec.key}-url-hint`).textContent = known ? spec.keep : spec.ask; + + // The way back, offered only to the person who needs it: somebody + // who has overridden this address here. Without it, one typo in an + // override hides the address this copy was built with and nothing on + // the screen ever mentions it again. Per address, because the two + // are overridden separately and a link that put back the wrong one + // would be worse than no link. + const strayed = + view[spec.source] === "this-machine" && + view[spec.builtIn] && + view[spec.builtIn] !== view[spec.url]; + el(`${spec.key}-built-in`).hidden = !strayed; + el(`${spec.key}-built-in-url`).textContent = view[spec.builtIn]; + } + + const override = el("override"); + override.classList.toggle("bare", allMissing); + if (anyMissing) override.open = true; + } + + // Fills the field rather than saving, so that going back to a built-in + // address is the same two steps as any other change to it, and is + // visible before it happens. + for (const spec of ADDRESSES) { + el(`${spec.key}-use-built-in`).addEventListener("click", () => { + el(`${spec.key}-url`).value = el(`${spec.key}-built-in-url`).textContent; + el(`${spec.key}-url`).focus(); + }); + } el("save").addEventListener("click", async () => { el("error").textContent = ""; try { render( - await invoke("save", { consoleUrl: el("url").value, token: el("token").value }), + await invoke("save", { + consoleUrl: el("console-url").value, + apiUrl: el("api-url").value, + token: el("token").value, + }), ); el("token").value = ""; - delete el("url").dataset.dirty; + el("console-url").value = ""; + el("api-url").value = ""; } catch (e) { // Every failure here is something the person can act on — a typo in // the address, a credential store that said no — so it is shown as a From 62c437f51548af286f5d3ec013b52fc28aa1bc45 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sun, 6 Sep 2026 12:03:06 +0600 Subject: [PATCH 20/30] Stop start.sh telling the API to allow its own origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- start.sh | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/start.sh b/start.sh index 607a6e9..a7925b6 100644 --- a/start.sh +++ b/start.sh @@ -59,7 +59,18 @@ fi # The API is its own process now, on API_PORT (4000). It is started here so # that one command still brings the whole thing up; stopping this script # stops both. -API_PORT="${API_PORT:-4000}" +API_PORT="${API_PORT:-4000}" + +# The origin the browser will be on, worked out BEFORE it is handed to +# anything. +# +# It used to be written inline as `PORT="$API_PORT" WEB_ORIGIN="http://localhost:$PORT"`, +# and bash performs command-prefix assignments left to right: PORT became +# 4000, and only then was $PORT expanded. So the API was told to allow its +# OWN origin, every browser call from :3333 was refused by CORS, and the page +# said "The API is not running" about an API that was running and answering +# curl perfectly. A variable that cannot be shadowed is the whole fix. +WEB_APP_ORIGIN="http://localhost:$PORT" # The worker, which is the thing that actually writes. # @@ -106,7 +117,7 @@ fi case "$MODE" in dev) echo "→ Content OS API (dev) on http://localhost:$API_PORT/api" - (cd api && PORT="$API_PORT" WEB_ORIGIN="http://localhost:$PORT" npm run dev) & + (cd api && PORT="$API_PORT" WEB_ORIGIN="$WEB_APP_ORIGIN" npm run dev) & API_PID=$! trap stop_all EXIT start_worker @@ -119,7 +130,7 @@ case "$MODE" in echo "→ building Content OS" NEXT_PUBLIC_API_URL="http://localhost:$API_PORT" npm run build echo "→ Content OS API on http://localhost:$API_PORT/api" - (cd api && PORT="$API_PORT" WEB_ORIGIN="http://localhost:$PORT" CONTENTOS_DATA_DIR=../.data npm run start) & + (cd api && PORT="$API_PORT" WEB_ORIGIN="$WEB_APP_ORIGIN" CONTENTOS_DATA_DIR=../.data npm run start) & API_PID=$! trap stop_all EXIT start_worker From 9b1b7a6e804ee62af187270675a62734110a9480 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sun, 6 Sep 2026 12:05:30 +0600 Subject: [PATCH 21/30] Let the worker say the name the console gave it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- api/src/workers/workers.controller.ts | 12 ++++++++++++ worker/api.ts | 8 +++++++- worker/index.ts | 6 +++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/api/src/workers/workers.controller.ts b/api/src/workers/workers.controller.ts index 92246f1..b21eb2c 100644 --- a/api/src/workers/workers.controller.ts +++ b/api/src/workers/workers.controller.ts @@ -113,6 +113,18 @@ export class WorkersController { }); return { workerId: saved.id, + /* + * The name the CONSOLE has for this machine, handed back so the worker + * can say it. + * + * The worker knows only what it was configured with, which is usually a + * hostname; the console knows what a person typed in Settings → Machines, + * and that is the name every screen shows. Without this the sidecar's own + * log announced itself as "Shifa" while the machine picker called it + * "Shakhawat's PC" — two names for one computer, in front of somebody + * trying to work out which one is not claiming jobs. + */ + name: saved.name, heartbeatMs: HEARTBEAT_MS, claimMs: CLAIM_HOLD_MS, }; diff --git a/worker/api.ts b/worker/api.ts index 94c5883..cd515d0 100644 --- a/worker/api.ts +++ b/worker/api.ts @@ -114,7 +114,13 @@ export class Api { tools: ToolStatus[]; enabled: string[]; }) { - return this.send<{ workerId: string; heartbeatMs: number; claimMs: number }>( + return this.send<{ + workerId: string; + /** What the console calls this machine — not what we called ourselves. */ + name?: string; + heartbeatMs: number; + claimMs: number; + }>( "POST", "/api/workers/register", body, diff --git a/worker/index.ts b/worker/index.ts index d2b5e42..7f65acc 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -91,7 +91,11 @@ async function main() { if (!enrolled) return; log( - `Registered as ${enrolled.workerId} (${cfg.name}). Claiming for ${enrolled.claimMs}ms at a time, heartbeat every ${enrolled.heartbeatMs}ms.`, + // The console's name for this machine, not the one we guessed. A machine is + // named by a person in Settings -> Machines and that is what every screen + // shows; announcing a hostname here gave one computer two names in front of + // somebody working out which of them had stopped claiming. + `Registered as ${enrolled.workerId} (${enrolled.name || cfg.name}). Claiming for ${enrolled.claimMs}ms at a time, heartbeat every ${enrolled.heartbeatMs}ms.`, ); /* From 09fb730a04a4d400943fae15fa8b7ee5d9400067 Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sun, 6 Sep 2026 12:49:51 +0600 Subject: [PATCH 22/30] Sign in with an email and a password, and let the app enrol itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `. 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 ` 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 ` 200, `Bearer ` 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 --- api/src/auth/auth.controller.ts | 58 ++- api/src/common/session.guard.ts | 110 +++- api/src/common/user.decorator.ts | 11 +- api/src/machines/machines.controller.ts | 39 +- docs/DEPLOYING.md | 9 +- docs/WORKER.md | 162 ++++-- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 25 + src-tauri/src/account.rs | 657 ++++++++++++++++++++++++ src-tauri/src/main.rs | 95 +++- src-tauri/src/settings.rs | 138 ++++- src-tauri/src/windows.rs | 6 +- src-tauri/src/worker.rs | 51 +- src-tauri/ui/setup.html | 194 ++++++- tests/session-credentials.test.ts | 54 ++ 15 files changed, 1516 insertions(+), 95 deletions(-) create mode 100644 src-tauri/src/account.rs create mode 100644 tests/session-credentials.test.ts diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index f94c1a7..2fd1c72 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -31,7 +31,7 @@ import { } from "@/lib/server/auth"; import { clearSession, setSession } from "../common/cookie"; -import { Public } from "../common/session.guard"; +import { Public, SESSION_SCHEME } from "../common/session.guard"; import { CurrentUser, SessionId } from "../common/user.decorator"; /** @@ -44,18 +44,55 @@ import { CurrentUser, SessionId } from "../common/user.decorator"; */ @Controller() export class AuthController { + /** + * Sign in. The cookie always; the session id only when it is asked for. + * + * A browser needs nothing but the cookie and has never read this body for a + * credential. The desktop app cannot use the cookie at all — its webview is + * on a local origin, so the API's cookie is third-party — so it holds the + * session id itself and sends it back as `Authorization: Session `; see + * `common/session.guard.ts` for why that is the same session and not a new + * principal. + * + * Behind a header rather than given to everybody, because a credential in a + * response body is a credential in whatever logs that body. Nothing on the + * console asks for this one, so nothing on the console can start recording + * it: a request-logging middleware, a browser devtools export somebody + * pastes into a bug report, a proxy keeping response samples. A client that + * sends `X-Session-Return: 1` has said it intends to store the thing, and + * has somewhere to store it — the OS credential store, in this app's case. + * + * The header is the request's, not a response flag, so the web app's login + * is byte-for-byte what it was. And it is a header rather than a body field + * so that the shape of what a client *posts* stays the same for both, which + * keeps `authenticate` the one thing this route is about. + */ @Public() @Post("auth/login") @HttpCode(200) async login( @Body() body: { email?: string; password?: string }, @Headers("user-agent") agent: string | undefined, + @Headers("x-session-return") wantsSession: string | undefined, @Res({ passthrough: true }) res: Response, ) { const user = await authenticate(body.email ?? "", body.password ?? ""); void sweepSessions(); - setSession(res, await startSession(user.id, agent ?? "")); - return user; + const sid = await startSession(user.id, agent ?? ""); + setSession(res, sid); + // Any value that is not an explicit "no" counts as asking: a client that + // sets this header at all is a non-browser client that will not get the + // cookie home, and failing it over the spelling of "true" would produce a + // sign-in that looks like it worked and a machine that never enrols. + if (!asked(wantsSession)) return user; + /* + * The scheme travels with the credential. The app has to send it back as + * `Authorization: Session ` and there is no second place it could read + * that word from — it is not in a header of this response, it is not in + * the cookie — so saying it here is what stops a client inventing `Bearer` + * and colliding with the worker token. + */ + return { ...user, session: sid, scheme: SESSION_SCHEME }; } @Public() @@ -162,3 +199,18 @@ export class AuthController { return { ok: true }; } } + +/** + * Whether a client asked for its session id back. + * + * Generous in one direction only. Absent is no — that is the browser, and the + * whole reason this is opt-in — but anything present counts as yes unless it + * is one of the words that plainly means no. A desktop app that sent `true` + * where this wanted `1` would otherwise sign in successfully, be handed + * nothing to keep, and fail at enrolment with a sentence about a server that + * is working perfectly. + */ +const asked = (header: string | undefined) => { + const value = String(header ?? "").trim().toLowerCase(); + return value !== "" && value !== "0" && value !== "false" && value !== "no"; +}; diff --git a/api/src/common/session.guard.ts b/api/src/common/session.guard.ts index eadda0b..e2633e1 100644 --- a/api/src/common/session.guard.ts +++ b/api/src/common/session.guard.ts @@ -14,20 +14,101 @@ import { SESSION_COOKIE, sessionUser, type User } from "@/lib/server/auth"; export const PUBLIC = "contentos:public"; export const Public = () => SetMetadata(PUBLIC, true); +/** + * The scheme a client that is not a browser sends its session under: + * `Authorization: Session `. + * + * **Not `Bearer`.** `WorkerGuard` owns that word, and the two credentials it + * would then share a spelling with are the two that must never be confused: + * a session is a person and reaches the whole console, a worker token is a + * machine and reaches nine queue routes. Two credentials that look alike at a + * glance is how one ends up pasted where the other belongs — into a support + * message, a `.env`, or the wrong field of a setup window — and the paste that + * matters is the one that hands somebody's whole console to a machine's + * credential store. A different scheme name costs one word and makes the two + * impossible to mistake in a log line, a proxy rule or a curl command. + * + * `Session` is not on the IANA scheme registry and does not need to be: it is + * read by this guard and sent by this repository's own desktop app, and a + * registered name would only invite something else to assume it means what it + * means somewhere else. + */ +export const SESSION_SCHEME = "Session"; + +const SESSION_HEADER = /^Session\s+(.+)$/i; + +/** + * The session id an `Authorization` header carries, or an empty string. + * + * Split out so it can be tested without a database, a request or a window + * system, and so there is exactly one place that decides what counts: the + * important half of this function is what it *refuses*. A `Bearer` header is + * a worker token and must read as no session at all, or the desktop app's two + * credentials would each be accepted in the other's place — precisely the + * merge `docs/WORKER.md` argues against in "The desktop app holds two + * credentials, on purpose". + */ +export function sessionFromHeader(header: string | undefined): string { + return SESSION_HEADER.exec(String(header ?? "").trim())?.[1]?.trim() ?? ""; +} + /** The request, once the guard has run: the user rides on it. */ export type SessionRequest = Request & { user?: User; sessionId: string }; /** * The lock on every route. * - * Reads the session cookie off the request, looks the session up, and puts - * the user on the request for `@CurrentUser()`. Global, so a new controller - * is closed by default: the audit found fifteen Next route handlers that - * each had to remember to call `requireUser()`, and did not. + * Reads the session off the request, looks it up, and puts the user on the + * request for `@CurrentUser()`. Global, so a new controller is closed by + * default: the audit found fifteen Next route handlers that each had to + * remember to call `requireUser()`, and did not. * * A route that must be open — login, signup, the "is signup open" probe — * says so with `@Public()`. The session id is put on the request either way, * because logout needs it whether or not the session is still valid. + * + * ## Two transports, one session + * + * The cookie is how a browser carries it, and nothing about that path changed: + * same name, same options in `common/cookie.ts`, same behaviour for the web + * app on `WEB_ORIGIN`. + * + * The header exists because the desktop app is not a browser. Its webview is + * on a local origin (`tauri://localhost`) and the API is on somebody's server, + * which makes the session cookie a *third-party* cookie — exactly the thing + * webviews and browsers are progressively refusing, and a thing this app would + * have to keep working across four Chromium releases it does not control. The + * app therefore holds the session id itself, in the OS credential store beside + * the worker token, and attaches it deliberately to each request. The team's + * plan is that only this API is deployed, with no hosted console to visit, so + * "mint a token in Settings → Machines and paste it" stopped being an + * instruction anybody could follow; the app signs in with an email and a + * password instead and enrols itself through `POST /api/machines`. + * + * **This is not a new principal, and it cannot reach anything a signed-in + * browser could not.** There is one lookup, `sessionUser`, and it is the same + * call the cookie path makes: the same `sessions` row, the same `expires_at`, + * the same user. Everything that ends a session ends this one too — Settings → + * Team deleting a person's rows, "sign out everywhere" through + * `endOtherSessions`, a password change deleting all of them, `endSession` on + * logout, and the expiry sweep. Nothing here mints anything, nothing here + * extends a lifetime, and there is no branch anywhere that asks which + * transport a session arrived on — which is the point: a second code path that + * decided *authorisation* differently would be a second thing to audit, and + * would be the one that drifts. + * + * What it genuinely does widen, said plainly: a session id now lives somewhere + * other than a cookie jar. A cookie is `httpOnly`, so script on the console + * cannot read it; a header value is held by whatever client sends it. That is + * why the desktop app keeps it in Windows Credential Manager rather than in + * `settings.json` next to the addresses, and why it never puts it in the + * webview's DOM. The console itself must keep using the cookie and does. + * + * The other direction is closed by construction. A worker token in a `Session` + * header is not a session id — `sessions` has no such row and `sessionUser` + * answers null. A session id in a `Bearer` header is not a worker — `WorkerGuard` + * hashes it and finds nothing in `workers`. Neither credential becomes the + * other by being sent under the wrong scheme; both simply fail. */ @Injectable() export class SessionGuard implements CanActivate { @@ -35,7 +116,26 @@ export class SessionGuard implements CanActivate { async canActivate(context: ExecutionContext) { const req = context.switchToHttp().getRequest(); - req.sessionId = String(req.cookies?.[SESSION_COOKIE] ?? ""); + + /* + * The header first, and the cookie only when there is no `Session` header + * at all. + * + * The explicit credential beats the ambient one. A cookie is sent by the + * browser whether or not this request meant to use it; a header is what + * this client chose to authenticate as, and a client holding both — a + * webview that once signed in on the API's own origin, an app being + * debugged against a console in the same browser profile — means the one + * it attached. + * + * `||` falls through only when the header is absent or is somebody else's + * scheme, never when it is a `Session` header carrying a value that turns + * out to be dead. A bad header is a failed request, not a quiet promotion + * to whichever session the cookie jar happens to hold. + */ + req.sessionId = + sessionFromHeader(req.headers?.authorization) || + String(req.cookies?.[SESSION_COOKIE] ?? ""); const open = this.reflector.getAllAndOverride(PUBLIC, [ context.getHandler(), diff --git a/api/src/common/user.decorator.ts b/api/src/common/user.decorator.ts index e6c639a..efb1855 100644 --- a/api/src/common/user.decorator.ts +++ b/api/src/common/user.decorator.ts @@ -14,7 +14,16 @@ export const CurrentUser = createParamDecorator( ctx.switchToHttp().getRequest().user ?? null, ); -/** The session id off the cookie, valid or not — logout needs it either way. */ +/** + * The session id, valid or not — logout needs it either way. + * + * From the cookie or from an `Authorization: Session …` header, whichever the + * caller used: the guard resolves that before anything reads this, so a + * handler never has to know which transport carried it. A browser sends the + * cookie; the desktop app, whose webview is on a local origin against a remote + * API, sends the header, because a cookie in that position is third-party and + * is what webviews are progressively refusing. + */ export const SessionId = createParamDecorator( (_data: unknown, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().sessionId, diff --git a/api/src/machines/machines.controller.ts b/api/src/machines/machines.controller.ts index aad0ee1..750e507 100644 --- a/api/src/machines/machines.controller.ts +++ b/api/src/machines/machines.controller.ts @@ -26,8 +26,9 @@ import { CurrentUser } from "../common/user.decorator"; /** * The console's half of the worker split. * - * Everything here is behind the global `SessionGuard` — a person, with a - * cookie, on the Settings screen. It is emphatically NOT a worker route: + * Everything here is behind the global `SessionGuard` — a person, on the + * Settings screen or in the desktop app's setup window. It is emphatically NOT + * a worker route: * `api/src/workers/workers.controller.ts` is the machine's six endpoints and * every one of them is `@WorkerRoute()` with a bearer token. The two are * different principals on purpose (`docs/WORKER.md`, "The desktop app holds @@ -71,6 +72,40 @@ export class MachinesController { * insert keyed on the same token hash. The machine has not spoken yet, so * `platform`, `version` and `tools` stay empty until it does — that emptiness * is what the panel reads as "waiting for this machine to check in". + * + * ## The desktop app calls this too, and that is the ordinary path now + * + * There is no hosted console to visit in the deployment this is heading for + * — only this API — so "mint a token in Settings → Machines and paste it + * into the app" stopped being an instruction anybody could follow. The + * desktop app signs in with an email and a password, keeps the session, and + * posts here with the computer's hostname as the name. Nothing about that is + * a wider door: the session arrives as `Authorization: Session ` rather + * than as a cookie (`common/session.guard.ts` has the whole argument), it is + * the same person's same session either way, and the machine is bound to + * `user.id` off the guard — never to anything in the body — so this route + * cannot mint a machine for somebody else however it is called. + * + * The name is the hostname because that is the app naming a machine nobody + * has named yet, and it is what the person will recognise in the picker + * before they think of something better. It is not a rename: `registerWorker` + * deliberately does not take `name` on conflict, so a machine somebody called + * "Shakhawat's desktop" here keeps that name through every re-registration. + * + * ## Why there is no find-or-create, and who is responsible instead + * + * A row's identity IS its token hash, and the token exists in one response + * and nowhere afterwards. So a "give me this machine's row again" route could + * not hand back the credential that row is keyed on; it could only mint a + * second token and orphan the first, which is a duplicate row wearing a + * helpful name. The client is therefore the one that must not ask twice: the + * desktop app records the machine id it was given — in its settings file, + * beside the addresses, while the token itself goes to the OS credential + * store — and re-enrols only when one of the two is gone, or when the id is + * no longer listed by `GET /machines` against the person who has just signed + * in. Anybody tempted to add a server-side + * dedupe here should read that paragraph again first — the thing that would + * have to be returned is the thing this route exists never to keep. */ @Post() async mint( diff --git a/docs/DEPLOYING.md b/docs/DEPLOYING.md index 24ebcea..052ebe7 100644 --- a/docs/DEPLOYING.md +++ b/docs/DEPLOYING.md @@ -126,8 +126,13 @@ addresses at install time, which is the thing this removes. The script says so, loudly, before and after the build. What a teammate then does: run the installer, click through SmartScreen's -warning (it is unsigned — see `docs/DECISIONS.md`), and paste the one token -minted for their machine. Nothing else. +warning (it is unsigned — see `docs/DECISIONS.md`), and **sign in with their +own email and password**. The app mints its own machine token from that +session and starts the worker; nobody pastes anything. + +The **Settings → Machines** flow above is still there and still works — it is +the way to enrol a machine that is not running the desktop app, and the way to +revoke one. It is no longer what an ordinary install involves. Then, from your laptop, ask the API the questions that matter: diff --git a/docs/WORKER.md b/docs/WORKER.md index 2a53274..a6c37a8 100644 --- a/docs/WORKER.md +++ b/docs/WORKER.md @@ -463,9 +463,54 @@ marks these and makes `SessionGuard` stand aside. - A worker is long-lived and headless. It cannot be sent to `/login?next=…`, which is what `lib/api-json.ts` does with every 401. -The token is minted once, in Settings → Machines, shown once, stored as a -sha256 hash, and pasted into the desktop app on that machine. Revoking it is -deleting the row. +The token is minted once, shown once, stored as a sha256 hash, and kept by the +machine it was minted for. Revoking it is deleting the row. Settings → Machines +is one way to mint it; the desktop app minting its own through +`POST /api/machines` after somebody signs in is the other, and is now the +ordinary one — see **Three credentials, and still two principals** below. + +### Three credentials, and still two principals + +There is a third thing in the `Authorization` header now, and it is worth +being exact about what it is, because the whole argument above is that a +worker's token and a person's session must not be merged. + +`SessionGuard` reads `Authorization: Session ` in addition to the +cookie. **It is the same session row.** Same `sessions` table, same +`expires_at`, same `sessionUser` lookup, same revocation: Settings → Team +deleting a person's sessions, "sign out everywhere", a password change +deleting all of them, the expiry sweep. There is no branch anywhere that asks +which transport a session arrived on, which is the point — a second code path +that decided *authorisation* differently would be the one that drifts. + +**Why it had to exist.** The desktop app's webview is on a local origin +(`tauri://localhost`) and the API is on the team's server, so the session +cookie is a third-party cookie — exactly what webviews and browsers are +progressively refusing — and CORS is deliberately narrowed to `WEB_ORIGIN`, so +a `fetch` from that origin is refused before it is sent. The app therefore +does its two requests from Rust and carries the session itself. It matters +more than it sounds: the deployment this is heading for is **the API alone, +with no hosted console**, so "mint a token in Settings → Machines and paste +it" became an instruction with nowhere to follow it. + +**Why the scheme is `Session` and not `Bearer`.** `WorkerGuard` owns `Bearer`. +Two credentials spelled the same way is how one ends up pasted where the other +belongs, and the direction that would matter is a machine's token being handed +a person's whole console. Under the wrong word each simply fails: a worker +token in a `Session` header matches no `sessions` row, a session id in a +`Bearer` header hashes to nothing in `workers`. `tests/session-credentials.test.ts` +pins the refusal, because a loosened regex is invisible to `tsc`. + +**What it genuinely widens, said plainly.** A session id now lives somewhere +other than a cookie jar. A cookie is `httpOnly` and script cannot read it; a +header value is held by whatever sends it. So the desktop app keeps it in the +OS credential store — not in `settings.json` beside the addresses, and never +in the webview's DOM. `POST /auth/login` returns the id **only** when the +request carries `X-Session-Return`, so the web app's login response never +starts carrying a credential it does not need and might log. + +So: still two principals. A person, who may now arrive by cookie or by header, +and a machine, which arrives by bearer token and by nothing else. | | | | |---|---|---| @@ -698,9 +743,8 @@ already atomic and already idempotent. ### The desktop app holds two credentials, on purpose -The webview signs in as a person, with the session cookie, exactly as a browser -does. The worker holds its own bearer token. They are not the same principal -and must not be merged. +The app signs in as a person and holds that session. The worker holds its own +bearer token. They are not the same principal and must not be merged. Everything in **The worker HTTP protocol** above argues this: different lifetime, different blast radius, different revocation. A single credential @@ -708,10 +752,24 @@ that is both would be a session that survives Team → Remove, or a worker token that can read the whole content library — one of the two, depending on which side won. -The confusion is a real cost and it is paid once. Settings → Machines mints the -token, shows it once, and the desktop app stores it in the OS keychain -(`tauri-plugin-stronghold`, or DPAPI on Windows). Nobody types it twice, and -nobody keeps it in a file next to the app. +**What changed, and what did not.** The person's half is no longer a cookie in +the webview: the window is on a local origin, the API is on the team's server, +and that cookie is third-party. The app signs in itself, over +`POST /auth/login` with `X-Session-Return`, and sends the session back as +`Authorization: Session ` — same row, same expiry, same revocation, as +**Three credentials, and still two principals** sets out. Two credentials, two +entries in the OS credential store under `com.contentos.desktop`: +`worker-token` and `console-session`. Separately written, separately revoked, +and only the first is ever handed to the Node worker. + +**Nobody types either of them.** The app mints this machine's worker token for +itself through `POST /api/machines` the moment somebody signs in, names it +after the computer, keeps the id it was given, and does not mint a second one +on the next launch. That is not a convenience: the deployment this is heading +for has no hosted console, so Settings → Machines is not a page anybody can +open, and "paste the token you were shown" was an instruction with nowhere to +follow it. The field is still on the setup window, one disclosure down, for +whoever is handed a token by a colleague. ### Uploads stay on the server, and whisper stops needing the video @@ -768,7 +826,7 @@ npm run worker | Variable | | |---|---| | `CONTENTOS_API_URL` | **Required.** The **API** this machine works for — NestJS, on its own hostname (`API_HOST` in `docs/DEPLOYING.md`, `:4000` in a checkout). Not the address of the console you open in a browser; there is no `/api` under Next, so that one 404s everything. | -| `CONTENTOS_WORKER_TOKEN` | **Required.** Minted in Settings → Machines and shown once. | +| `CONTENTOS_WORKER_TOKEN` | **Required.** Minted in Settings → Machines and shown once, or minted for this machine by the desktop app when somebody signs in there. Either way it is the same 256-bit token and the same row. | | `CONTENTOS_WORKER_NAME` | What the machine picker calls it. Defaults to the hostname; "Shakhawat's desktop" reads better than `DESKTOP-7F2K1`. | | `CONTENTOS_WORKER_ALLOW_FRAME_READ` | `1` to let Claude's CLI be granted `Read` for frame files **on this machine**. Off by default, and checked in addition to `workers.can_read_frames` — the grant lands on this filesystem, so whoever owns it gets the last word. | | `CONTENTOS_WORKER_TOOLS_OFF` | Comma-separated tool ids to switch off here, whatever the probe found. | @@ -995,9 +1053,11 @@ with music and no speech genuinely has no words in it. Built, and small on purpose. It lives in `src-tauri/` — Tauri 2, Windows first — and it is two things in one process: **a window onto the console the team already runs**, and **the worker running beside it**. A teammate who is -not a developer installs one app, pastes in the token for their machine, and it -starts taking jobs. Nobody opens a terminal, and nobody is asked where the -console is — the installer was built knowing. +not a developer installs one app, signs in with their email and their password, +and it starts taking jobs. Nobody opens a terminal, nobody is asked where the +console is — the installer was built knowing — and nobody is asked for a token: +the app mints this machine's own through `POST /api/machines` as part of +signing in. **What it is not**, and this is the part worth defending: @@ -1005,15 +1065,17 @@ console is — the installer was built knowing. `src-tauri`, and there must not be. The console is served by the server, so a template change or a fix reaches every teammate the moment it is deployed instead of needing fifteen desktops updated. The one screen this app draws - itself is `src-tauri/ui/setup.html`, and it exists only because this - machine's token has to be given *before* there is a console to ask — as do - the console and API addresses, on the builds that were not compiled with them - — and - because a worker that cannot start needs somewhere to say so that is not a - console window. -- **Not a second identity system.** The webview signs in as a person, with the - console's own cookie, exactly as a browser does. The worker holds the machine - token. Two credentials, for the reasons in "The desktop app holds two + itself is `src-tauri/ui/setup.html`, and it exists only because this machine + has to be set up *before* there is a console to set it up from — the sign-in, + and the console and API addresses on the builds that were not compiled with + them — and because a worker that cannot start needs somewhere to say so that + is not a console window. +- **Not a second identity system.** It signs in with the console's own accounts, + over the console's own `POST /auth/login`, and gets back the console's own + session — the same row a browser would have held in a cookie, carried in a + header because a webview on a local origin cannot keep that cookie. No users + here, no passwords here, no second table anywhere. The worker holds the + machine token. Two credentials, for the reasons in "The desktop app holds two credentials, on purpose" above. - **Not a rewrite of the worker.** It spawns `worker/index.ts` with the same flag, the same loader hook and the same relative path `npm run worker` uses. @@ -1026,8 +1088,11 @@ console is — the installer was built knowing. |---|---| | Console address | Compiled in, or in the environment, or in `%APPDATA%\com.contentos.desktop\settings.json` — three places in a fixed order, below. Not a secret in any of them: it is the URL a browser would show, and a person debugging their own machine has to be able to read it. | | API address | The same three places, the same order, its own value. It is what the worker calls; the console address is not. Why they are two, and why neither is guessed from the other, is the next section. | -| Worker token | The **OS credential store**: Windows Credential Manager, under the target `worker-token.com.contentos.desktop`. The console keeps only its sha256 so that a database dump is not a list of live credentials; writing the plaintext into `%APPDATA%` would undo that at the other end, where anything running as that user can read it and the first backup of the profile carries it off the machine. | -| Session cookie | The webview's own data directory, which is WebView2's business and not this app's. | +| Worker token | The **OS credential store**: Windows Credential Manager, under the target `worker-token.com.contentos.desktop`. Minted by the app itself at sign-in, or pasted in by somebody who was handed one. The console keeps only its sha256 so that a database dump is not a list of live credentials; writing the plaintext into `%APPDATA%` would undo that at the other end, where anything running as that user can read it and the first backup of the profile carries it off the machine. | +| The session | The same store, under `console-session.com.contentos.desktop`. A separate entry rather than a second field in one blob, because the two are separately replaced: signing in again writes this and leaves the token alone, re-enrolling writes the token and leaves this alone. It is in the credential store for the same reason the token is — a header credential in `%APPDATA%` is a credential in the next backup of the profile — and it is never given to the webview or to the worker. | +| Who is signed in | `accountEmail` in `settings.json`, and it is only a label: the window shows it, `settings::account_email` returns it only while there is a session in the store to go with it, and nothing authenticates with it. | +| This machine's row | `machineId` in `settings.json`. Not a credential — it is the id the machine picker shows beside a name — and it is what stops a second machine being minted on every sign-in. | +| Session cookie | The webview's own data directory, which is WebView2's business and not this app's. The API's cookie never reaches it: the window is on a local origin, so that cookie is third-party. | ### Where the two addresses come from @@ -1078,6 +1143,40 @@ and one that is not a usable address is skipped rather than accepted — so an `apiUrl: ""` in a `settings.json` written before there was one does not shadow the address the installer was built with. +**What the setup window asks for.** An email and a password, and nothing else. +Sign in, and the app keeps the session, mints this machine's worker token, +stores it, and starts the worker — one button, and the person never sees a +token at all. Afterwards the panel stops being a form and becomes a line saying +who is signed in, with two things beside it: *Set this machine up again*, which +mints a fresh token from the session already held and is the answer to a +machine revoked or a credential store cleared, and *Sign in as somebody else*, +for a computer handed to a colleague. + +Every way it can fail gets its own sentence, because they are different things +to go and do: + +| What happened | What the window says | +|---|---| +| The password is wrong | *That email and password do not match an account on this console. Accounts here are invite-only, so if you have never signed in before, ask whoever runs the console for an invite link.* | +| Signup is closed and there is no account for this person — which is every console that has an owner | The same sentence. It is the same 401, and the invite half is the part that is actually actionable. | +| The console has **no** accounts at all yet | *There are no accounts on this console yet… The very first account — the owner's — is made on the console itself.* Asked for with `GET /auth/signup` before anything is said, because telling somebody their password is wrong when nobody has made them an account sends them to change a password that was fine. | +| The API cannot be reached | The address, named, and said to be the **API** — *the server the worker asks for jobs, which is not the console*. The person very often has the console open behind the window, so a sentence about "the server" reads as obviously false. | +| The API answers, but with something else | *…answered with something this app could not read. That address may not be the Content OS API* — which is what a console address or a proxy's error page in that field actually looks like. | +| This computer is already enrolled | It is kept: *This computer was already set up as "Shifa", so it kept it rather than adding a second one.* | +| The API is older than this app | *…signed you in but did not give this app a session to keep.* The one case where everything the person can see says it worked, so it has to say plainly that the fix is on the server. | + +**How "already enrolled" is decided**, since a wrong answer here fills Settings +→ Machines with a row per launch, each holding a live credential. The app keeps +the machine id it was handed when it minted the row, in `settings.json`, and +reuses that machine only when **all three** hold: the id is recorded, a worker +token is in the credential store, and the console still lists that id *against +the person who has just signed in*. Not by hostname — two laptops are both +called DESKTOP-something, and a machine somebody renamed must not be renamed +back. Not by the token alone — a token whose row was revoked authenticates as +nothing, and the machine would sit there doing no work. And the ownership check +is what stops a computer handed from one teammate to another carrying on as the +first, spending their subscription under their name in the picker. + **What the setup window shows.** Both addresses, one under the other, each with its own provenance line — because "my machine will not connect" is answered by knowing which server the worker was calling and who told it to call that one, @@ -1148,7 +1247,7 @@ been compiled into production installers, silently, which is the exact failure this is all here to prevent. First run opens the setup window when it is missing the token or has either -address from nowhere; on a build that carries both, that is the token and +address from nowhere; on a build that carries both, that is the sign-in and nothing else. After that it opens the console and starts the worker. **A checkout runs the checkout's worker.** The app looks for `worker/index.ts` @@ -1163,9 +1262,9 @@ The status is in the tray menu and in the setup window, in these words: *Connected*, *Cannot reach the server*, *This app's copy of Node is missing*, *Node is too old*, *This machine's token was refused*, *The worker's files are missing*, *Not set up yet*, *The worker is not running*. They are separate -states because they are separate actions — one is wifi, one is a trip to -Settings → Machines, one is installing the app again — and a single "error" -would hide all of them behind the same shrug. Anything the worker prints that +states because they are separate actions — one is wifi, one is *Set this +machine up again* in the setup window, one is installing the app again — and a +single "error" would hide all of them behind the same shrug. Anything the worker prints that this app does not recognise is still shown verbatim, under "What the worker is saying". @@ -1429,7 +1528,10 @@ usually closed — which is what the tray is for. a machine that is available when somebody remembers. - **`workers.max_concurrency`, the per-machine tool switches and the machine's name are not exposed.** They are environment variables the worker already - reads, and they belong in the setup window next to the token. + reads, and they belong in the setup window under the sign-in. The name + matters more than it did: the app now names a new machine after the computer, + and renaming it means opening Settings → Machines on a console this app's + whole point is that you may not have. - **A forced kill still orphans the worker.** Quit and window-close are handled; End Task on the app, or a machine shutting down, leaves the Node process to its lease. A Windows Job Object would close that hole. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3d990c4..9bdbe50 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -345,6 +345,8 @@ name = "contentos-desktop" version = "0.1.0" dependencies = [ "keyring", + "reqwest", + "rustls", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 65ae579..03b098f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,31 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-updater = "2.11.0" +# Signing in and enrolling this machine are done from Rust, not from the setup +# page's JavaScript, and the reason is not taste: the API allows CORS for +# WEB_ORIGIN — the console's origin — so a `fetch` from a webview on +# `tauri://localhost` is refused before it is sent. Doing it here also keeps +# the session out of the page's DOM. +# +# The features are copied from tauri-plugin-updater's, deliberately and +# exactly: the same reqwest is already in this binary, and asking for a +# different TLS stack or an extra decoder would add crates to an app whose +# whole job is to be one small install. They are named here rather than left +# to feature unification because a build that got its TLS from somebody else's +# dependency is a build where removing that dependency turns every https URL +# into a runtime "scheme is not allowed" — with nothing at compile time saying +# so. `rustls-no-provider` is why the `rustls` line below exists. +reqwest = { version = "0.13", default-features = false, features = [ + "json", + "rustls-no-provider", + "system-proxy", +] } + +# Only for the crypto provider that `rustls-no-provider` deliberately does not +# choose. See `client()` in src/account.rs: it is installed once per process, +# by whoever gets there first — this app signing in, or the updater checking. +rustls = { version = "0.23", default-features = false, features = ["ring"] } + # The worker token is a credential, so it goes in the OS credential store # rather than beside the settings file. Windows-first, deliberately: this is # what the team runs, and the per-target features mean a macOS or Linux build diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs new file mode 100644 index 0000000..0525cdb --- /dev/null +++ b/src-tauri/src/account.rs @@ -0,0 +1,657 @@ +//! Signing in as a person, and enrolling this machine as a worker. +//! +//! This is the screen's whole job now. It used to ask for a worker token, +//! which somebody had to mint in Settings → Machines on a web console and +//! paste in — and the team's deployment is only the API, with no hosted +//! console to visit, so that instruction had nowhere left to be followed. So +//! the app asks for the two things a teammate already has, their email and +//! their password, and does the rest itself. +//! +//! **Two requests, two credentials, and they stay two.** `POST /auth/login` +//! with `X-Session-Return: 1` gives back the session id as well as setting the +//! cookie nothing here can use; `POST /machines` over that session mints this +//! machine's worker token. The session is the person and reaches the whole +//! console; the token is the machine and reaches the queue. `docs/WORKER.md` +//! argues that at length under "The desktop app holds two credentials, on +//! purpose", and nothing here merges them: they are separate entries in the +//! credential store, separately revoked, and the worker is handed only the +//! second one. +//! +//! **Why the HTTP is here and not in the setup page.** The page could `fetch`, +//! and it would be refused: the API's CORS list is `WEB_ORIGIN`, which is the +//! console's origin and never a webview's local one. Doing it in Rust also +//! keeps the session out of the page's DOM, where it would sit in a variable +//! any script the page ever grows could read. + +use std::time::Duration; + +use serde::Deserialize; +use tauri::AppHandle; + +use crate::settings; + +/// Long enough for a cold API behind a proxy on a bad connection, short enough +/// that a person who typed the wrong address is told so while they still +/// remember typing it. +const TIMEOUT: Duration = Duration::from_secs(20); + +/// How a session travels when it is not in a cookie: `Authorization: Session +/// `, read by `SessionGuard` in `api/src/common/session.guard.ts`. +/// +/// Deliberately not `Bearer`, which the worker token owns. Two credentials +/// under one word is how one ends up sent where the other belongs, and the +/// direction that would matter is a machine's token being handed a person's +/// whole console. The API says the word back on login and this is the fallback +/// for a server that does not. +const SCHEME: &str = "Session"; + +/// What happened, for the sentence the window shows afterwards. +pub struct Enrolled { + pub email: String, + pub machine: String, + /// False when this computer kept the machine it already had. The + /// difference is worth showing: "set up as Shifa" and "already set up as + /// Shifa" answer different questions about what just happened. + pub minted: bool, +} + +/* ── Signing in ─────────────────────────────────────────────────────────── */ + +/// Sign in with an email and a password, then make sure this machine is +/// enrolled. +/// +/// The whole path, in the order a person experiences it, with every failure on +/// the way given its own sentence — because they are different things to go +/// and do: a typo in a password, a laptop with no network, a console that has +/// no account for you yet, an address pointing at nothing. +pub async fn sign_in(app: &AppHandle, email: &str, password: &str) -> Result { + let email = email.trim(); + if email.is_empty() || password.is_empty() { + return Err("Fill in your email and your password.".into()); + } + + let api = api(app)?; + let http = client()?; + + let me = login(&http, &api, email, password).await?; + let Some(session) = me.session.clone().filter(|s| !s.is_empty()) else { + /* + * Signed in, and handed nothing to keep. Only one thing does this: an + * API older than the `X-Session-Return` header, which sets the cookie + * and returns the user. It has to say so precisely, because everything + * the person can see says it worked — and the fix is on the server, + * not on this machine. + */ + return Err(format!( + "{api} signed you in but did not give this app a session to keep, so it cannot set \ + this machine up. That server is older than this app: whoever runs it needs to \ + update it. Until then you can still paste a machine token below." + )); + }; + + settings::set_session(&session)?; + settings::set_account_email(app, &me.email)?; + + let header = authorization(me.scheme.as_deref().unwrap_or(SCHEME), &session); + let machine = enrol_as(app, &http, &api, &header, &me.id).await?; + Ok(Enrolled { + email: me.email, + machine: machine.0, + minted: machine.1, + }) +} + +/// The sign-in request itself, with nothing stored and nothing decided. +/// +/// Separated from the flow above so that the conversation with a real API can +/// be run on its own — see the test at the bottom of this file, which is the +/// only way to find out that these `Deserialize` structs still match what the +/// server actually sends. A field renamed on the API would otherwise typecheck +/// perfectly here and fail on a teammate's laptop. +async fn login( + http: &reqwest::Client, + api: &str, + email: &str, + password: &str, +) -> Result { + let sent = http + .post(format!("{api}/api/auth/login")) + // The one header that makes this different from the web app's login. + // Without it the API sets a cookie and returns the user, exactly as it + // always has; with it, the session id comes back in the body for a + // client that has somewhere to keep it. See api/src/auth/auth.controller.ts. + .header("x-session-return", "1") + .json(&serde_json::json!({ "email": email, "password": password })) + .send() + .await + .map_err(|e| unreachable(api, &e))?; + + let status = sent.status().as_u16(); + let body = sent.text().await.unwrap_or_default(); + + if status == 401 { + /* + * A refused password and a console with no accounts on it are the same + * 401 and are not the same problem, so the app asks which it is before + * it says anything. `GET /auth/signup` is public and cheap, and + * getting this wrong is expensive in the way that matters: telling + * somebody their password is wrong when the truth is that nobody has + * made them an account sends them off to change a password that was + * fine. + */ + return Err(refused(signup_open(http, api).await).into()); + } + if !(200..300).contains(&status) { + return Err(complained(status, &body, api)); + } + serde_json::from_str(&body).map_err(|_| unreadable(api)) +} + +/// Set this machine up again without asking for a password, using the session +/// this app is already holding. +/// +/// The repair path, and the reason the session is kept at all rather than +/// being used once and dropped. A machine whose token was revoked, or whose +/// credential store was cleared, otherwise means typing a password again to +/// fix something the person did not break. +pub async fn enrol(app: &AppHandle) -> Result { + let session = settings::session(); + if session.is_empty() { + return Err("This app is not signed in to your console. Sign in above first.".into()); + } + + let api = api(app)?; + let http = client()?; + let header = authorization(SCHEME, &session); + + // Who the session belongs to, from the console rather than from anything + // remembered here: it is also how this finds out the session is still + // alive before it tries to mint anything with it. + let sent = http + .get(format!("{api}/api/auth/me")) + .header("authorization", &header) + .send() + .await + .map_err(|e| unreachable(&api, &e))?; + let status = sent.status().as_u16(); + let body = sent.text().await.unwrap_or_default(); + if !(200..300).contains(&status) { + return Err(complained(status, &body, &api)); + } + + let who: Me = serde_json::from_str(&body).map_err(|_| unreadable(&api))?; + let Some(person) = who.user else { + return Err(expired().into()); + }; + + settings::set_account_email(app, &person.email)?; + let machine = enrol_as(app, &http, &api, &header, &person.id).await?; + Ok(Enrolled { + email: person.email, + machine: machine.0, + minted: machine.1, + }) +} + +/* ── Enrolling ──────────────────────────────────────────────────────────── */ + +/// Make sure this computer has a machine on the console, and a token for it. +/// +/// Returns the machine's name and whether one had to be minted. +async fn enrol_as( + app: &AppHandle, + http: &reqwest::Client, + api: &str, + header: &str, + user_id: &str, +) -> Result<(String, bool), String> { + let machines = list(http, api, header).await?; + + /* + * The machine this computer already has, if it still has one. + * + * Identified by the id this app was handed when it minted the row, kept in + * `settings.json` beside the addresses, and checked against what the + * console currently lists. Not by hostname — two people's laptops are + * called DESKTOP-something and a person may rename a machine in Settings → + * Machines, which is a rename this app must not undo — and not by the + * token alone, because a token whose row was revoked authenticates as + * nothing and would leave this machine quietly doing no work. + * + * Both halves have to be there. The id says which row is ours; the token + * is the only thing that can actually claim jobs as it, and the console + * cannot hand it out a second time — it keeps a sha256 and nothing else. + * So an id with no token beside it is a row nothing can use, and the + * honest answer is a new one. + */ + let recorded = settings::machine_id(app); + let mine: Vec = machines + .iter() + .filter(|m| m.user_id == user_id) + .map(|m| m.id.clone()) + .collect(); + + if already_enrolled(&recorded, !settings::token().is_empty(), &mine) { + let name = machines + .iter() + .find(|m| m.id == recorded) + .map(|m| m.name.clone()) + .unwrap_or_default(); + return Ok((name, false)); + } + + let name = machine_name(); + let minted = mint(http, api, header, &name).await?; + if minted.token.is_empty() { + return Err(format!( + "{api} made a machine for this computer but did not send back its token, so there is \ + nothing for the worker to sign in with. Try again; if it keeps happening, \ + whoever runs the server should look at it." + )); + } + + /* + * The token first, then the id. + * + * If the store refuses, this stops with nothing recorded, and the next + * attempt mints another machine — one stale row somebody can delete. The + * other order would record an id whose token was never saved, and this app + * would then believe it was enrolled forever while doing no work at all. + */ + settings::set_token(&minted.token)?; + settings::set_machine_id(app, &minted.machine.id)?; + Ok((minted.machine.name, true)) +} + +/// Whether this computer may keep the machine it already has. +/// +/// Pulled out with no HTTP in it because it is the rule, and the rule is the +/// thing worth testing: a wrong answer here either mints a machine on every +/// sign-in — the console filling up with rows named after one laptop, each +/// with a live token — or keeps one that cannot work. +/// +/// `mine` is deliberately only the signed-in person's machines. The console +/// lists everybody's, and a computer handed from one teammate to another must +/// not carry on claiming jobs as the first: their CLI login, their +/// subscription, their name in the picker. Signing in as somebody else on a +/// machine enrolled to a colleague enrols it again, as them. +pub fn already_enrolled(recorded: &str, have_token: bool, mine: &[String]) -> bool { + !recorded.is_empty() && have_token && mine.iter().any(|id| id == recorded) +} + +/// What to call a machine nobody has named yet. +/// +/// The hostname, which is what the person will recognise in the picker before +/// anybody has thought of anything better, and it is only ever a *first* name: +/// `registerWorker` on the server does not take a name on conflict, so the +/// moment somebody calls this "Rakib's desktop" in Settings → Machines it stays +/// that, through every re-registration and every re-enrolment. +fn machine_name() -> String { + for var in ["COMPUTERNAME", "HOSTNAME"] { + if let Ok(value) = std::env::var(var) { + let value = value.trim().to_string(); + if !value.is_empty() { + return value; + } + } + } + // Windows always sets COMPUTERNAME, and Windows is what this app is built + // for. Somewhere that sets neither gets something a person can at least + // recognise as "the one that did not know", and can rename. + "A teammate's machine".into() +} + +/* ── Talking to the API ─────────────────────────────────────────────────── */ + +#[derive(Deserialize)] +struct Person { + id: String, + email: String, + /// Present only because this app asked for it with `X-Session-Return`. + session: Option, + /// The scheme to send it back under, named by the server that issued it so + /// that this app is not the place the word `Session` is guessed. + scheme: Option, +} + +#[derive(Deserialize)] +struct Me { + user: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Machine { + id: String, + user_id: String, + name: String, +} + +#[derive(Deserialize)] +struct Minted { + machine: Machine, + token: String, +} + +/// Mint a machine and its token: one row, one credential, handed back once. +async fn mint( + http: &reqwest::Client, + api: &str, + header: &str, + name: &str, +) -> Result { + let sent = http + .post(format!("{api}/api/machines")) + .header("authorization", header) + .json(&serde_json::json!({ "name": name })) + .send() + .await + .map_err(|e| unreachable(api, &e))?; + let status = sent.status().as_u16(); + let body = sent.text().await.unwrap_or_default(); + if status == 401 { + return Err(expired().into()); + } + if !(200..300).contains(&status) { + return Err(complained(status, &body, api)); + } + serde_json::from_str(&body).map_err(|_| unreadable(api)) +} + +async fn list(http: &reqwest::Client, api: &str, header: &str) -> Result, String> { + let sent = http + .get(format!("{api}/api/machines")) + .header("authorization", header) + .send() + .await + .map_err(|e| unreachable(api, &e))?; + let status = sent.status().as_u16(); + let body = sent.text().await.unwrap_or_default(); + if status == 401 { + return Err(expired().into()); + } + if !(200..300).contains(&status) { + return Err(complained(status, &body, api)); + } + serde_json::from_str(&body).map_err(|_| unreadable(api)) +} + +/// Whether this console still has no owner, which is the one time signing up +/// is possible at all. +/// +/// A failure to ask is an answer of "closed": the sentence for a closed +/// console is the one that fits an ordinary wrong password, so guessing wrong +/// in that direction only costs a suggestion about invites. +async fn signup_open(http: &reqwest::Client, api: &str) -> bool { + #[derive(Deserialize)] + struct Open { + open: bool, + } + match http.get(format!("{api}/api/auth/signup")).send().await { + Ok(sent) => sent.json::().await.map(|o| o.open).unwrap_or(false), + Err(_) => false, + } +} + +fn client() -> Result { + /* + * The one thing `rustls-no-provider` leaves to the application: which + * cryptography actually runs. It is not chosen by the dependency so that + * an app carrying two users of rustls cannot end up with two providers + * fighting over the process-wide default. + * + * Installed here as well as in tauri-plugin-updater — the same call, the + * same provider, guarded the same way — because whichever happens first + * has to work: signing in is usually long before anybody clicks Check for + * updates, and a client built with no provider fails to build at all, + * which would arrive as "could not open a connection" on a machine whose + * network is perfect. The `let _` is the second caller finding the first + * already there, which is the expected outcome, not an error. + */ + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + reqwest::Client::builder() + .timeout(TIMEOUT) + // The console records this against the session and shows it in + // Settings → Team, so a person looking at their own sessions can tell + // the desktop app from a browser and end the right one. + .user_agent(concat!("ContentOS-Desktop/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| format!("This app could not open a connection at all: {e}")) +} + +fn api(app: &AppHandle) -> Result { + let api = settings::api_url(app); + if api.is_empty() { + return Err(settings::Which::Api.missing().into()); + } + Ok(api) +} + +/// The `Authorization` header value, built in one place. +/// +/// The scheme comes from the server that issued the session, but it is checked +/// before it is used: a value with anything odd in it would either be refused +/// by the HTTP client or, in the worst reading, be a way for a response to +/// write a second header. `Session` is what this app expects and what it falls +/// back to. +fn authorization(scheme: &str, session: &str) -> String { + let scheme = if scheme.chars().all(|c| c.is_ascii_alphabetic()) && !scheme.is_empty() { + scheme + } else { + SCHEME + }; + format!("{scheme} {session}") +} + +/* ── What went wrong, in the app's own words ────────────────────────────── */ + +/// The console said no to an email and a password. +pub fn refused(signup_open: bool) -> &'static str { + if signup_open { + "There are no accounts on this console yet, so there is nothing to sign in to. The very \ + first account — the owner's — is made on the console itself: choose Open the console, \ + sign up there, then come back here and sign in." + } else { + "That email and password do not match an account on this console. Accounts here are \ + invite-only, so if you have never signed in before, ask whoever runs the console for an \ + invite link." + } +} + +/// The session this app was holding is not good any more. +fn expired() -> &'static str { + "Your sign-in on this machine has run out, or somebody signed it out from Settings → Team. \ + Sign in again above." +} + +fn unreachable(api: &str, e: &reqwest::Error) -> String { + /* + * The API, named, and said to be the API — not the console. + * + * They are two hosts on any real deployment, and the person reading this + * has very often just watched the console window load perfectly, so a + * sentence about "the server" reads as obviously false and gets dismissed. + * The address is in it because that is the one fact that turns "it will + * not connect" into something somebody can check. + */ + let what = if e.is_timeout() { + "did not answer in time" + } else if e.is_connect() { + "cannot be reached" + } else { + "did not answer" + }; + format!( + "{api} {what}. That is the API address this machine is set up with — the server the \ + worker asks for jobs, which is not the console. Check that you are online, and that the \ + API address shown above is the one your team uses. ({e})" + ) +} + +/// The API answered, and it was not an answer this app can use. +fn unreadable(api: &str) -> String { + format!( + "{api} answered with something this app could not read. That address may not be the \ + Content OS API — a console address, or a proxy's error page, both look like this." + ) +} + +/// The API refused, and said why in its own words. +/// +/// The server's sentence is shown rather than replaced. `ErrorsFilter` on the +/// API writes these for people — "Give the machine a name", "This console is +/// invite-only" — and an app that swallowed them in favour of "something went +/// wrong" would be hiding the only part of the message that says what to do. +fn complained(status: u16, body: &str, api: &str) -> String { + #[derive(Deserialize)] + struct Said { + error: String, + } + match serde_json::from_str::(body) { + Ok(said) if !said.error.is_empty() => said.error, + _ => format!("{api} refused this app's request ({status})."), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(all: &[&str]) -> Vec { + all.iter().map(|s| s.to_string()).collect() + } + + /// The ordinary second sign-in: the machine is there and the token is + /// here, so nothing is minted. Getting this wrong fills Settings → + /// Machines with a row per launch, each holding a live credential. + #[test] + fn a_machine_that_is_still_listed_is_kept() { + assert!(already_enrolled( + "wrk_1", + true, + &ids(&["wrk_9", "wrk_1"]) + )); + } + + /// Revoked in Settings → Machines. The row is gone, so the token in the + /// store authenticates as nothing, and the honest answer is a new machine + /// — asked for by somebody who has just proved they are who they say. + #[test] + fn a_revoked_machine_is_replaced() { + assert!(!already_enrolled("wrk_1", true, &ids(&["wrk_9"]))); + } + + /// The row exists and the token does not — a cleared credential store, a + /// profile restored without it. The console keeps only a sha256 and cannot + /// hand the token out again, so that row can never work. + #[test] + fn a_machine_whose_token_is_gone_is_replaced() { + assert!(!already_enrolled("wrk_1", false, &ids(&["wrk_1"]))); + } + + /// A computer handed to somebody else. `mine` holds only the signed-in + /// person's machines, so a row belonging to a colleague is not a row this + /// sign-in may keep: the jobs it claims would spend their subscription and + /// carry their name in the picker. + #[test] + fn somebody_elses_machine_is_not_reused() { + assert!(!already_enrolled("wrk_1", true, &ids(&["wrk_2"]))); + } + + #[test] + fn a_computer_that_has_never_enrolled_mints_one() { + assert!(!already_enrolled("", true, &ids(&["wrk_1"]))); + } + + /// The two 401s a person can hit, and they must not share a sentence: one + /// is a password, the other is a console nobody has made an account on. + #[test] + fn the_two_refusals_say_different_things() { + assert!(refused(true).contains("no accounts on this console yet")); + assert!(refused(false).contains("invite-only")); + } + + /// The whole conversation, against an API that is actually running. + /// + /// Every other test here is a rule with no network in it, which is the + /// right shape for a rule — and leaves one thing entirely unchecked: that + /// the `Deserialize` structs above still describe what the server sends. + /// A field renamed on the API compiles perfectly here and fails on a + /// teammate's laptop, with a sentence about an address being wrong. + /// + /// So this one signs in for real, lists the machines, mints one, and + /// checks that the reuse rule then recognises it. It skips itself unless + /// it is told where to go — the same shape as `tests/queue.test.ts`, which + /// skips when there is no Postgres — because a test that needs a server + /// must never be the reason `cargo test` is red on a laptop with none: + /// + /// ```text + /// CONTENTOS_TEST_API=http://localhost:4010 \ + /// CONTENTOS_TEST_EMAIL=owner@example.com \ + /// CONTENTOS_TEST_PASSWORD=… \ + /// cargo test --manifest-path src-tauri/Cargo.toml -- --nocapture enrols_against + /// ``` + /// + /// It mints a machine every run, so point it at a throwaway console rather + /// than at the team's. + #[test] + fn signs_in_and_enrols_against_a_real_api() { + let (Ok(api), Ok(email), Ok(password)) = ( + std::env::var("CONTENTOS_TEST_API"), + std::env::var("CONTENTOS_TEST_EMAIL"), + std::env::var("CONTENTOS_TEST_PASSWORD"), + ) else { + println!("skipped: CONTENTOS_TEST_API, _EMAIL and _PASSWORD name no console"); + return; + }; + + tauri::async_runtime::block_on(async move { + let http = client().expect("a client"); + + let me = login(&http, &api, &email, &password).await.expect("sign in"); + let session = me + .session + .clone() + .filter(|s| !s.is_empty()) + .expect("the API returned no session — is X-Session-Return read?"); + println!("signed in as {} ({})", me.email, me.id); + + // Sent back the way the app sends it, so this covers the scheme as + // well as the session: a `Bearer` here would be refused, which is + // the whole point of the two words being different. + let header = authorization(me.scheme.as_deref().unwrap_or(SCHEME), &session); + + let before = list(&http, &api, &header).await.expect("list machines"); + let minted = mint(&http, &api, &header, "A test machine") + .await + .expect("mint a machine"); + assert!(minted.token.starts_with("wrk_"), "a worker token"); + println!("minted {} \"{}\"", minted.machine.id, minted.machine.name); + + let after = list(&http, &api, &header).await.expect("list again"); + assert_eq!(after.len(), before.len() + 1); + let mine: Vec = after + .iter() + .filter(|m| m.user_id == me.id) + .map(|m| m.id.clone()) + .collect(); + assert!(already_enrolled(&minted.machine.id, true, &mine)); + + // And the password still has to be right, over the same client + // against the same server — a login that accepts anything would + // pass every assertion above. + assert!(login(&http, &api, &email, "not-the-password").await.is_err()); + }); + } + + /// The scheme is the server's word, but only if it is a word. + #[test] + fn the_scheme_is_checked_before_it_is_sent() { + assert_eq!(authorization("Session", "abc"), "Session abc"); + assert_eq!(authorization("", "abc"), "Session abc"); + assert_eq!(authorization("Bad\r\nX-Evil: 1", "abc"), "Session abc"); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d201d27..64abe6b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -16,6 +16,7 @@ //! what cannot live there — where the console and the API are, this machine's //! token, and whether the worker beside it is actually running. +mod account; mod settings; mod tray; mod updates; @@ -31,6 +32,83 @@ fn status(app: AppHandle, worker: State<'_, Worker>) -> View { worker.view(&app) } +/// Sign in as a person, and set this machine up. +/// +/// The ordinary way in, and the only one a teammate should ever need: an email +/// and a password. Everything after that is this app's job — keep the session, +/// mint this machine's worker token through `POST /api/machines`, store it, +/// start the worker — and it is `src/account.rs` that does it, including every +/// sentence for every way it can fail. +/// +/// The console is opened at the end because that is what the person came for. +/// A window that stayed on the setup screen after a successful sign-in would +/// be a window that looks like nothing happened. +#[tauri::command] +async fn sign_in(app: AppHandle, email: String, password: String) -> Result { + let done = account::sign_in(&app, &email, &password).await?; + let view = restart_worker(&app); + windows::console(&app); + Ok(Done::from(done, view)) +} + +/// Set this machine up again, without asking for a password. +/// +/// The repair, and the reason the session is kept rather than used once: a +/// machine somebody revoked, or a credential store that was cleared, would +/// otherwise cost a password for something the person did not break. It is +/// offered only while this app holds a session, and says so when it does not. +#[tauri::command] +async fn enrol(app: AppHandle) -> Result { + let done = account::enrol(&app).await?; + let view = restart_worker(&app); + Ok(Done::from(done, view)) +} + +/// What just happened, and what is true now. +/// +/// The window needs both: a sentence about the sign-in that has just finished, +/// and the same `View` every other command hands back so the status panel and +/// the addresses are redrawn from one answer rather than from a second call +/// that could disagree with this one. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct Done { + said: String, + view: View, +} + +impl Done { + fn from(done: account::Enrolled, view: View) -> Self { + let machine = if done.machine.is_empty() { + "this machine".to_string() + } else { + done.machine + }; + Done { + said: if done.minted { + format!( + "Signed in as {}. This computer is now set up as \"{machine}\" and is taking \ + jobs. You can rename it on the console, in Settings → Machines.", + done.email + ) + } else { + format!( + "Signed in as {}. This computer was already set up as \"{machine}\", so it \ + kept it rather than adding a second one.", + done.email + ) + }, + view, + } + } +} + +fn restart_worker(app: &AppHandle) -> View { + let worker = app.state::(); + worker.start(app); + worker.view(app) +} + /// Take what the person filled in, keep it, and start. /// /// Blank means "leave it as it is", for all three fields and for two different @@ -81,15 +159,18 @@ fn save( settings::set_url(&app, which, &url)?; } + /* + * The token is the way in for somebody who needs it, and no longer the way + * in. Since signing in mints this machine's token by itself, a blank field + * here is the ordinary case rather than a mistake, and refusing the Save + * over it — which this used to do — would stop a person fixing an address + * before they can sign in at all. What is still missing is said by the + * worker's own status the moment it tries to start, in the same words and + * in the place a person is already looking. + */ let typed = token.trim(); if !typed.is_empty() { settings::set_token(typed)?; - } else if settings::token().is_empty() { - return Err( - "Paste this machine's token. On the console it is Settings → Machines → add this machine, \ - and it is shown once." - .into(), - ); } worker.start(&app); @@ -121,6 +202,8 @@ fn main() { .manage(Worker::default()) .invoke_handler(tauri::generate_handler![ status, + sign_in, + enrol, save, restart, open_console diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index a4fd794..bc7371f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -8,8 +8,8 @@ //! produce either a session that survives Team → Remove or a worker token that //! can read the whole content library. //! -//! So this file owns exactly one of the two. The cookie is the webview's -//! business and is never touched here. What lives here is: +//! So this file owns exactly one of the two — and, since the app started +//! signing people in itself, one thing that is neither. What lives here is: //! //! - **the two addresses** — the console this app opens a window onto, and the //! API the worker calls for jobs. Each comes from the build, from the @@ -23,6 +23,24 @@ //! `%APPDATA%` would undo that at the other end, where it is trivially //! readable by anything running as this user and would end up in the first //! backup somebody takes of their profile. +//! - **the session** this app signed in with, in the credential store beside +//! the token and for the same reason. It is the *person's* credential, not +//! the machine's, so it is emphatically not a third principal: it is the +//! same session row a browser would have held in a cookie, sent as +//! `Authorization: Session ` because a webview on a local origin cannot +//! keep a cookie for somebody else's server. It exists here at all because +//! the deployment this is heading for has no hosted console to visit, so a +//! token minted in Settings → Machines and pasted in is an instruction with +//! nowhere to follow it: the app asks for an email and a password, and mints +//! this machine's token for itself through `POST /api/machines`. +//! +//! Two credentials in one store, then, and it is worth being precise about why +//! that is still the two `docs/WORKER.md` argues for rather than a merge. They +//! are separate entries with separate names, they authenticate different +//! things — a person, and a machine — and either can be taken away without the +//! other: Settings → Team ends the session, Settings → Machines revokes the +//! token. What this app does is hold both, which is exactly what a teammate +//! with a browser and a pasted token was already doing. //! //! **Why the addresses are two and not one.** `docs/DEPLOYING.md` puts the web //! app on `APP_HOST` and the NestJS API on `API_HOST` — two hostnames, and the @@ -58,6 +76,16 @@ const SERVICE: &str = "com.contentos.desktop"; /// nothing here to key by a user name. const ACCOUNT: &str = "worker-token"; +/// And the person's session, under its own name in the same store. +/// +/// A separate entry rather than a second field in one blob, because they are +/// separately revoked and separately replaced: signing in again writes this +/// and leaves the machine's token alone, and a machine re-enrolled writes the +/// token and leaves the session alone. It is named for what it is so that +/// somebody scrolling through Windows Credential Manager can tell the two +/// apart without opening either. +const SESSION_ACCOUNT: &str = "console-session"; + /// Which of the two addresses is being talked about. /// /// They are found by the same `pick`, saved in the same file, and drawn by the @@ -99,7 +127,7 @@ impl Which { /// console is the thing they already open in a browser, whereas nobody has /// a reason to know the worker calls a *second* host until they are told, /// and "the API address" on its own would send them off to guess one. - fn missing(self) -> &'static str { + pub fn missing(self) -> &'static str { match self { Which::Console => { "The console address is where your team's Content OS is, for example \ @@ -125,6 +153,14 @@ impl Which { struct Stored { console_url: String, api_url: String, + /// The machine row this computer already has on the console, if it has + /// one. Not a credential — it is the id the picker shows beside a name — + /// and it is what stops a second machine being minted on every sign-in. + machine_id: String, + /// Who signed in here last, for the one line on the setup window that says + /// so. The session itself is in the credential store; this is the label, + /// and it is only ever shown while there is a session to go with it. + account_email: String, } fn file(app: &AppHandle) -> Option { @@ -326,25 +362,60 @@ fn pick(which: Which, env: Option<&str>, saved: Option<&str>, built_in: Option<& } } -/// Save one address on this machine, keeping the other. +/// Change one field of the file, keeping everything else in it. /// -/// Read, change one field, write the whole file back. The file is one JSON -/// object, and a write built from a single value would drop the other address -/// silently — which is not hypothetical, because the setup window saves the -/// two one after the other and the second write of a single Save would erase -/// the first. -pub fn set_url(app: &AppHandle, which: Which, url: &str) -> Result<(), String> { +/// Read, change, write the whole object back — in one place, because the file +/// is one JSON object and a write built from a single value would drop the +/// rest of it silently. That is not hypothetical: the setup window saves the +/// two addresses one after the other, and the second write of a single Save +/// would erase the first. It is a sharper edge now that the machine id and the +/// signed-in email live here too, since those are written from a different +/// screen entirely. +fn write(app: &AppHandle, change: impl FnOnce(&mut Stored)) -> Result<(), String> { let path = file(app).ok_or("This machine has no application data folder to write to.")?; if let Some(dir) = path.parent() { fs::create_dir_all(dir).map_err(|e| format!("Could not create {}: {e}", dir.display()))?; } let mut stored = stored(app); - match which { + change(&mut stored); + let body = serde_json::to_string_pretty(&stored).map_err(|e| e.to_string())?; + fs::write(&path, body).map_err(|e| format!("Could not write {}: {e}", path.display())) +} + +/// Save one address on this machine, keeping the other. +pub fn set_url(app: &AppHandle, which: Which, url: &str) -> Result<(), String> { + write(app, |stored| match which { Which::Console => stored.console_url = url.to_string(), Which::Api => stored.api_url = url.to_string(), + }) +} + +/// The machine this computer is already enrolled as, or an empty string. +pub fn machine_id(app: &AppHandle) -> String { + stored(app).machine_id +} + +pub fn set_machine_id(app: &AppHandle, id: &str) -> Result<(), String> { + write(app, |stored| stored.machine_id = id.to_string()) +} + +/// Who is signed in here, as far as this machine knows — and only while there +/// is a session to go with it. +/// +/// The two are checked together on the way out rather than being kept in step +/// on the way in, because they live in two different places and one of them +/// can vanish on its own: a credential store that has been cleared, a profile +/// restored without it. A name with no session behind it would be a window +/// saying "signed in as Rakib" over a worker that cannot enrol anything. +pub fn account_email(app: &AppHandle) -> String { + if session().is_empty() { + return String::new(); } - let body = serde_json::to_string_pretty(&stored).map_err(|e| e.to_string())?; - fs::write(&path, body).map_err(|e| format!("Could not write {}: {e}", path.display())) + stored(app).account_email +} + +pub fn set_account_email(app: &AppHandle, email: &str) -> Result<(), String> { + write(app, |stored| stored.account_email = email.to_string()) } /// The token, or an empty string when this machine has never been given one. @@ -353,17 +424,46 @@ pub fn set_url(app: &AppHandle, which: Which, url: &str) -> Result<(), String> { /// here", and the worker then says it is not set up — which is the honest /// thing for the person to see, and better than an app that will not start. pub fn token() -> String { - keyring::Entry::new(SERVICE, ACCOUNT) + secret(ACCOUNT) +} + +pub fn set_token(value: &str) -> Result<(), String> { + set_secret(ACCOUNT, value, "this machine's token") +} + +/// The session this app signed in with, or an empty string. +/// +/// Read exactly like the token, and unreadable for exactly the same reasons — +/// a locked store, a profile without it — which reads as "nobody is signed in +/// here", which is a screen with a sign-in form on it rather than an app that +/// will not start. +pub fn session() -> String { + secret(SESSION_ACCOUNT) +} + +pub fn set_session(value: &str) -> Result<(), String> { + set_secret(SESSION_ACCOUNT, value, "your sign-in") +} + +fn secret(account: &str) -> String { + keyring::Entry::new(SERVICE, account) .and_then(|entry| entry.get_password()) .unwrap_or_default() } -pub fn set_token(value: &str) -> Result<(), String> { - let entry = keyring::Entry::new(SERVICE, ACCOUNT) +/// One place that writes to the credential store, and one sentence shape for +/// when it refuses. +/// +/// `what` is the person's word for the thing — "this machine's token", "your +/// sign-in" — because a store that says no is a rare enough event that the +/// message has to be readable on its own, without the reader knowing which of +/// the two calls made it. +fn set_secret(account: &str, value: &str, what: &str) -> Result<(), String> { + let entry = keyring::Entry::new(SERVICE, account) .map_err(|e| format!("Could not open this machine's credential store: {e}"))?; - entry - .set_password(value) - .map_err(|e| format!("Could not save the token to this machine's credential store: {e}")) + entry.set_password(value).map_err(|e| { + format!("Could not save {what} to this machine's credential store: {e}") + }) } /// Turn what somebody typed into the origin that will actually be used. diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 019d3c3..1754f93 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -54,6 +54,10 @@ pub fn setup(app: &AppHandle) { // is what prevents. let _ = WebviewWindowBuilder::new(app, "setup", WebviewUrl::App("setup.html".into())) .title("Content OS — this machine") - .inner_size(560.0, 600.0) + // Tall enough for the three panels — status, sign in, addresses — on a + // first run, because the one that matters on a first run is the middle + // one, and a window that opens with the sign-in below the fold is a + // window somebody reads as "nothing here to do". + .inner_size(560.0, 780.0) .build(); } diff --git a/src-tauri/src/worker.rs b/src-tauri/src/worker.rs index cdab5e5..c72089f 100644 --- a/src-tauri/src/worker.rs +++ b/src-tauri/src/worker.rs @@ -156,6 +156,14 @@ pub struct View { /// reason to hold a credential it cannot do anything with, and a webview's /// DOM is the last place it should be sitting. pub has_token: bool, + /// Who this app is signed in to the console as, or empty when nobody is. + /// + /// The email and never the session, for the same reason as the line above: + /// the page draws a name, and a credential in the DOM is a credential in + /// whatever the page ever grows into. `settings::account_email` only + /// answers while there is a session in the credential store to go with it, + /// so this cannot say "signed in as Rakib" over a machine that is not. + pub signed_in_as: String, pub log: Vec, } @@ -195,6 +203,7 @@ impl Worker { built_in_api_url: api.built_in(), api_url: api.url, has_token: !settings::token().is_empty(), + signed_in_as: settings::account_email(app), log: inner.log.iter().cloned().collect(), } } @@ -227,7 +236,7 @@ impl Worker { let token = settings::token(); if console.is_empty() || api.is_empty() || token.is_empty() { /* - * Four sentences rather than one, because they are four different + * Five sentences rather than one, because they are five different * things to go and do — and because a teammate with an installer * that knows where both servers are has exactly one of them left, * and asking them for an address the app already has would send @@ -238,28 +247,38 @@ impl Worker { * address, or built by somebody who set only the first. That copy * has a console and no API, and "fill in the address" would send * its owner to check the one that is already right. + * + * The last two are the ordinary first run, and they are two + * because a person who has already signed in must not be told to + * sign in. Signing in is what mints this machine's token now, so + * "no token" nearly always means "has not signed in yet"; when it + * does not — a store that was cleared, a machine revoked — the + * thing to do is the one-click repair, not a password. */ + let signed_in = !settings::account_email(app).is_empty(); return self.set( app, Health::NotConfigured, match (console.is_empty(), api.is_empty()) { (true, true) => { - "Fill in the console and API addresses and this machine's token below, then \ - choose Save and start." + "Fill in the console and API addresses below, then sign in with your email \ + and password." } (true, false) => { - "Fill in the console address and this machine's token below, then choose \ - Save and start." + "Fill in the console address below, then sign in with your email and \ + password." } (false, true) => { "This copy knows the console but not the API — the server the worker asks \ - for jobs, which is a different host. Fill it in below, then choose Save \ - and start." + for jobs, which is a different host. Fill it in below, then sign in." + } + (false, false) if signed_in => { + "This app is signed in, but this machine has no worker token of its own. \ + Choose Set this machine up again below and it will get one." } (false, false) => { - "Paste this machine's token below, then choose Save and start. On the \ - console it is Settings → Machines → add this machine, and it is shown \ - once." + "Sign in below with the email and password you use for Content OS. This \ + app will set this machine up itself — there is nothing to paste." } }, ); @@ -557,8 +576,16 @@ fn classify(line: &str) -> Option<(Health, String)> { if line.contains("did not recognise CONTENTOS_WORKER_TOKEN") { return Some(( Health::BadToken, - "The console does not recognise this machine's token. On the console, go to Settings → Machines, \ - add this machine again, and paste the new token below." + /* + * This used to be a trip to Settings → Machines on a web console + * to mint a token and paste it back. There may not be a console to + * go to — the deployment this is heading for is the API alone — + * and there does not need to be: this app can mint a new one for + * itself from the sign-in it already holds, which is one button. + */ + "The console does not recognise this machine's token — it was probably revoked in \ + Settings → Machines. Choose Set this machine up again below and this app will get a \ + new one. If it asks you to sign in first, sign in." .into(), )); } diff --git a/src-tauri/ui/setup.html b/src-tauri/ui/setup.html index 0e4721b..89974ef 100644 --- a/src-tauri/ui/setup.html +++ b/src-tauri/ui/setup.html @@ -9,10 +9,19 @@ Everything else in Content OS is served by the team's console and shown in a webview, deliberately: a second copy of the UI here would be a second thing to keep in step, and it would be the copy nobody remembered to - update. But this machine's token has to be given before there IS a - console to load, so it cannot come from one — and a person who has to - open a terminal to fix their own machine is the failure this whole app - exists to prevent, so the worker's status is on this page too. + update. But this machine has to be set up before there IS a console to + load, so it cannot be set up from one — and a person who has to open a + terminal to fix their own machine is the failure this whole app exists to + prevent, so the worker's status is on this page too. + + What it asks for is an email and a password, and nothing else. It used to + ask for a worker token minted in Settings → Machines on a web console, + which is an instruction with nowhere to follow it once only the API is + deployed. So the app signs in, keeps the session, and mints this + machine's token for itself through POST /api/machines — see + src-tauri/src/account.rs. The token field is still here, one disclosure + down, because somebody handed a token by a colleague should not be + stopped; it is simply not the way in any more. The addresses used to be the second question here and mostly are not any more: an installer is built with both, so the fields are folded away @@ -98,8 +107,17 @@ button.primary { background: var(--accent); border-color: transparent; } button.primary:hover { background: #1a68ff; } .spacer { flex: 1; } - #error { color: var(--bad); margin: 12px 0 0; min-height: 0; } - #error:empty { display: none; } + #error, #account-error { color: var(--bad); margin: 12px 0 0; min-height: 0; } + #error:empty, #account-error:empty { display: none; } + /* + What just happened, in green, and only while it is news. It is a + separate line from the status panel above on purpose: that one says + what is true now, this one says what the button did, and a person who + pressed Sign in needs both — "connected" alone does not tell them + whether a machine was made or reused. + */ + #account-said { color: var(--good); margin: 12px 0 0; } + #account-said:empty { display: none; } details { margin-top: 4px; } summary { color: var(--mute); cursor: pointer; } /* @@ -174,6 +192,51 @@

This machine

+ +
+

Sign in to Content OS

+ + + +
+ + + + +

+ The same email and password you use for Content OS. This machine is + set up for you — there is no token to find and nothing to paste. +

+
+ +
+
+ +

+

+
+
+
+ Set this machine up with a token instead + + +

+ Minted on the console under Settings → Machines and shown once. It is + kept in Windows Credential Manager, not in a file. +

+
- +
@@ -238,7 +314,15 @@

This machine

const { listen } = window.__TAURI__.event; const el = (id) => document.getElementById(id); - const said = { state: "", message: "" }; + + /* + Whether the sign-in fields are on screen for somebody who is already + signed in. It is a thing the person asked for by clicking, not a fact + about the machine, so it lives here and not in the view Rust sends — + otherwise every status tick from the worker would fold the form away + again while they were typing in it. + */ + let switching = false; function render(view) { el("dot").className = "dot " + view.state; @@ -246,11 +330,32 @@

This machine

el("message").textContent = view.message; el("log").textContent = view.log.join("\n") || "Nothing yet."; addresses(view); + account(view); if (view.hasToken && !el("token").value) { el("token").placeholder = "Saved — leave blank to keep it"; } } + /* + Who this app is signed in as, if anybody. + + The two faces are not two states of one form: one says "here is who you + are, and here is the one button that fixes a machine", the other asks + the only question this window has. Both can be on screen at once, and + only when somebody has said they are handing the machine over — a + person who is signed in should not have to look at a password field + they have no reason to fill in. + */ + function account(view) { + const signedIn = Boolean(view.signedInAs); + el("signed-in").hidden = !signedIn; + el("account-email").textContent = view.signedInAs; + el("signed-out").hidden = signedIn && !switching; + // The repair only means anything with a session behind it, and the + // sentence for having none is in Rust, where the reason lives. + el("re-enrol").disabled = !signedIn; + } + /* The two addresses, described once. @@ -351,6 +456,67 @@

This machine

}); } + /* + One place where a button that talks to the console is pressed. + + Every one of them can take a few seconds, can fail with a sentence the + person is meant to read, and must not be pressable twice while it is + running — a second sign-in mid-flight would race the first and could + mint two machines for one computer. The button says what it is doing + while it does it, because a window that looks frozen is a window + somebody clicks again. + */ + async function talk(button, working, call) { + const label = button.textContent; + button.disabled = true; + button.textContent = working; + el("account-error").textContent = ""; + el("account-said").textContent = ""; + try { + const done = await call(); + el("account-said").textContent = done.said; + el("password").value = ""; + switching = false; + render(done.view); + } catch (e) { + // Every sentence here is written in src/account.rs, where the reason + // for it lives: a wrong password, a server that cannot be reached, a + // console with no accounts on it yet. The page shows it as it is + // rather than replacing it with something vaguer. + el("account-error").textContent = String(e); + } finally { + button.disabled = false; + button.textContent = label; + } + } + + const signIn = () => + talk(el("sign-in"), "Signing in…", () => + invoke("sign_in", { + email: el("email").value, + password: el("password").value, + }), + ); + + el("sign-in").addEventListener("click", signIn); + el("re-enrol").addEventListener("click", () => + talk(el("re-enrol"), "Setting up…", () => invoke("enrol")), + ); + + // Enter in either field signs in. A form of two fields where the only + // button is the obvious one should not need the mouse. + for (const id of ["email", "password"]) { + el(id).addEventListener("keydown", (e) => { + if (e.key === "Enter" && !el("sign-in").disabled) signIn(); + }); + } + + el("switch-account").addEventListener("click", () => { + switching = true; + el("signed-out").hidden = false; + el("email").focus(); + }); + el("save").addEventListener("click", async () => { el("error").textContent = ""; try { diff --git a/tests/session-credentials.test.ts b/tests/session-credentials.test.ts new file mode 100644 index 0000000..27a3389 --- /dev/null +++ b/tests/session-credentials.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { sessionFromHeader } from "@/api/src/common/session.guard"; + +/** + * Which credential is which. + * + * The API takes three now — the session cookie a browser sends, the session id + * the desktop app sends as `Authorization: Session ` because a webview on + * a local origin cannot keep a third-party cookie, and the worker token a + * machine sends as `Authorization: Bearer `. Two of those three arrive + * in the same header, and telling them apart is one regex in `SessionGuard`. + * + * That regex is worth a test for one specific failure: a `Bearer` header + * reading as a session. A worker token is a machine's credential over nine + * queue routes; a session is a person's credential over the whole console — + * every run, every template, and the ability to spend model budget. A guard + * that accepted either word would let the first stand in for the second, which + * is the merge `docs/WORKER.md` argues against under "The desktop app holds + * two credentials, on purpose". Nothing about that is visible in a screenshot + * and nothing in `tsc` would notice a loosened pattern. + * + * The lookup itself is not tested here on purpose: it is `sessionUser`, the + * same call the cookie path has always made, and a second copy of that + * assertion would only be testing Postgres. + */ +describe("what an Authorization header counts as", () => { + it("reads a session sent under its own scheme", () => { + expect(sessionFromHeader("Session abc123")).toBe("abc123"); + }); + + it("is not fussy about the case or the spacing a client uses", () => { + expect(sessionFromHeader("session abc123")).toBe("abc123"); + expect(sessionFromHeader(" SESSION abc123 ")).toBe("abc123"); + }); + + /* + * The one that matters. `Bearer` belongs to `WorkerGuard`, and a worker + * token that satisfied the console's guard would be a stolen machine + * credential reading the content library. + */ + it("refuses a worker's bearer token", () => { + expect(sessionFromHeader("Bearer wrk_aaaabbbbcccc")).toBe(""); + }); + + it("refuses anything else, including a scheme that only starts the same", () => { + expect(sessionFromHeader("Basic dXNlcjpwYXNz")).toBe(""); + expect(sessionFromHeader("Sessionabc123")).toBe(""); + expect(sessionFromHeader("Session")).toBe(""); + expect(sessionFromHeader("Session ")).toBe(""); + expect(sessionFromHeader("")).toBe(""); + expect(sessionFromHeader(undefined)).toBe(""); + }); +}); From e794268849a4d6b1e76ab01a0e64645e0692baea Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sun, 6 Sep 2026 13:14:00 +0600 Subject: [PATCH 23/30] Sign in once, and let the console window inherit it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/src/auth/auth.controller.ts | 92 ++++++++++++- docs/DEPLOYING.md | 9 ++ docs/WORKER.md | 108 ++++++++++++++- lib/server/auth.ts | 191 ++++++++++++++++++++++++++ lib/server/db/schema.ts | 57 ++++++++ src-tauri/src/account.rs | 229 +++++++++++++++++++++++++++++++- src-tauri/src/main.rs | 6 + src-tauri/src/windows.rs | 66 +++++++-- tests/handoff.test.ts | 100 ++++++++++++++ 9 files changed, 838 insertions(+), 20 deletions(-) create mode 100644 tests/handoff.test.ts diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 2fd1c72..94b1558 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -8,12 +9,15 @@ import { NotFoundException, Param, Post, + Query, Res, } from "@nestjs/common"; import type { Response } from "express"; import { + adoptTarget, authenticate, + createHandoff, createInvite, createUser, endOtherSessions, @@ -24,7 +28,9 @@ import { removeUser, revokeInvite, signupOpen, + spendHandoff, startSession, + sweepHandoffs, sweepSessions, updatePassword, type User, @@ -37,10 +43,15 @@ import { CurrentUser, SessionId } from "../common/user.decorator"; /** * Signing in, signing up, and the people who can. * - * The four routes a visitor reaches before they have a session are `@Public()`; + * The routes a visitor reaches before they have a session are `@Public()`; * everything else is behind the global guard. The cookie is set by this * process, on this origin — see common/cookie.ts for what that means when the * web app is somewhere else. + * + * `auth/adopt` is the newest of the public ones and the only one that is not a + * form: it takes a one-time code minted by `auth/handoff` over an existing + * session and turns it into that same session's cookie, so the desktop app's + * webview arrives signed in instead of at a second login form. */ @Controller() export class AuthController { @@ -152,6 +163,85 @@ export class AuthController { return { ok: true }; } + /** + * A one-time code that carries this session into a webview. + * + * Behind the global guard, and that is the whole authorisation: the session + * it hands out is `@SessionId()`, which is the one the caller just + * authenticated with. There is no parameter naming a session or a user, so + * there is nothing here that could be pointed at somebody else's — a caller + * can only ever ask for a code for the session it already has, which means + * this route hands out no access that the request did not arrive holding. + * + * The desktop app is what calls it, over its `Authorization: Session …`, + * immediately before opening the console window. See "The hand-off" in + * lib/server/auth.ts for why the code is single-use, short-lived, hashed at + * rest, and buys this session rather than minting another. + * + * The code is in the response body because the caller is a Rust process with + * somewhere to put it, exactly as `POST /auth/login` returns a session id to + * a client that asked for one — and nothing on the console calls this, so + * nothing on the console can start logging it. + */ + @Post("auth/handoff") + @HttpCode(200) + handoff(@SessionId() sid: string) { + // Spent and expired rows, on the way past. Same shape as `sweepSessions` + // on login: the tidying rides on the traffic rather than on a timer. + void sweepHandoffs(); + return createHandoff(sid); + } + + /** + * Spend a hand-off code, set the session cookie, and go to the console. + * + * `@Public()` because the point of it is to be reachable by a webview that + * has no cookie yet — that is the state it exists to fix. The code is the + * credential, and it is checked by `spendHandoff`, which refuses a spent, an + * expired, an unknown and a since-revoked code identically. + * + * **`next` is validated before the code is spent**, and a `next` that is not + * the console is refused outright rather than replaced with something safe. + * Two reasons in that order: a redirect that also sets a session cookie is + * the pairing an open redirect is actually worth something for, and a typo + * in a deployment's `WEB_ORIGIN` should fail loudly on the first try rather + * than work-ish forever. Spending the code only after that check means a + * refusal does not also burn the app's one credential, so it can simply open + * the console the ordinary way and let the person sign in. + * + * **A bad code still redirects, with no cookie set.** This is a window + * somebody is looking at, mid-navigation, and a JSON error in it is a dead + * end with nothing to click. Sending them to the console signed out gets + * them the console's own login page, which is precisely the second sign-in + * this feature removes when it works and is a perfectly good outcome when it + * does not. It is also one response for all four failures, so it says + * nothing about which one happened. + */ + @Public() + @Get("auth/adopt") + async adopt( + @Query("code") code: string | undefined, + @Query("next") next: string | undefined, + @Res() res: Response, + ) { + const target = adoptTarget(String(next ?? "")); + if (!target) { + throw new BadRequestException( + "That is not this console's address, so this app will not sign a browser in and send it there.", + ); + } + + const sid = await spendHandoff(String(code ?? "")); + if (sid) setSession(res, sid); + /* + * Never stored anywhere on the way. A 302 carrying a `Set-Cookie` is the + * one response in this API that a shared proxy caching it would hand one + * person's session to the next person down the wire. + */ + res.setHeader("Cache-Control", "no-store"); + res.redirect(302, target); + } + @Get("auth/sessions") sessions(@CurrentUser() user: User, @SessionId() sid: string) { return listSessions(user.id, sid); diff --git a/docs/DEPLOYING.md b/docs/DEPLOYING.md index 052ebe7..80d7a8a 100644 --- a/docs/DEPLOYING.md +++ b/docs/DEPLOYING.md @@ -34,6 +34,15 @@ cookie is set by the API and read by both, which works because both hosts sit under `COOKIE_DOMAIN` (`.content.yourcompany.com`). Put them on unrelated domains and you need `COOKIE_SAMESITE=none` — and you should not. +`WEB_ORIGIN` now carries a second job. It is still the CORS allow-list, and it +is also what the desktop app's one-time sign-in hand-off is allowed to redirect +to — an endpoint that sets a session cookie and then redirects is worth more to +an attacker than either half, so `next` is refused unless it matches. It has to +be the **exact** origin the app was built with as `CONTENTOS_CONSOLE_URL`: +scheme, host and port. Get it wrong and nothing breaks loudly — teammates just +quietly get asked to sign in a second time, in the webview, having already +signed in to the app. + **Not Vercel, and now for a smaller reason.** A run is no longer a loop inside the API: `advance()` enqueues jobs and returns, and a worker claims them. But the API still holds long-poll connections for workers claiming work, runs a diff --git a/docs/WORKER.md b/docs/WORKER.md index a6c37a8..e017795 100644 --- a/docs/WORKER.md +++ b/docs/WORKER.md @@ -512,6 +512,11 @@ starts carrying a credential it does not need and might log. So: still two principals. A person, who may now arrive by cookie or by header, and a machine, which arrives by bearer token and by nothing else. +There is a hand-off code as well, and it is not a fourth credential in any +sense that matters: it is a two-minute, single-use ticket that can be exchanged +for one specific existing session and for nothing else, by the one route that +accepts it. See **One sign-in, and the hand-off that carries it** below. + | | | | |---|---|---| | **Register** | `POST /api/workers/register` | `{ name, platform, version, tools: ToolStatus[], enabled: string[], canReadFrames, maxConcurrency }` → `{ workerId, heartbeatMs, claimMs }`. Idempotent on the token: the same machine re-registering after an update replaces its tool report rather than creating a second row. Re-registration is also how a newly installed `whisper` becomes visible — this is what replaces `toolStatuses(force)`. | @@ -771,6 +776,93 @@ open, and "paste the token you were shown" was an instruction with nowhere to follow it. The field is still on the setup window, one disclosure down, for whoever is handed a token by a colleague. +### One sign-in, and the hand-off that carries it + +Two credentials, and — until this — two *sign-ins*. The app signs in from Rust +and keeps the session in the credential store, because its webview is on +`tauri://localhost` and the API's cookie is third-party there. Then it opened +the console window, which is an ordinary navigation to the team's server +carrying nothing at all, and the first thing a teammate saw after signing in +was a login form asking for the same email and the same password again. Same +account, same machine, seconds apart. Nothing was broken; it just looked like +the first sign-in had not counted. + +The fix is a route pair, and deliberately the smallest thing that closes it. + +| | | | +|---|---|---| +| **Mint** | `POST /api/auth/handoff` | Behind the global `SessionGuard` — **not** `@Public()`. Returns `{ code, expiresAt }` for the session the request arrived on, whichever transport carried it. There is no parameter naming a session or a user, so a caller can only ever ask for a code for the session it already holds: the route hands out no access the request did not arrive with. | +| **Spend** | `GET /api/auth/adopt?code=…&next=…` | `@Public()`, because the whole point is a webview that has no cookie yet — that is the state it exists to fix. Validates `next`, spends the code, sets the session cookie with the same `setSession` every login uses, and redirects. | + +Four rules, each preventing something specific: + +- **It buys the session it was minted from, and nothing else.** Spending calls + no `startSession` and extends no expiry — it hands over the row the app + already holds. Two session rows for one sign-in would mean Settings → Team + and "sign out everywhere" could each end one and leave the other alive: a + person who revoked their laptop and watched it carry on working. +- **Minutes, not hours** — two. For as long as a code is live it is a session + sitting in a URL, and a URL is the least private thing in the system: webview + history, a proxy log, the `Referer` on whatever the console loads next. +- **Stored as a sha256**, through the same `hashToken` `workers.token_hash` + uses, so a database dump is not a list of live logins. It is worth more here + than there: what a hand-off code opens is a person's whole console, not nine + queue routes. +- **Single use, decided by Postgres.** One `UPDATE … WHERE used_at IS NULL` + returning a row, the same trick that stops an invite link being redeemed + twice by two people racing it. A read followed by a write would let a + replayed URL and the real navigation both pass the read. + +**`next` is an allowlist, and a miss is a refusal.** A redirect that also sets +a session cookie is worth more to an attacker than either half on its own: send +somebody a link and their browser arrives on your page *already signed in as +themselves*. So the target is checked against the origins in `WEB_ORIGIN` — the +same variable, parsed the same way, as the CORS list, because a second +statement of where the console is would be a second thing to keep in step — and +anything else is a `400`, not a quiet fall back to the console's front page. A +silent fallback would make a misconfigured `WEB_ORIGIN` look like it worked, +and the one time it mattered would be the time somebody was probing it. The +whole rule is that the *origin* of whatever `next` parses to must be on the +list, which handles `//evil.example`, `javascript:…` and +`https://console@evil.example` without any of them being recognised on its own. +`tests/handoff.test.ts` pins it. + +**`next` is validated before the code is spent**, so a refusal does not also +burn the app's one credential — it can open the console the ordinary way +instead. + +**What it deliberately is not:** + +- **Not a second session.** See the first rule. This is the difference between + a hand-off and "log the webview in too", which is what any design that called + `startSession` here would be. +- **Not a general-purpose redirect.** It goes to the console or it goes + nowhere. There is no `?url=` for a third party, no relative escape hatch, no + "trusted domains" list to grow. +- **Not a way to get a session you did not have.** Minting is behind the guard + and names no principal; spending is a lookup on a hash, and a spent, expired, + unknown or since-revoked code is one indistinguishable answer — a redirect to + the console with no cookie set, which is the second sign-in, which is exactly + where this started. + +**A bad code still opens the window.** The person is mid-navigation and a JSON +error in a webview is a dead end with nothing to click, so the response is the +same `302` either way and the console's own login page is what they get. The +app's side matches: `account::console_entry` falls back to the console's plain +URL on *every* failure — no session held, no API address, the API unreachable, +a server too old to have the route, a body it cannot read. + +**Every opening of the console goes through it, not only the first.** The +webview's cookie and the app's stored session are two credentials with two +lifetimes and they come apart in ordinary use: a cleared WebView2 profile, a +restored backup, a cookie that expired while the app's session did not. The one +that actually matters is a computer handed to a colleague who chooses *Sign in +as somebody else* — after which the app holds their session and the webview +still holds the last person's cookie, and a window opened without a hand-off +would show the wrong person's console. Minting on every open costs one HTTP +round trip before the window appears, on a five-second timeout, which is the +price of the window never disagreeing with the app about who is signed in. + ### Uploads stay on the server, and whisper stops needing the video `ingestFile` keeps running where the bytes already are. `ffmpeg` is a real @@ -1073,8 +1165,9 @@ signing in. - **Not a second identity system.** It signs in with the console's own accounts, over the console's own `POST /auth/login`, and gets back the console's own session — the same row a browser would have held in a cookie, carried in a - header because a webview on a local origin cannot keep that cookie. No users - here, no passwords here, no second table anywhere. The worker holds the + header because a webview on a local origin cannot keep that cookie, and + handed to that webview as a cookie for *that same row* when the console + window opens. No users here, no passwords here, no second table anywhere. The worker holds the machine token. Two credentials, for the reasons in "The desktop app holds two credentials, on purpose" above. - **Not a rewrite of the worker.** It spawns `worker/index.ts` with the same @@ -1092,7 +1185,7 @@ signing in. | The session | The same store, under `console-session.com.contentos.desktop`. A separate entry rather than a second field in one blob, because the two are separately replaced: signing in again writes this and leaves the token alone, re-enrolling writes the token and leaves this alone. It is in the credential store for the same reason the token is — a header credential in `%APPDATA%` is a credential in the next backup of the profile — and it is never given to the webview or to the worker. | | Who is signed in | `accountEmail` in `settings.json`, and it is only a label: the window shows it, `settings::account_email` returns it only while there is a session in the store to go with it, and nothing authenticates with it. | | This machine's row | `machineId` in `settings.json`. Not a credential — it is the id the machine picker shows beside a name — and it is what stops a second machine being minted on every sign-in. | -| Session cookie | The webview's own data directory, which is WebView2's business and not this app's. The API's cookie never reaches it: the window is on a local origin, so that cookie is third-party. | +| Session cookie | The webview's own data directory, which is WebView2's business and not this app's. It gets there by hand-off: the window is opened at `GET /api/auth/adopt?code=…`, which sets the cookie for the session this app already holds — see "One sign-in, and the hand-off that carries it". What it is *not* is this app writing a cookie, or the app's `fetch` earning one: the window is on a local origin, so a cookie the API set for a request made from here would be third-party. It is set on a top-level navigation to the API's own origin, which is the ordinary way every browser gets one. | ### Where the two addresses come from @@ -1143,10 +1236,11 @@ and one that is not a usable address is skipped rather than accepted — so an `apiUrl: ""` in a `settings.json` written before there was one does not shadow the address the installer was built with. -**What the setup window asks for.** An email and a password, and nothing else. -Sign in, and the app keeps the session, mints this machine's worker token, -stores it, and starts the worker — one button, and the person never sees a -token at all. Afterwards the panel stops being a form and becomes a line saying +**What the setup window asks for.** An email and a password, and nothing else, +**once**. Sign in, and the app keeps the session, mints this machine's worker +token, stores it, starts the worker, and opens the console *already signed in* +through the hand-off above — one button, and the person never sees a token at +all, nor a second login form. Afterwards the panel stops being a form and becomes a line saying who is signed in, with two things beside it: *Set this machine up again*, which mints a fresh token from the session already held and is the answer to a machine revoked or a credential store cleared, and *Sign in as somebody else*, diff --git a/lib/server/auth.ts b/lib/server/auth.ts index 47d5051..5f90474 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -1,6 +1,7 @@ import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; import { id, iso, one, q } from "@/lib/server/db/client"; +import { hashToken } from "@/lib/server/repos/workers"; import { SESSION_COOKIE } from "@/lib/session"; /** @@ -397,6 +398,196 @@ export async function endOtherSessions(userId: string, keep: string) { export const sweepSessions = () => q("DELETE FROM sessions WHERE expires_at <= now()"); +/* ── The hand-off ───────────────────────────────────────────────────────── */ + +/** + * One sign-in, carried from the desktop app into the console window beside it. + * + * The app signs in from Rust and keeps the session id in the OS credential + * store, because its webview is on `tauri://localhost` and the API's cookie is + * third-party there — `api/src/common/session.guard.ts` sets that out at + * length. Then it opens the console, which is an ordinary navigation to the + * team's server carrying no cookie at all, and the person signs in a *second* + * time: same email, same password, same account, ten seconds after the first. + * That second form is the only thing this exists to remove. + * + * A hand-off code is a one-shot bearer of one existing session. The app mints + * one over the session it already holds, opens the webview at + * `GET /api/auth/adopt?code=…` instead of at the console's own URL, and that + * route spends the code and sets the ordinary session cookie. The console is + * then signed in exactly the way every browser is signed in. + * + * Four rules, and each of them is preventing something specific: + * + * 1. **It buys the session it was minted from, and nothing else.** Spending a + * code calls no `startSession` and extends no expiry. Two session rows for + * one sign-in would mean "sign out everywhere" and Settings → Team could + * each end one and leave the other alive — a person who revoked their + * laptop and watched it keep working. + * 2. **Minutes, not hours.** For as long as a code is live it is a session in + * a URL, and a URL is the least private thing in the system: webview + * history, a proxy log, the `Referer` on whatever the console loads next. + * Two minutes is a navigation, generously. + * 3. **Stored as a hash.** The `handoffs` table holds a sha256 and never the + * code, for the reason `workers.token_hash` does: a database dump must not + * be a list of live credentials. It is worth more here than there, because + * what a hand-off code opens is a person's whole console rather than nine + * queue routes. + * 4. **Single use, decided by Postgres.** The spend is one `UPDATE ... WHERE + * used_at IS NULL` returning a row, the same trick `createUser` uses to + * stop an invite link being redeemed twice by two people racing it. A read + * followed by a write would let a replayed URL and the real navigation both + * pass the read. + */ + +/** + * How long a code is good for. + * + * It is spent by the very next request the app makes — it exists to survive + * one navigation, not a coffee break — so this is generous already. What it + * has to absorb is a slow window opening and a clock a minute out of step + * between the app's machine and the API's. + */ +const HANDOFF_SECONDS = 120; + +/** + * 32 random bytes, base64url. + * + * The same size and the same encoding as a session id, because it is a + * credential for the same thing for two minutes. base64url rather than + * `inviteToken`'s hand-rolled substitution because this one is always going + * into a query string, and a `+` in a URL is a space by the time it is read + * back. + */ +const handoffCode = () => randomBytes(32).toString("base64url"); + +/** + * Mint a code for a session that already exists. + * + * `sessionId` is the caller's own, taken off the request by the guard, so + * there is no way to ask for a code for somebody else's session: the only + * session a caller can name is the one it authenticated with. + * + * `hashToken` is deliberately the worker repo's function rather than a second + * `createHash` call written here. It says so itself: one place that turns a + * credential into what the database stores is what stops two places getting + * the encoding subtly different, which would show up as a hand-off that mints + * fine and can never be spent. + */ +export async function createHandoff(sessionId: string) { + const code = handoffCode(); + const row = await one<{ expires_at: string }>( + `INSERT INTO handoffs (code_hash, session_id, expires_at) + VALUES ($1, $2, now() + ($3 || ' seconds')::interval) + RETURNING expires_at`, + [hashToken(code), sessionId, String(HANDOFF_SECONDS)], + ); + return { code, expiresAt: iso(row!.expires_at) }; +} + +/** + * Spend a code, and get back the session it was minted from. + * + * `null` for spent, expired, unknown, and for a code whose session has since + * been ended — one answer, on purpose. Telling those apart would tell whoever + * is guessing which half of their guess was right, and there is nothing a + * caller could usefully do differently with the distinction anyway: every one + * of them means "this navigation is not signed in". + * + * The `UPDATE` is the whole single-use rule. Two requests carrying the same + * code race each other inside one statement, and exactly one of them gets a + * row back — so a replayed URL, out of a webview's history or a log, finds + * `used_at` already set and is refused. + */ +export async function spendHandoff(code: string): Promise { + if (!code) return null; + const row = await one<{ session_id: string }>( + `UPDATE handoffs SET used_at = now() + WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now() + RETURNING session_id`, + [hashToken(code)], + ); + if (!row) return null; + + /* + * And the session it names has to still be alive. + * + * The cascade takes codes with a deleted session, but expiry is not a + * delete: a session row sits there past `expires_at` until the sweep runs, + * and `sessionUser` is the one place that decides what "still signed in" + * means. Setting a cookie for a session this same module would refuse a + * moment later is a console that loads and immediately bounces to /login. + */ + const sid = String(row.session_id); + return (await sessionUser(sid)) ? sid : null; +} + +/** Spent and expired codes are dead weight; cleared whenever one is minted. */ +export const sweepHandoffs = () => + q("DELETE FROM handoffs WHERE expires_at <= now() OR used_at IS NOT NULL"); + +/** + * The origins a hand-off is allowed to land on: the console's, and no others. + * + * Read from `WEB_ORIGIN` — the same variable, parsed the same way, as the CORS + * list in `api/src/main.ts` — because that is already this deployment's + * statement of where the console is. A second variable would be a second thing + * to keep in step, and the failure of drifting apart would be a hand-off that + * works in dev and refuses on the server. + */ +const consoleOrigins = () => + (process.env.WEB_ORIGIN ?? "http://localhost:3333") + .split(",") + .map((o) => { + try { + return new URL(o.trim()).origin; + } catch { + return ""; + } + }) + .filter(Boolean); + +/** + * Where a hand-off may redirect to, or `null`. + * + * **This is the sharp edge of the whole feature.** `/auth/adopt` sets a + * session cookie and then redirects, and an open redirect that also hands out + * a session is worth more to an attacker than either half on its own: send + * somebody a link, and their browser arrives at your page signed in as + * themselves, with whatever the console renders into the URL along for the + * ride. So `next` is checked against an allowlist and anything else is + * refused outright — not trimmed, not stripped back to a path, not quietly + * replaced with the console's front page. A silent fallback would make a + * misconfigured `WEB_ORIGIN` look like it worked, and the one time it mattered + * would be the time somebody was probing it. + * + * The rule is one line and covers every trick by construction, because + * `origin` is a scheme, a host and a port and nothing else: `//evil.example` + * resolves to evil's origin, `javascript:…` has no origin any allowlist can + * hold, `https://console@evil.example` is evil's origin with the console's + * name as a username. All three miss the list. + * + * An absent `next` is not a refusal — there is nothing to refuse — and means + * the console's front door. + */ +export function adoptTarget(next: string): string | null { + const allowed = consoleOrigins(); + if (!allowed.length) return null; + + const raw = (next ?? "").trim(); + if (!raw) return allowed[0]; + + try { + // Resolved against the console, so `/runs/run_x` is a thing the app can + // send; the origin check below is what makes that safe rather than the + // parsing. + const url = new URL(raw, allowed[0]); + return allowed.includes(url.origin) ? url.toString() : null; + } catch { + return null; + } +} + /* ── The current request ────────────────────────────────────────────────── */ /* diff --git a/lib/server/db/schema.ts b/lib/server/db/schema.ts index f889bef..547018f 100644 --- a/lib/server/db/schema.ts +++ b/lib/server/db/schema.ts @@ -730,4 +730,61 @@ export const MIGRATIONS: { name: string; sql: string }[] = [ DELETE FROM settings WHERE key IN ('brain', 'enabled', 'cliCanReadFrames'); `, }, + + { + /* + * One sign-in, carried from the desktop app into its webview. + * + * The app signs in from Rust and holds the session as a header credential; + * the console window beside it is an ordinary navigation to somebody + * else's origin and carries no cookie. So a teammate signed in twice on + * first setup — once in the setup window, once in the console — for the + * same account, on the same machine, a second apart. + * + * A hand-off code is the bridge. The app mints one over the session it + * already holds, the webview is opened at `/api/auth/adopt?code=…`, and + * that route spends the code and sets the cookie the ordinary way. See + * "Hand-off" in lib/server/auth.ts for the rules and docs/WORKER.md for + * what this deliberately is not. + */ + name: "0017_handoff_codes", + sql: ` + CREATE TABLE handoffs ( + -- The sha256 of the code, never the code. For the two minutes it lives + -- a hand-off code IS a session: whoever holds it can spend it and be + -- signed in as that person. Storing it raw would make a database dump a + -- list of live logins, which is the same argument workers.token_hash + -- makes about a machine's token, and it is worth more here because the + -- thing on the other end is a person's whole console. + code_hash TEXT PRIMARY KEY, + + -- The session this code hands over. NOT the user: spending a code must + -- not mint a second session, or one sign-in would leave two rows and + -- revoking either would leave the other alive. Cascaded, so signing out + -- — or Settings → Team removing somebody — takes any un-spent codes + -- with it rather than leaving a live one pointing at a dead session. + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- Minutes, not days. A code that outlives the navigation it was minted + -- for is a session sitting in a URL — in a webview's history, in a + -- proxy log, in whatever a screen recorder caught — waiting to be + -- replayed. + expires_at TIMESTAMPTZ NOT NULL, + + -- Set by the UPDATE that spends it, so single use is decided by + -- Postgres rather than by a read followed by a write that two + -- navigations can interleave. The row is kept rather than deleted so a + -- replay is a row that says "already used" instead of a row that is + -- missing for two different reasons. + used_at TIMESTAMPTZ + ); + + -- The sweep's index. Spent and expired rows are dead weight and are + -- cleared whenever a code is minted, and that pass must not become a + -- sequential scan of every hand-off this console has ever made. + CREATE INDEX handoffs_sweep ON handoffs(expires_at); + `, + }, ]; diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs index 0525cdb..2176bf2 100644 --- a/src-tauri/src/account.rs +++ b/src-tauri/src/account.rs @@ -26,7 +26,7 @@ use std::time::Duration; use serde::Deserialize; -use tauri::AppHandle; +use tauri::{AppHandle, Url}; use crate::settings; @@ -35,6 +35,16 @@ use crate::settings; /// remember typing it. const TIMEOUT: Duration = Duration::from_secs(20); +/// And a much shorter one for the hand-off, because nobody asked for it. +/// +/// Signing in is a thing a person pressed a button for and will wait on. The +/// hand-off happens on the way to opening a window they asked to *see*, and +/// every second of it is a second of nothing appearing after a click. It has a +/// fallback that is merely worse rather than broken — the console, opened the +/// ordinary way — so giving up early costs a second sign-in and giving up late +/// costs an app that looks hung. +const HANDOFF_TIMEOUT: Duration = Duration::from_secs(5); + /// How a session travels when it is not in a cookie: `Authorization: Session /// `, read by `SessionGuard` in `api/src/common/session.guard.ts`. /// @@ -301,6 +311,117 @@ fn machine_name() -> String { "A teammate's machine".into() } +/* ── Opening the console without a second sign-in ───────────────────────── */ + +/// Where the console window should be pointed, given what this app is holding. +/// +/// **The problem.** The person signs in here, in Rust, and this app keeps the +/// session in the credential store — it has to, because the window beside it +/// is on `tauri://localhost` and the API's cookie would be third-party there. +/// Then the console window opens, which is an ordinary navigation to the +/// team's server carrying nothing at all, and asks them to sign in again: same +/// email, same password, same account, seconds after the first. One sign-in, +/// two forms, and the second one is the one people write support messages +/// about. +/// +/// **The fix.** `POST /api/auth/handoff`, over the session this app already +/// holds, returns a one-time code; the window is opened at +/// `GET /api/auth/adopt?code=…&next=` instead, which spends the code, +/// sets the cookie for *that same session*, and redirects. The cookie lands in +/// the webview the ordinary way — a `Set-Cookie` on a top-level navigation — +/// and the console is signed in before it draws anything. +/// +/// **And when anything at all goes wrong, the console's own URL.** Every +/// branch here falls back to it: no session held, no API address, the API +/// unreachable, a server too old to have the route, a body this cannot read. A +/// second sign-in is a worse experience; a window that refuses to open is a +/// broken app, and this must never turn the first into the second. +/// +/// This runs on **every** opening of the console, not only the first after a +/// sign-in, and that is deliberate. The webview's cookie and this app's stored +/// session are two credentials with two lifetimes, and they come apart in +/// ordinary use: somebody clears the WebView2 profile, Windows restores a +/// backup without it, or — the one that actually matters — a computer is +/// handed to a colleague who chooses *Sign in as somebody else*, after which +/// the app holds their session while the webview still holds the last person's +/// cookie. Going through the hand-off every time makes the window show whoever +/// this app is signed in as, which is the only answer that is ever right. +pub async fn console_entry(app: &AppHandle) -> String { + let console = settings::console_url(app); + let session = settings::session(); + if console.is_empty() || session.is_empty() { + return console; + } + + let Ok(api) = api(app) else { return console }; + let Ok(http) = client_with(HANDOFF_TIMEOUT) else { + return console; + }; + + match handoff(&http, &api, &authorization(SCHEME, &session)).await { + Ok(code) => adopt_url(&api, &code, &console).unwrap_or(console), + // Deliberately silent. There is nowhere to say it — this is a window + // opening, not a form being submitted — and the outcome the person + // sees is the console asking them to sign in, which is what they would + // have got anyway. The setup window is still where a session that has + // run out is explained, because that is where it can be fixed. + Err(_) => console, + } +} + +/// Ask for a one-time code for the session this app is holding. +/// +/// Short, and it stays short: the app sends a credential it already has and +/// gets back a credential it is about to spend. It does not read the person, +/// does not store anything, and does not decide anything — every rule about +/// what a code is worth lives on the server, in `lib/server/auth.ts`, because +/// a client that enforced its own idea of "single use" would be enforcing it +/// on the one copy that is not the one being replayed. +async fn handoff(http: &reqwest::Client, api: &str, header: &str) -> Result { + #[derive(Deserialize)] + struct Code { + code: String, + } + + let sent = http + .post(format!("{api}/api/auth/handoff")) + .header("authorization", header) + .send() + .await + .map_err(|e| unreachable(api, &e))?; + + let status = sent.status().as_u16(); + let body = sent.text().await.unwrap_or_default(); + if !(200..300).contains(&status) { + // Includes the 404 from an API older than this route, which is the + // ordinary reason a team's server would say no. All of them come out + // as "open the console the plain way". + return Err(complained(status, &body, api)); + } + + let code: Code = serde_json::from_str(&body).map_err(|_| unreadable(api))?; + if code.code.is_empty() { + return Err(unreadable(api)); + } + Ok(code.code) +} + +/// The adopt URL, built with a URL parser rather than by pasting strings. +/// +/// Both values go through `query_pairs_mut`, which percent-encodes them. The +/// code is base64url and would survive being pasted in raw; the console +/// address would not — a `?` or a `&` in it, or a path with a space, would +/// silently truncate `next` and leave the API redirecting somewhere other than +/// where the app meant, which the server would then refuse and turn into a +/// second sign-in nobody could explain. +fn adopt_url(api: &str, code: &str, console: &str) -> Option { + let mut url = Url::parse(&format!("{api}/api/auth/adopt")).ok()?; + url.query_pairs_mut() + .append_pair("code", code) + .append_pair("next", console); + Some(url.to_string()) +} + /* ── Talking to the API ─────────────────────────────────────────────────── */ #[derive(Deserialize)] @@ -394,6 +515,16 @@ async fn signup_open(http: &reqwest::Client, api: &str) -> bool { } fn client() -> Result { + client_with(TIMEOUT) +} + +/// The same client, waited on for as long as the caller is prepared to wait. +/// +/// One builder rather than two, because the crypto provider below is the kind +/// of setup that must not exist in two places: a second builder written later, +/// without it, would work in every build that had already signed in and fail +/// as "could not open a connection" in the one that had not. +fn client_with(timeout: Duration) -> Result { /* * The one thing `rustls-no-provider` leaves to the application: which * cryptography actually runs. It is not chosen by the dependency so that @@ -413,7 +544,7 @@ fn client() -> Result { } reqwest::Client::builder() - .timeout(TIMEOUT) + .timeout(timeout) // The console records this against the session and shows it in // Settings → Team, so a person looking at their own sessions can tell // the desktop app from a browser and end the right one. @@ -640,6 +771,69 @@ mod tests { .collect(); assert!(already_enrolled(&minted.machine.id, true, &mine)); + /* + * And the hand-off, which is the other half of one sign-in. + * + * Mint a code over the session just used, spend it the way the + * webview would — a plain GET, redirects NOT followed so the + * `Set-Cookie` and the `Location` can actually be looked at — and + * then send exactly the same URL a second time. The second one is + * the assertion that matters: a code that could be replayed is a + * session sitting in a webview's history. + * + * `next` has to be an origin the API's own WEB_ORIGIN names, which + * is what CONTENTOS_TEST_CONSOLE is for; its default is the API's + * own default, so a throwaway console started with neither set + * still runs this. + */ + let console = std::env::var("CONTENTOS_TEST_CONSOLE") + .unwrap_or_else(|_| "http://localhost:3333".into()); + let code = handoff(&http, &api, &header).await.expect("a hand-off code"); + let url = adopt_url(&api, &code, &console).expect("an adopt url"); + + let webview = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("a client that does not follow redirects"); + + let first = webview.get(&url).send().await.expect("adopt"); + assert_eq!(first.status().as_u16(), 302, "a redirect to the console"); + assert!( + first + .headers() + .get("location") + .and_then(|l| l.to_str().ok()) + .unwrap_or_default() + .starts_with(&console), + "the redirect goes to the console it was given" + ); + assert!( + first.headers().get("set-cookie").is_some(), + "the session cookie is what the webview came for" + ); + + let again = webview.get(&url).send().await.expect("replay"); + assert_eq!(again.status().as_u16(), 302, "still a window, not an error"); + assert!( + again.headers().get("set-cookie").is_none(), + "a spent code must hand out nothing at all" + ); + + // A code for somewhere that is not the console is refused before + // anything is spent — the open redirect and the session cookie + // are only dangerous together. + let elsewhere = handoff(&http, &api, &header).await.expect("a second code"); + let bad = adopt_url(&api, &elsewhere, "https://evil.example.com").unwrap(); + let sent = webview.get(&bad).send().await.expect("adopt elsewhere"); + assert_eq!(sent.status().as_u16(), 400); + assert!(sent.headers().get("set-cookie").is_none()); + + // And the code it refused is still live, because refusing a target + // must not cost the app the credential it was about to use. + let good = adopt_url(&api, &elsewhere, &console).unwrap(); + let retry = webview.get(&good).send().await.expect("adopt properly"); + assert!(retry.headers().get("set-cookie").is_some()); + // And the password still has to be right, over the same client // against the same server — a login that accepts anything would // pass every assertion above. @@ -647,6 +841,37 @@ mod tests { }); } + /// Both halves of the adopt URL go through a URL parser, so neither can + /// end the query string early. `next` is the one that would: a console + /// address with a `?` or an `&` anywhere in it would truncate silently, + /// the API would refuse the mangled target, and the person would get the + /// second sign-in this whole thing exists to remove — with nothing on any + /// screen saying why. + #[test] + fn the_adopt_url_encodes_what_it_carries() { + assert_eq!( + adopt_url( + "https://api.example.com", + "aBc-_123", + "https://console.example.com" + ) + .unwrap(), + "https://api.example.com/api/auth/adopt\ + ?code=aBc-_123&next=https%3A%2F%2Fconsole.example.com" + ); + } + + /// A base64url code is unreserved from end to end, so it arrives at the + /// server byte-for-byte — which matters, because the server compares its + /// sha256 and a single re-encoded character is a code that mints fine and + /// can never be spent. + #[test] + fn a_code_survives_the_query_string_unchanged() { + let code = "Zm9vYmFy-_0123456789abcXYZ"; + let url = adopt_url("https://api.example.com", code, "https://c.example.com").unwrap(); + assert!(url.contains(&format!("code={code}&"))); + } + /// The scheme is the server's word, but only if it is a word. #[test] fn the_scheme_is_checked_before_it_is_sent() { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 64abe6b..c6d5a63 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -43,6 +43,12 @@ fn status(app: AppHandle, worker: State<'_, Worker>) -> View { /// The console is opened at the end because that is what the person came for. /// A window that stayed on the setup screen after a successful sign-in would /// be a window that looks like nothing happened. +/// +/// And it opens *signed in*. `windows::console` carries the session this app +/// has just been given into the webview through the hand-off — see +/// `account::console_entry` — so the form the person has just filled in is the +/// only one they ever see. Before that, the window that opened here asked them +/// for the same email and the same password a second time, seconds later. #[tauri::command] async fn sign_in(app: AppHandle, email: String, password: String) -> Result { let done = account::sign_in(&app, &email, &password).await?; diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 1754f93..3258658 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -5,7 +5,9 @@ //! `src-tauri`: the console is served by whoever runs the server, so a //! template change or a bug fix reaches every teammate the moment it is //! deployed, without fifteen desktops needing an update. The webview signs in -//! as a person, with the console's own cookie, exactly as a browser does. +//! as a person, with the console's own cookie, exactly as a browser does — and +//! that cookie now arrives by hand-off rather than by a second login form. See +//! `account::console_entry`. //! //! **The setup window is the one screen this app draws**, because the console //! address and this machine's worker token have to be given before there is a @@ -14,19 +16,53 @@ use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder}; -use crate::settings; +use crate::{account, settings}; +/// Open the console, signed in as whoever this app is signed in as. +/// +/// Returns straight away and finishes on a background task, because getting +/// the session into that window costs one HTTP request — see +/// `account::console_entry` for what it is and why it is worth a round trip. +/// The alternative shapes are both worse: blocking would freeze whichever +/// button or tray item was clicked, and opening the window first and +/// navigating afterwards would show the console's login form for a moment +/// before replacing it, which reads as a sign-in that failed. pub fn console(app: &AppHandle) { - if let Some(open) = app.get_webview_window("console") { - let _ = open.show(); - let _ = open.set_focus(); + // The common case, and it must stay instant: the window is already there + // and the person is asking to look at it. Nothing to hand off — this + // webview signed in when it was opened. + if focus(app, "console") { return; } // Anything wrong with the address puts the person in front of the field // that holds it, rather than in front of an empty window or nothing at all. - let url = settings::console_url(app); - let Ok(url) = Url::parse(&url) else { + // Checked here, synchronously, so that the one failure a person can fix + // does not arrive five seconds later from a task they cannot see. + if Url::parse(&settings::console_url(app)).is_err() { + return setup(app); + } + + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let entry = account::console_entry(&app).await; + open(&app, &entry); + }); +} + +/// Build the window, at whatever URL the hand-off settled on. +/// +/// The existing-window check happens again here, and it is not paranoia: two +/// clicks a second apart, or a tray item pressed while the first hand-off is +/// still in flight, would otherwise each get to this point and this app would +/// have two console windows — one of them signed in as the wrong person, since +/// only one of the two codes can be spent. +fn open(app: &AppHandle, entry: &str) { + if focus(app, "console") { + return; + } + + let Ok(url) = Url::parse(entry) else { return setup(app); }; @@ -41,9 +77,7 @@ pub fn console(app: &AppHandle) { } pub fn setup(app: &AppHandle) { - if let Some(open) = app.get_webview_window("setup") { - let _ = open.show(); - let _ = open.set_focus(); + if focus(app, "setup") { return; } @@ -61,3 +95,15 @@ pub fn setup(app: &AppHandle) { .inner_size(560.0, 780.0) .build(); } + +/// Bring a window that already exists to the front, and say whether there was +/// one. Written once because both windows want it and the console now wants it +/// twice. +fn focus(app: &AppHandle, label: &str) -> bool { + let Some(open) = app.get_webview_window(label) else { + return false; + }; + let _ = open.show(); + let _ = open.set_focus(); + true +} diff --git a/tests/handoff.test.ts b/tests/handoff.test.ts new file mode 100644 index 0000000..f8026a8 --- /dev/null +++ b/tests/handoff.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { adoptTarget } from "@/lib/server/auth"; + +/** + * Where a hand-off is allowed to put somebody down. + * + * `GET /api/auth/adopt` sets a session cookie and then redirects, and those + * two halves together are worth more to an attacker than either on its own: a + * link that lands a browser on your page *already signed in as its owner* is a + * different thing from an open redirect. So the one rule that keeps it honest + * — the redirect target must be the console — is the thing worth pinning. + * + * Pure, and therefore here rather than in a request test. Nothing about a + * loosened check is visible to `tsc`, nothing about it shows in a screenshot, + * and the failure it prevents is not a broken page but a working one on + * somebody else's domain. + * + * The single-use and expiry rules live in Postgres — one `UPDATE ... WHERE + * used_at IS NULL` — and are proved against a real API rather than mocked + * here; see docs/WORKER.md, "The hand-off". + */ + +const ORIGINAL = process.env.WEB_ORIGIN; + +beforeEach(() => { + process.env.WEB_ORIGIN = "https://console.example.com,https://old.example.com"; +}); + +afterEach(() => { + if (ORIGINAL === undefined) delete process.env.WEB_ORIGIN; + else process.env.WEB_ORIGIN = ORIGINAL; +}); + +describe("where a hand-off may redirect to", () => { + it("allows the console it was told about", () => { + expect(adoptTarget("https://console.example.com/content")).toBe( + "https://console.example.com/content", + ); + }); + + it("allows every origin WEB_ORIGIN names, because CORS already does", () => { + expect(adoptTarget("https://old.example.com/")).toBe("https://old.example.com/"); + }); + + /* A path is what the app has when it wants to land somebody on one screen. */ + it("resolves a path against the console", () => { + expect(adoptTarget("/runs/run_x")).toBe("https://console.example.com/runs/run_x"); + }); + + it("sends an empty next to the console's front door", () => { + expect(adoptTarget("")).toBe("https://console.example.com"); + expect(adoptTarget(" ")).toBe("https://console.example.com"); + }); + + /* + * The four that matter, and they are one rule: whatever the string parses + * to, its ORIGIN has to be on the list. A scheme, a host and a port is all + * an origin is, so a protocol-relative host, a `javascript:` URL and the + * console's name smuggled in as a username all miss it without any of them + * needing to be recognised on its own. + */ + it("refuses another host outright", () => { + expect(adoptTarget("https://evil.example.com/")).toBeNull(); + }); + + it("refuses a protocol-relative host, which is a host and not a path", () => { + expect(adoptTarget("//evil.example.com/")).toBeNull(); + }); + + it("refuses a scheme that is not the web", () => { + expect(adoptTarget("javascript:alert(1)")).toBeNull(); + expect(adoptTarget("data:text/html,")).toBeNull(); + }); + + it("refuses the console's name used as a username on somebody else's host", () => { + expect(adoptTarget("https://console.example.com@evil.example.com/")).toBeNull(); + }); + + /* Same host, wrong scheme or wrong port, is a different origin and is not it. */ + it("refuses the same host on another scheme or port", () => { + expect(adoptTarget("http://console.example.com/")).toBeNull(); + expect(adoptTarget("https://console.example.com:8443/")).toBeNull(); + }); + + it("refuses a subdomain of the console, which is not the console", () => { + expect(adoptTarget("https://uploads.console.example.com/")).toBeNull(); + }); + + /* + * A deployment that names no console has nowhere legitimate to send anyone, + * and must refuse rather than fall back to a default host that would then be + * the one origin an attacker knows is always allowed. + */ + it("refuses everything when WEB_ORIGIN is unusable", () => { + process.env.WEB_ORIGIN = "not a url"; + expect(adoptTarget("https://console.example.com/")).toBeNull(); + expect(adoptTarget("")).toBeNull(); + }); +}); From ea720a36324f3a8e684b734adf38a4dee5a01dad Mon Sep 17 00:00:00 2001 From: Shakhawat Date: Sun, 6 Sep 2026 13:46:25 +0600 Subject: [PATCH 24/30] Make the first screen a sign-in, and reload it on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-tauri/scripts/dev.mjs | 29 +++++- src-tauri/scripts/ui-server.mjs | 116 +++++++++++++++++++++++ src-tauri/src/account.rs | 18 ++-- src-tauri/src/settings.rs | 6 +- src-tauri/src/windows.rs | 12 ++- src-tauri/tauri.conf.json | 3 +- src-tauri/ui/setup.html | 163 +++++++++++++++++++++++++++----- 7 files changed, 307 insertions(+), 40 deletions(-) create mode 100644 src-tauri/scripts/ui-server.mjs diff --git a/src-tauri/scripts/dev.mjs b/src-tauri/scripts/dev.mjs index c542a29..b18a9e9 100644 --- a/src-tauri/scripts/dev.mjs +++ b/src-tauri/scripts/dev.mjs @@ -30,6 +30,8 @@ import { spawn } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { serveUi } from "./ui-server.mjs"; + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); /** @@ -56,6 +58,26 @@ console.log(`CONTENTOS_CONSOLE_URL ${env.CONTENTOS_CONSOLE_URL} the window op console.log(`CONTENTOS_API_URL ${env.CONTENTOS_API_URL} the worker calls this (NestJS)`); console.log(""); +/* + * The setup window's HTML, served rather than embedded, so editing it is a + * save and not a `cargo build`. `tauri.conf.json` points `build.devUrl` here, + * and Tauri prefers it over the compiled-in copy in a debug build — release + * builds never see this file at all. + * + * A port already in use is not worth stopping for: the app still runs, it + * just uses the embedded copy, which is what it did before any of this. + */ +const UI_PORT = 1421; +const ui = await serveUi(path.join(root, "src-tauri", "ui"), UI_PORT).catch((e) => { + console.log(`ui: not serving on ${UI_PORT} (${e.code ?? e.message}) — the window will use its built-in copy`); + console.log(""); + return null; +}); +if (ui) { + console.log(`ui http://localhost:${UI_PORT} setup.html, reloading on save`); + console.log(""); +} + const child = spawn( "cargo", ["run", "--manifest-path", "src-tauri/Cargo.toml", ...process.argv.slice(2)], @@ -73,4 +95,9 @@ child.on("error", (e) => { process.exit(1); }); -child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 1))); +child.on("exit", (code, signal) => { + // The server holds the process open on its own, so closing the window would + // otherwise leave a port bound and the next run quietly on the built-in copy. + ui?.close(); + process.exit(signal ? 1 : (code ?? 1)); +}); diff --git a/src-tauri/scripts/ui-server.mjs b/src-tauri/scripts/ui-server.mjs new file mode 100644 index 0000000..a734f49 --- /dev/null +++ b/src-tauri/scripts/ui-server.mjs @@ -0,0 +1,116 @@ +/** + * The setup window's HTML, served instead of baked in, so an edit shows up + * without a Rust rebuild. + * + * `frontendDist: "ui"` embeds `src-tauri/ui` into the binary at compile time, + * which is right for something a teammate installs and wrong for working on + * it: a comma in a stylesheet cost a full `cargo build`. Tauri prefers + * `build.devUrl` over the embedded copy in a debug build, so in development + * the window loads from here and a save is a reload. + * + * Deliberately not a dependency. This serves four files from one directory to + * one localhost window; a bundler and its config would be more moving parts + * than the thing they were moving. + * + * It is only ever reachable from this machine: bound to 127.0.0.1, never + * started by a release build, and it serves nothing outside `src-tauri/ui`. + */ + +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { watch } from "node:fs"; +import path from "node:path"; + +const TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", +}; + +/* + * How the page learns it is stale. + * + * A version that changes when a file does, and a page that asks for it every + * second. Polling rather than a socket because the whole exchange is a number + * on localhost: a socket would need a protocol, a reconnect and a story about + * what happens when this process restarts, to save an HTTP request a second + * from one window. + * + * `location.reload()` and nothing cleverer. The setup window keeps no state a + * person would mind losing — what it shows is polled from Rust a moment later + * anyway — so trying to preserve any would be work in exchange for a subtler + * kind of wrong. + */ +const RELOAD = ` +`; + +export function serveUi(dir, port) { + let version = String(Date.now()); + + // One watcher for the directory. Editors save by writing a temp file and + // renaming it, so the event that arrives is not always "change" — the + // version is bumped for any of them. + watch(dir, { recursive: false }, () => { + version = String(Date.now()); + }); + + const server = createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + + if (url.pathname === "/__version") { + res.writeHead(200, { "content-type": "text/plain", "cache-control": "no-store" }); + res.end(version); + return; + } + + /* + * Resolved against the directory and then checked to still be inside it. + * `path.join` alone would happily walk out of it with enough `..`, and + * this process can read everything the person running it can. + */ + const name = url.pathname === "/" ? "setup.html" : url.pathname.slice(1); + const file = path.resolve(dir, name); + if (!file.startsWith(path.resolve(dir) + path.sep)) { + res.writeHead(403).end("outside the ui directory"); + return; + } + + try { + await stat(file); + const body = await readFile(file); + const ext = path.extname(file).toLowerCase(); + res.writeHead(200, { + "content-type": TYPES[ext] ?? "application/octet-stream", + "cache-control": "no-store", + }); + // The reload snippet goes only into HTML, and only in development — + // this file is never reached by a release build. + res.end(ext === ".html" ? body.toString("utf8") + RELOAD : body); + } catch { + res.writeHead(404).end("not here"); + } + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs index 2176bf2..044f0b5 100644 --- a/src-tauri/src/account.rs +++ b/src-tauri/src/account.rs @@ -660,11 +660,7 @@ mod tests { /// Machines with a row per launch, each holding a live credential. #[test] fn a_machine_that_is_still_listed_is_kept() { - assert!(already_enrolled( - "wrk_1", - true, - &ids(&["wrk_9", "wrk_1"]) - )); + assert!(already_enrolled("wrk_1", true, &ids(&["wrk_9", "wrk_1"]))); } /// Revoked in Settings → Machines. The row is gone, so the token in the @@ -742,7 +738,9 @@ mod tests { tauri::async_runtime::block_on(async move { let http = client().expect("a client"); - let me = login(&http, &api, &email, &password).await.expect("sign in"); + let me = login(&http, &api, &email, &password) + .await + .expect("sign in"); let session = me .session .clone() @@ -788,7 +786,9 @@ mod tests { */ let console = std::env::var("CONTENTOS_TEST_CONSOLE") .unwrap_or_else(|_| "http://localhost:3333".into()); - let code = handoff(&http, &api, &header).await.expect("a hand-off code"); + let code = handoff(&http, &api, &header) + .await + .expect("a hand-off code"); let url = adopt_url(&api, &code, &console).expect("an adopt url"); let webview = reqwest::Client::builder() @@ -837,7 +837,9 @@ mod tests { // And the password still has to be right, over the same client // against the same server — a login that accepts anything would // pass every assertion above. - assert!(login(&http, &api, &email, "not-the-password").await.is_err()); + assert!(login(&http, &api, &email, "not-the-password") + .await + .is_err()); }); } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index bc7371f..f918416 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -461,9 +461,9 @@ fn secret(account: &str) -> String { fn set_secret(account: &str, value: &str, what: &str) -> Result<(), String> { let entry = keyring::Entry::new(SERVICE, account) .map_err(|e| format!("Could not open this machine's credential store: {e}"))?; - entry.set_password(value).map_err(|e| { - format!("Could not save {what} to this machine's credential store: {e}") - }) + entry + .set_password(value) + .map_err(|e| format!("Could not save {what} to this machine's credential store: {e}")) } /// Turn what somebody typed into the origin that will actually be used. diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 3258658..4015a54 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -88,11 +88,13 @@ pub fn setup(app: &AppHandle) { // is what prevents. let _ = WebviewWindowBuilder::new(app, "setup", WebviewUrl::App("setup.html".into())) .title("Content OS — this machine") - // Tall enough for the three panels — status, sign in, addresses — on a - // first run, because the one that matters on a first run is the middle - // one, and a window that opens with the sign-in below the fold is a - // window somebody reads as "nothing here to do". - .inner_size(560.0, 780.0) + // Sized for the sign-in, which on a first run is the whole screen: a + // mark, a heading, two fields and a button. It used to be 780 tall for + // three stacked panels, and everything but the sign-in is behind one + // disclosure now — so the height was measuring content that is no + // longer there, and an empty half-window reads as a screen still + // loading. It grows on its own when somebody opens the diagnostics. + .inner_size(460.0, 560.0) .build(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 91337dd..82570f7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,7 +4,8 @@ "version": "0.1.0", "identifier": "com.contentos.desktop", "build": { - "frontendDist": "ui" + "frontendDist": "ui", + "devUrl": "http://localhost:1421" }, "app": { "withGlobalTauri": true, diff --git a/src-tauri/ui/setup.html b/src-tauri/ui/setup.html index 89974ef..b3c8d93 100644 --- a/src-tauri/ui/setup.html +++ b/src-tauri/ui/setup.html @@ -174,24 +174,84 @@ -webkit-user-select: text; user-select: text; } + + /* + The console's own sign-in, in the values this window can reach. + components/views/AuthView.tsx is the screen it is matching: the mark + beside the wordmark, a 27px tight heading, one sentence, then the + panel. The kit is not importable here — this is plain HTML in a Tauri + window — so the numbers are copied, not the components. + */ + .brand { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 22px; + } + .mark { + width: 30px; + height: 30px; + border-radius: 9px; + background: linear-gradient(150deg, #5b93ff, #0057fc); + } + .wordmark { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.02em; + } + h1#title { + font-size: 27px; + font-weight: 700; + letter-spacing: -0.03em; + margin: 0 0 6px; + } + + /* + One line, and everything that is not signing in behind it. Quiet on + purpose: a person who needs it is looking for it, and a person who does + not should not be reading it. + */ + .diag { + margin-top: 22px; + } + .diag > summary { + cursor: pointer; + font-size: 12.5px; + color: rgba(255, 255, 255, 0.45); + padding: 6px 0; + } + .diag > summary:hover { + color: rgba(255, 255, 255, 0.72); + } + .diag[open] > summary { + margin-bottom: 10px; + } -

This machine

-

- The console runs on your team's server. This app opens it, and runs the - worker that does the writing and the video work here, on this computer. -

+ +
+ + Content OS
+

Sign in

+

+ This app opens your team's console and runs the worker on this computer. +

+
-

Sign in to Content OS

+ +
+ This machine's setup and worker + +
+
Starting…
+

+
+ What the worker is saying +

+      
+
+
+