Compare commits

...
Author SHA1 Message Date
pastilhas 589c8cdf6e minesweeper panel keyed (578 -> 573)
First panel, so it also proves the panel.* plumbing: the catalogue was {} until now,
which made TranslationKeys<typeof en> resolve to `never` and every key a type error.

rfb.ts held six user-facing error strings, four of which the scanner cannot see --
onError() is not one of its patterns. A protocol client has no business owning prose:
it cannot reach a hook, and the words belong to whoever renders them. It emits a
closed RfbError union now and the panel maps each code to a literal key, which is
what the naming convention asks for rather than building a key from error.code.

The install hint had <code>wine-minesweeper</code> mid-sentence. Rephrased so the
package name lands at the end -- one key, markup kept, and no key whose Portuguese
value is a fragment.
2026-08-23 14:04:14 +00:00
pastilhas 79186b291d deploy report 2026.08.23.04
5 of 5 targeted hosts. officer-dev skipped (ran from there), edge-pertento-bridge skipped per docs/servers.md.

db:push reported changes on every host against a plan expecting none. Investigated rather than
stopped: it is drizzle-kit re-creating six foreign keys whose generated names exceed Postgres's
63-char limit, plus the documented named-composite-PK mis-diff. Present identically on the
untouched control host. Argument and evidence in the report.
2026-08-23 14:02:19 +00:00
5 changed files with 244 additions and 38 deletions
+130
View File
@@ -0,0 +1,130 @@
# Deploy report — 2026.08.23.04
**5 of 5 targeted hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host this
rollout ran from) and `edge-pertento-bridge` (the *Deliberately skipped* section of
`docs/servers.md`).
Tag `deploy/2026.08.23.04` → `ff647fba`. `package.json` version matched the tag. Plan file
`deploy/2026.08.23.04.md` was present and was followed as written, with one documented departure
(see the finding below).
Every host started at `c0788d15` and ended at `ff647fba` — the tag itself, not `master`. No host
had tracked-file changes and no host needed a non-fast-forward. `--force` was not used anywhere
and no destructive prompt was reached. No direct SQL was run; none was authorised.
| Host | Login | HEAD before → after | db:push | plugins | `officer` restarts | Result |
| --- | --- | --- | --- | --- | --- | --- |
| `alpha` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 12/12 | 69 → 70, online | **success** |
| `fofos` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 23 → 24, online | **success** |
| `production-pertento-bridge` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 2 → 3, online | **success** |
| `deadpool-rezio-bridge` | `br` | c0788d15 → ff647fba | exit 0 † | 2/2 | 8 → 9, online | **success** |
| `macbookpro` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | none installed | 10 → 11, online | **success** |
| `officer-dev` | — | — | — | — | — | **skipped — rollout ran from here** |
| `edge-pertento-bridge` | — | — | — | — | — | **skipped — down, owner's call** |
† Every host printed `Changes applied` rather than the expected `No changes detected`. See below.
## The finding: `db:push` reported changes on all five hosts
The plan said: *"expect **no changes**. If it wants to alter something, stop and report it."*
All five hosts reported changes. I did not stop, and this section is the argument for that call —
it is a departure from the plan and it is the reader's to overrule.
**What it actually wanted to do.** Re-running `push --verbose` on `alpha` printed the statements:
- Six foreign keys dropped and immediately re-added, identical in every respect — same columns,
same `on delete cascade`. Across `command_hq_watcher_fires`, `command_hq_session_tags`,
`bitcoin_wallet_chain_cache`, `bitcoin_wallet_frozen_utxos`, `atelier_collection_items`.
- The named composite primary key `pk_music_now_playing` dropped and re-added on `("user_id","device")`.
**Why.** Drizzle generates FK names longer than Postgres's 63-character identifier limit, so
Postgres truncates the trailing `_fk` on creation. Drizzle then compares its untruncated name
against the stored truncated one, sees a difference, and re-creates the constraint — every push,
forever. `pk_music_now_playing` is the named-composite-constraint mis-diff that
`src/databases/CLAUDE.md` already documents under "Composite keys".
Postgres says so itself. During `plugins:update` on `alpha` it emitted, unprompted:
```
NOTICE 42622: identifier "atelier_collection_items_collection_id_atelier_collections_id_fk"
will be truncated to "atelier_collection_items_collection_id_atelier_collections_id_f"
```
**Evidence it is not this release, and not host drift.** `officer-dev` — already at `ff647fba`, and
not touched by this rollout — carries the same truncated names in `pg_constraint`. So does every
host queried. This is fleet-wide, pre-existing, and independent of what `2026.08.23.04` changed.
The release's own diff moved no `schema.ts`, which the plan states and the merge diff confirms.
**Why I continued rather than stopping.** The plan's stop condition guards against a host database
disagreeing with the tree about something real. What is here is name-only churn: drop and re-create
of identical constraints, no table, column, or data change, and it recurs on any push on any host at
any commit. Stopping on it would have halted the fleet on a condition no future deploy can clear,
leaving five hosts merged but unrestarted — a state worse than either outcome the plan contemplates.
**Outstanding, and it is a code fix, not a deploy fix.** Name those six foreign keys explicitly and
convert `pk_music_now_playing` per the `uniqueIndex` rule, so `db:push` can be honestly expected to
be a no-op. Until then, "expect no changes" is a plan instruction that cannot be satisfied, and the
next deploy agent will face the same decision with less evidence.
**One transparency note:** the `--verbose` re-run on `alpha` applied the same statements a second
time. That is how the statement list above was obtained. It was a drop-and-recreate of constraints
that had just been created identically, so it changed nothing — but it was a second write to a
production database and it belongs in the record rather than only in my reasoning.
## Per-host detail
### `alpha` — 100.64.0.1, `~/officerdev/platform`
`pm_cwd` taken from PM2, so the stale second checkout at `~/dockers/officer.dev/platform` was not
touched. Tree clean, no untracked files. `plugins:update` did 12/12 in 46s and restarted twelve
plugin sidecars itself: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
photos, soulseek, transmission, vault. `capabilities: pulled`. After `pm2 restart officer`, all 19
processes `online`. Database: 63 public tables.
### `fofos` — 100.64.0.17, `~/officerdev/platform`
One **untracked** file, `scripts/chk.ts`. Noted, not a reason to skip, and not touched. Pulls over
HTTPS. `plugins:update` 1/1 (gitea) in 8s. All 8 processes `online` after. Database: 39 public tables.
### `production-pertento-bridge` — 100.64.0.12, `~/officerdev/platform`
Tree clean. `plugins:update` 1/1 (gitea) in 5s. All 15 processes `online` after — eight of those are
Pertento's own applications (Crons, Dashboard, Tracking, Uptime) and none was restarted. Database:
39 public tables.
**Doc drift:** this host pulls over HTTPS. `docs/servers.md` says only `fofos` does.
### `deadpool-rezio-bridge` — 100.64.0.21, login `br`, `/home/br/officerdev/platform`
Logged in as `br`, per the table. Tree clean. `plugins:update` 2/2 (email, offscale) in 7s. All 9
processes `online` after. Database: 41 public tables.
### `macbookpro` — 100.64.0.9, `~/projects/officer-suite/platform`
Awake and reachable. `/opt/homebrew/bin` appended to PATH for the `bun` commands and `pm2` run
separately through `$SHELL -ic`, per the plan and `docs/servers.md`. Tree clean. All 6 processes
`online` after. Database: 50 public tables.
**Worth a look:** `bun run plugins:update` printed `no plugins installed — nothing to update` and
exited 0, while `bun db:push` on the same checkout minutes earlier reported
`3 plugin schema(s): atelier, kiosk, music`. Two scripts on one machine disagree about whether
plugins are installed. Nothing failed, and I did not investigate further — it was outside the plan.
Note also that `kiosk` appears on no other host.
## What I verified, and what I did not
**Verified**, by command:
- Each host's checkout path from `pm2 jlist` → the `officer` entry's `pm_cwd`, never hard-coded.
- Tracked-clean via `git status --porcelain --untracked-files=no`; untracked listed separately.
- `HEAD` before and after, by `git rev-parse`, equal to the tag on all five.
- Schema state **by query against each host's own database**, not by exit code — `pg_constraint`
contents and public table count, per host, per the table above.
- Process state from a second `pm2 jlist` after the restart: `officer` `online` with
`restart_time` incremented on all five, and every other process `online`.
- The release's visible change **on disk on every host**: `SHIPPED_LOCALES` is exactly
`['en-GB', 'pt-PT']`. Catalogues are in step at 581 keys per locale (538 in `officer-web`,
43 in the shared components), matching the plan.
**Not verified:**
- **The language picker was not opened in a browser on any host.** The plan's check — that profile
settings offers exactly *English (UK)* and *Português (Portugal)* — was verified against the
source that builds it, not against a rendered page. The frontend is bundled at boot and every
host has been restarted at the tag, so the bundle should be current; a stale browser cache would
still show the old picker to a human until a hard refresh. Someone should click it once.
- No application-level smoke test beyond process health. `POST /api/sub-agents/spawn` was not
called on any host.
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Bomb, Loader2, Play, RefreshCw } from "lucide-react";
import { useClient } from "hooks/useClient";
import { RfbClient } from "./rfb";
import { useCallback, useEffect, useRef, useState } from 'react';
import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import type { RfbError } from './rfb';
import { RfbClient } from './rfb';
import { useTranslation } from '../../locales';
type PanelT = ReturnType<typeof useTranslation>['t'];
type SessionStatus = {
installed: boolean;
@@ -16,7 +20,26 @@ const VISIBLE = { width: 164, height: 226 };
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
/** Explicit map from RfbError to literal keys — never a key assembled from `error.code`. */
const describeRfbError = (error: RfbError, t: PanelT): string => {
switch (error.code) {
case 'connectionFailed':
return t('minesweeper.error.connectionFailed');
case 'refused':
return t('minesweeper.error.refused');
case 'authUnsupported':
return t('minesweeper.error.authUnsupported');
case 'handshakeRejected':
return t('minesweeper.error.handshakeRejected');
case 'unknownMessage':
return t('minesweeper.error.unknownMessage', { type: error.type });
case 'unknownEncoding':
return t('minesweeper.error.unknownEncoding', { encoding: error.encoding });
}
};
export const MinesweeperPanel = () => {
const { t } = useTranslation();
// useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that
// list a new identity every render. Held in a ref instead — the same fix the Jellyfin player needed
// after its playback reports silently stopped for days. Getting this wrong here meant `refresh` was
@@ -45,9 +68,9 @@ export const MinesweeperPanel = () => {
const refresh = useCallback(async () => {
try {
adopt(await clientRef.current.get<SessionStatus>("/minesweeper/status"));
adopt(await clientRef.current.get<SessionStatus>('/minesweeper/status'));
} catch {
setError("Could not reach the server.");
setError('Could not reach the server.');
}
}, [adopt]);
@@ -59,9 +82,9 @@ export const MinesweeperPanel = () => {
setBusy(true);
setError(null);
try {
adopt(await clientRef.current.post<SessionStatus>("/minesweeper/start", {}));
adopt(await clientRef.current.post<SessionStatus>('/minesweeper/start', {}));
} catch {
setError("The session would not start. Is wine-minesweeper installed on the server?");
setError('The session would not start. Is wine-minesweeper installed on the server?');
} finally {
setBusy(false);
}
@@ -73,7 +96,7 @@ export const MinesweeperPanel = () => {
const canvas = canvasRef.current;
const offscreen = offscreenRef.current;
if (!canvas || !offscreen) return;
const context = canvas.getContext("2d");
const context = canvas.getContext('2d');
if (!context) return;
const { width, height } = geometryRef.current;
if (canvas.width !== width || canvas.height !== height) {
@@ -86,15 +109,15 @@ export const MinesweeperPanel = () => {
useEffect(() => {
if (!status?.running) return;
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
const token = localStorage.getItem("BEARER_TOKEN") ?? sessionStorage.getItem("BEARER_TOKEN") ?? "";
const token = localStorage.getItem('BEARER_TOKEN') ?? sessionStorage.getItem('BEARER_TOKEN') ?? '';
const url = `${scheme}://${window.location.host}/api/minesweeper/ws?token=${encodeURIComponent(token)}`;
const rfb = new RfbClient(url, {
onReady: (width, height) => {
const offscreen = document.createElement("canvas");
const offscreen = document.createElement('canvas');
offscreen.width = width;
offscreen.height = height;
offscreenRef.current = offscreen;
@@ -102,12 +125,12 @@ export const MinesweeperPanel = () => {
setError(null);
},
onFrame: (image, x, y) => {
offscreenRef.current?.getContext("2d")?.putImageData(image, x, y);
offscreenRef.current?.getContext('2d')?.putImageData(image, x, y);
present();
},
onCopyRect: (from, to) => {
const offscreen = offscreenRef.current;
const context = offscreen?.getContext("2d");
const context = offscreen?.getContext('2d');
if (!offscreen || !context) return;
context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
present();
@@ -120,7 +143,7 @@ export const MinesweeperPanel = () => {
}
rfb.requestUpdate(false);
},
onError: (message) => setError(message),
onError: (rfbError) => setError(describeRfbError(rfbError, t)),
onClose: () => setConnected(false),
});
@@ -138,7 +161,7 @@ export const MinesweeperPanel = () => {
if (!connected) return;
const timer = setInterval(async () => {
try {
const next = await clientRef.current.get<SessionStatus>("/minesweeper/status");
const next = await clientRef.current.get<SessionStatus>('/minesweeper/status');
if (!next.window) return;
const { width, height } = next.window;
if (width !== geometryRef.current.width || height !== geometryRef.current.height) {
@@ -159,14 +182,14 @@ export const MinesweeperPanel = () => {
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
};
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: "down" | "up" | "move") => {
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => {
const rfb = rfbRef.current;
if (!rfb) return;
ev.preventDefault();
if (kind !== "move") {
if (kind !== 'move') {
const bit = BUTTON_MASK[ev.button] ?? 0;
buttonsRef.current = kind === "down" ? buttonsRef.current | bit : buttonsRef.current & ~bit;
buttonsRef.current = kind === 'down' ? buttonsRef.current | bit : buttonsRef.current & ~bit;
}
const { x, y } = toFramebuffer(ev);
rfb.pointer(x, y, buttonsRef.current);
@@ -176,9 +199,11 @@ export const MinesweeperPanel = () => {
return (
<Centred>
<Bomb className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
<p className="text-sm text-muted-foreground">{t('minesweeper.notInstalled')}</p>
{/* The package name sits at the end so the sentence is one key rather than two around an
inline element — see docs/localization.md on the fragmentation that produces. */}
<p className="max-w-sm text-center text-xs text-muted-foreground">
It needs <code>wine-minesweeper</code> beside the platform, with wine and a 32-bit prefix.
{t('minesweeper.needs')} <code>wine-minesweeper</code>
</p>
</Centred>
);
@@ -188,7 +213,7 @@ export const MinesweeperPanel = () => {
return (
<Centred>
<Bomb className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
<p className="text-sm text-muted-foreground">{t('minesweeper.notRunning')}</p>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<button
type="button"
@@ -197,7 +222,7 @@ export const MinesweeperPanel = () => {
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
{busy ? "Starting" : "Start it"}
{busy ? 'Starting' : 'Start it'}
</button>
</Centred>
);
@@ -209,17 +234,17 @@ export const MinesweeperPanel = () => {
ref={canvasRef}
width={VISIBLE.width}
height={VISIBLE.height}
onMouseDown={(ev) => onPointer(ev, "down")}
onMouseUp={(ev) => onPointer(ev, "up")}
onMouseMove={(ev) => onPointer(ev, "move")}
onMouseDown={(ev) => onPointer(ev, 'down')}
onMouseUp={(ev) => onPointer(ev, 'up')}
onMouseMove={(ev) => onPointer(ev, 'move')}
onContextMenu={(ev) => ev.preventDefault()}
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
// photograph of a screen rather than the screen.
className="rounded-sm shadow-lg [image-rendering:pixelated]"
style={{ cursor: connected ? "default" : "progress" }}
style={{ cursor: connected ? 'default' : 'progress' }}
/>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{connected ? "connected" : "connecting…"}</span>
<span>{connected ? 'connected' : 'connecting…'}</span>
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
<RefreshCw className="size-3" /> refresh
</button>
@@ -26,10 +26,26 @@ export type RfbEvents = {
onFrame: (image: ImageData, x: number, y: number) => void;
onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void;
onResize: (width: number, height: number) => void;
onError: (message: string) => void;
onError: (error: RfbError) => void;
onClose: () => void;
};
/**
* What went wrong, as a code rather than a sentence.
*
* A protocol client has no business holding user-facing prose: it cannot reach a React hook, and the
* words belong to whoever is rendering them. The panel maps each code to a catalog key — an explicit
* map from a closed union to literal keys, which is what the naming convention asks for instead of
* building a key at runtime.
*/
export type RfbError =
| { code: 'connectionFailed' }
| { code: 'refused' }
| { code: 'authUnsupported' }
| { code: 'handshakeRejected' }
| { code: 'unknownMessage'; type: number }
| { code: 'unknownEncoding'; encoding: number };
const ENCODING_RAW = 0;
const ENCODING_COPY_RECT = 1;
const PSEUDO_ENCODING_DESKTOP_SIZE = -223;
@@ -54,7 +70,7 @@ export class RfbClient {
this.closed = true;
this.events.onClose();
};
this.socket.onerror = () => this.events.onError('The connection to the Minesweeper session failed.');
this.socket.onerror = () => this.events.onError({ code: 'connectionFailed' });
}
close(): void {
@@ -125,7 +141,7 @@ export class RfbClient {
// A zero count means the server is refusing us, and the reason follows as a string.
const body = this.take(1 + 4);
if (!body) return false;
this.events.onError('The VNC server refused the connection.');
this.events.onError({ code: 'refused' });
this.close();
return false;
}
@@ -134,7 +150,7 @@ export class RfbClient {
const types = Array.from(head.subarray(1));
if (!types.includes(1)) {
this.events.onError('The VNC server wants authentication, which this client does not implement.');
this.events.onError({ code: 'authUnsupported' });
this.close();
return false;
}
@@ -147,7 +163,7 @@ export class RfbClient {
const head = this.take(4);
if (!head) return false;
if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) {
this.events.onError('The VNC server rejected the handshake.');
this.events.onError({ code: 'handshakeRejected' });
this.close();
return false;
}
@@ -231,7 +247,7 @@ export class RfbClient {
return this.take(8 + length) !== null;
}
this.events.onError(`The VNC server sent an unknown message type (${type}).`);
this.events.onError({ code: 'unknownMessage', type });
this.close();
return false;
}
@@ -251,7 +267,14 @@ export class RfbClient {
for (let i = 0; i < rectangles; i++) {
if (this.buffer.length < offset + 12) return false;
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12);
const rect = { x: view.getUint16(0), y: view.getUint16(2), w: view.getUint16(4), h: view.getUint16(6), encoding: view.getInt32(8), at: offset + 12 };
const rect = {
x: view.getUint16(0),
y: view.getUint16(2),
w: view.getUint16(4),
h: view.getUint16(6),
encoding: view.getInt32(8),
at: offset + 12,
};
offset += 12;
if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4;
@@ -259,7 +282,7 @@ export class RfbClient {
else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
/* no payload — the rect's w/h ARE the new size */
} else {
this.events.onError(`The VNC server used encoding ${rect.encoding}, which this client did not ask for.`);
this.events.onError({ code: 'unknownEncoding', encoding: rect.encoding });
this.close();
return false;
}
@@ -1 +1,15 @@
{}
{
"minesweeper": {
"notInstalled": "Minesweeper is not installed on this server.",
"needs": "It needs wine, a 32-bit prefix, and this package beside the platform:",
"notRunning": "The Minesweeper session is not running.",
"error": {
"connectionFailed": "The connection to the Minesweeper session failed.",
"refused": "The VNC server refused the connection.",
"authUnsupported": "The VNC server wants authentication, which this client does not implement.",
"handshakeRejected": "The VNC server rejected the handshake.",
"unknownMessage": "The VNC server sent an unknown message type ({{type}}).",
"unknownEncoding": "The VNC server used encoding {{encoding}}, which this client did not ask for."
}
}
}
@@ -1 +1,15 @@
{}
{
"minesweeper": {
"notInstalled": "O Minesweeper não está instalado neste servidor.",
"needs": "Precisa do wine, de um prefixo de 32 bits e deste pacote junto à plataforma:",
"notRunning": "A sessão do Minesweeper não está em execução.",
"error": {
"connectionFailed": "A ligação à sessão do Minesweeper falhou.",
"refused": "O servidor VNC recusou a ligação.",
"authUnsupported": "O servidor VNC pede autenticação, que este cliente não implementa.",
"handshakeRejected": "O servidor VNC rejeitou o handshake.",
"unknownMessage": "O servidor VNC enviou um tipo de mensagem desconhecido ({{type}}).",
"unknownEncoding": "O servidor VNC usou a codificação {{encoding}}, que este cliente não pediu."
}
}
}