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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -364,7 +364,6 @@ emailRouter.delete('/messages/:id', async (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
emailRouter.get('/sync-status', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user