offscale is a plugin

headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 00:15:38 +00:00
co-authored by Claude Opus 5
parent 0e24aa3d52
commit e13128846b
111 changed files with 351 additions and 302 deletions
+1 -4
View File
@@ -21,10 +21,7 @@ async function tick() {
console.log(`[email-cron] ${account.email}: ${result.saved} new emails`);
}
} catch (err) {
console.error(
`[email-cron] Failed to resync ${account.email}:`,
err instanceof Error ? err.message : err,
);
console.error(`[email-cron] Failed to resync ${account.email}:`, err instanceof Error ? err.message : err);
}
}
} catch (err) {
+9 -1
View File
@@ -115,7 +115,15 @@ async function connect(w: Watcher): Promise<void> {
function startWatcher(account: Account): void {
if (watchers.has(account.id)) return;
const w: Watcher = { account, client: null, closing: false, syncing: false, pending: false, backoff: RECONNECT_BASE, reconnectTimer: null };
const w: Watcher = {
account,
client: null,
closing: false,
syncing: false,
pending: false,
backoff: RECONNECT_BASE,
reconnectTimer: null,
};
watchers.set(account.id, w);
void connect(w);
}
+1 -3
View File
@@ -668,9 +668,7 @@ const gmailSyncHandler = {
});
}
console.log(
`[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`,
);
console.log(`[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`);
setSyncMeta(db, 'last_sync_date', new Date().toISOString().split('T')[0]!);
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
-1
View File
@@ -6,7 +6,6 @@ import { startEmailServer } from './http';
import { createSidecarConnector } from '../connect';
import { API_URL } from '../../officer-url.mjs';
// The sidecar used to reach BACK into the platform's queue over this socket to get a sync run —
// enqueueViaWs / listJobsViaWs and a pending-response map. Syncs run in this process now
// (sync-runner.ts), so the shim is gone and nothing but a port crosses the socket at startup.
-1
View File
@@ -364,7 +364,6 @@ emailRouter.delete('/messages/:id', async (ctx) => {
}
});
emailRouter.get('/sync-status', async (ctx) => {
const user = ctx.get('user');
+67 -18
View File
@@ -122,7 +122,13 @@ const firstAddress = (value: unknown): string => {
* Groups by normalized subject + the counterpart address, so recurring 1:1 conversations collapse
* while unrelated same-subject mail from different people stays apart. Trivial subjects stay ungrouped.
*/
function fallbackThreadId(row: { id: string; subject: unknown; from_address: unknown; to_address: unknown; email_account: unknown }): string {
function fallbackThreadId(row: {
id: string;
subject: unknown;
from_address: unknown;
to_address: unknown;
email_account: unknown;
}): string {
const norm = normalizeSubject(typeof row.subject === 'string' ? row.subject : '');
if (!norm) return row.id;
const me = typeof row.email_account === 'string' ? row.email_account.toLowerCase() : '';
@@ -202,7 +208,13 @@ function migrate(db: Database): void {
function backfillThreadIds(db: Database): void {
const rows = db
.query('SELECT id, subject, from_address, to_address, email_account FROM emails WHERE thread_id IS NULL')
.all() as Array<{ id: string; subject: unknown; from_address: unknown; to_address: unknown; email_account: unknown }>;
.all() as Array<{
id: string;
subject: unknown;
from_address: unknown;
to_address: unknown;
email_account: unknown;
}>;
if (rows.length === 0) return;
const update = db.prepare('UPDATE emails SET thread_id = ? WHERE id = ?');
@@ -243,9 +255,18 @@ function ensureFts(db: Database): void {
}
const ftsDeleteStmt = 'DELETE FROM emails_fts WHERE id = ?';
const ftsInsertStmt = 'INSERT INTO emails_fts (id, subject, sender, recipients, snippet, body) VALUES (?, ?, ?, ?, ?, ?)';
const ftsInsertStmt =
'INSERT INTO emails_fts (id, subject, sender, recipients, snippet, body) VALUES (?, ?, ?, ?, ?, ?)';
function syncFtsRow(db: Database, id: string, subject: string, sender: string, recipients: string, snippet: string, body: string): void {
function syncFtsRow(
db: Database,
id: string,
subject: string,
sender: string,
recipients: string,
snippet: string,
body: string,
): void {
db.run(ftsDeleteStmt, [id]);
db.run(ftsInsertStmt, [id, subject, sender, recipients, snippet, body]);
}
@@ -319,7 +340,12 @@ function parseBranch(q: string): Branch {
return { fts: fts.join(' '), where, params };
}
export function searchEmails(db: Database, q: string, limit: number, offset: number): { rows: Record<string, unknown>[]; total: number } {
export function searchEmails(
db: Database,
q: string,
limit: number,
offset: number,
): { rows: Record<string, unknown>[]; total: number } {
// Split on top-level uppercase OR into branches (Gmail-style; lowercase "or" stays a search word).
const branches = q
.split(/\s+OR\s+/)
@@ -343,7 +369,9 @@ export function searchEmails(db: Database, q: string, limit: number, offset: num
}
const whereSql = `e.deleted = 0 AND (${conds.join(' OR ')})`;
const rows = db.query(`SELECT e.* FROM emails e WHERE ${whereSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`).all(...params, limit, offset) as Record<string, unknown>[];
const rows = db
.query(`SELECT e.* FROM emails e WHERE ${whereSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`)
.all(...params, limit, offset) as Record<string, unknown>[];
const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${whereSql}`).get(...params) as { c: number }).c;
return { rows, total };
}
@@ -372,7 +400,8 @@ const upsertEmailStmt = `
`;
const deleteAttachmentsStmt = 'DELETE FROM attachments WHERE email_id = ?';
const insertAttachmentStmt = 'INSERT INTO attachments (email_id, idx, filename, size, content_type, content) VALUES (?, ?, ?, ?, ?, ?)';
const insertAttachmentStmt =
'INSERT INTO attachments (email_id, idx, filename, size, content_type, content) VALUES (?, ?, ?, ?, ?, ?)';
export function upsertEmail(db: Database, email: ParsedEmail): void {
const domain = extractDomain(email.fromAddress);
@@ -404,7 +433,15 @@ export function upsertEmail(db: Database, email: ParsedEmail): void {
db.run(insertAttachmentStmt, [email.id, i, att.filename, att.size, att.contentType, att.content]);
}
syncFtsRow(db, email.id, email.subject ?? '', `${email.fromName ?? ''} ${email.fromAddress ?? ''}`.trim(), `${email.to ?? ''} ${email.cc ?? ''}`.trim(), email.snippet ?? '', email.text ?? '');
syncFtsRow(
db,
email.id,
email.subject ?? '',
`${email.fromName ?? ''} ${email.fromAddress ?? ''}`.trim(),
`${email.to ?? ''} ${email.cc ?? ''}`.trim(),
email.snippet ?? '',
email.text ?? '',
);
db.exec('COMMIT');
} catch (err) {
@@ -439,7 +476,22 @@ export function upsertFromRawEml({ db, id, raw, integration, emailAccount, label
const threadId = computeThreadId(db, id, raw);
db.run(upsertEmailStmt, [
id, integration, emailAccount, name, address, domain, to, cc, subject, date, snippet, html, text, attachments.length, labelsToString(labels), threadId,
id,
integration,
emailAccount,
name,
address,
domain,
to,
cc,
subject,
date,
snippet,
html,
text,
attachments.length,
labelsToString(labels),
threadId,
]);
if (attachments.length > 0) {
@@ -455,9 +507,7 @@ export function upsertFromRawEml({ db, id, raw, integration, emailAccount, label
/** Convert a db row to an EmailSummary for the API */
export function rowToSummary(row: Record<string, unknown>): EmailSummary {
const from = row.from_name
? `${row.from_name} <${row.from_address}>`
: (row.from_address as string);
const from = row.from_name ? `${row.from_name} <${row.from_address}>` : (row.from_address as string);
const labels = labelsFromString(row.labels);
@@ -604,7 +654,7 @@ function normalizeCharset(charset: string): string {
'windows-1252': 'latin1',
'windows-1254': 'latin1',
'us-ascii': 'ascii',
'ascii': 'ascii',
ascii: 'ascii',
};
return map[charset] ?? charset;
}
@@ -691,7 +741,8 @@ function parseAttachments(raw: string): AttachmentMeta[] {
// Walk backwards to find the start of this MIME part's headers
const partStart = raw.lastIndexOf('\n--', pos);
const headerBlock = partStart !== -1 ? raw.slice(partStart, pos + 500) : raw.slice(Math.max(0, pos - 500), pos + 500);
const headerBlock =
partStart !== -1 ? raw.slice(partStart, pos + 500) : raw.slice(Math.max(0, pos - 500), pos + 500);
// Skip inline parts without a filename (e.g. inline text/plain body parts)
const hasFilename = /filename/i.test(headerBlock);
@@ -731,12 +782,10 @@ function parseAttachments(raw: string): AttachmentMeta[] {
content = bodyRaw.replace(/\s/g, '');
} else {
// For quoted-printable or 7bit/8bit, re-encode to base64
const buf = encoding === 'quoted-printable'
? decodeQuotedPrintableBytes(bodyRaw)
: Buffer.from(bodyRaw);
const buf = encoding === 'quoted-printable' ? decodeQuotedPrintableBytes(bodyRaw) : Buffer.from(bodyRaw);
content = buf.toString('base64');
}
size = Math.floor(content.length * 3 / 4);
size = Math.floor((content.length * 3) / 4);
}
results.push({ filename, size, contentType, content });