task logs: migrate from filesystem to postgresql; refactor sidecars into submodules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import type { Job, EnqueueParams } from '../../queue/types';
|
||||
import { getAllSyncedAccounts, getUserById, getUserIntegration } from 'officerdb';
|
||||
|
||||
const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let enqueueFn: ((params: EnqueueParams) => Promise<Job>) | null = null;
|
||||
let listJobsFn: (() => Promise<Job[]>) | null = null;
|
||||
|
||||
async function tick() {
|
||||
if (!enqueueFn || !listJobsFn) return;
|
||||
|
||||
try {
|
||||
const accounts = await getAllSyncedAccounts();
|
||||
if (accounts.length === 0) return;
|
||||
|
||||
const allJobs = await listJobsFn();
|
||||
const activeEmailSyncIds = new Set(
|
||||
allJobs
|
||||
.filter((j) => j.type === 'email-sync' && (j.status === 'queued' || j.status === 'running'))
|
||||
.map((j) => (j.meta as Record<string, unknown> | undefined)?.emailAccountId),
|
||||
);
|
||||
|
||||
for (const account of accounts) {
|
||||
if (activeEmailSyncIds.has(account.id)) continue;
|
||||
|
||||
const user = await getUserById(account.userId);
|
||||
if (!user) continue;
|
||||
|
||||
// Resolve IMAP auth
|
||||
const imapAuth: Record<string, unknown> = { user: account.email };
|
||||
if (account.authType === 'oauth') {
|
||||
const integration = await getUserIntegration(account.userId, 'google');
|
||||
const config = integration?.config as Record<string, unknown> | undefined;
|
||||
const accessToken = config?.accessToken as string | undefined;
|
||||
if (!accessToken) {
|
||||
console.log(`[email-cron] Skipping ${account.email}: no OAuth access token`);
|
||||
continue;
|
||||
}
|
||||
imapAuth.accessToken = accessToken;
|
||||
} else {
|
||||
const creds = account.credentials as Record<string, unknown>;
|
||||
imapAuth.pass = creds.password;
|
||||
}
|
||||
|
||||
try {
|
||||
await enqueueFn({
|
||||
lane: 'email',
|
||||
type: 'email-sync',
|
||||
userId: user.email,
|
||||
meta: {
|
||||
emailAccountId: account.id,
|
||||
userEmail: user.email,
|
||||
account: {
|
||||
id: account.id,
|
||||
userId: account.userId,
|
||||
email: account.email,
|
||||
imapHost: account.imapHost,
|
||||
imapPort: account.imapPort,
|
||||
imapSecure: account.imapSecure,
|
||||
provider: account.provider,
|
||||
authType: account.authType,
|
||||
credentials: account.credentials,
|
||||
},
|
||||
imapAuth,
|
||||
},
|
||||
});
|
||||
console.log(`[email-cron] Enqueued incremental sync for ${account.email}`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[email-cron] Failed to enqueue sync for ${account.email}:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[email-cron] Error:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
type EmailCronDeps = {
|
||||
enqueue: (params: EnqueueParams) => Promise<Job>;
|
||||
listJobs: () => Promise<Job[]>;
|
||||
};
|
||||
|
||||
export function initEmailCron(deps: EmailCronDeps) {
|
||||
if (timer) return;
|
||||
enqueueFn = deps.enqueue;
|
||||
listJobsFn = deps.listJobs;
|
||||
console.log(`[email-cron] Starting email sync cron (every ${INTERVAL_MS / 60_000} min)`);
|
||||
timer = setInterval(tick, INTERVAL_MS);
|
||||
// Run first tick after a short delay
|
||||
setTimeout(tick, 30_000);
|
||||
}
|
||||
|
||||
export function stopEmailCron() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { SidecarEvent } from '../protocol';
|
||||
import type { Job, EnqueueParams } from '../../queue/types';
|
||||
import { initEmailCron, stopEmailCron } from './email-cron';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
// ── Queue access via WS ──
|
||||
|
||||
let reqCounter = 0;
|
||||
const pendingQueue = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: Timer }>();
|
||||
|
||||
function nextQueueId(): string {
|
||||
return `eq_${Date.now()}_${++reqCounter}`;
|
||||
}
|
||||
|
||||
function sendQueueCommand(cmd: Record<string, unknown>): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = cmd.id as string;
|
||||
const timer = setTimeout(() => {
|
||||
pendingQueue.delete(id);
|
||||
reject(new Error(`Queue command ${cmd.type} timed out`));
|
||||
}, 30_000);
|
||||
pendingQueue.set(id, { resolve, reject, timer });
|
||||
connection.send(cmd as SidecarEvent);
|
||||
});
|
||||
}
|
||||
|
||||
async function enqueueViaWs(params: EnqueueParams): Promise<Job> {
|
||||
const res = (await sendQueueCommand({ type: 'queue:enqueue', id: nextQueueId(), params })) as Record<string, unknown>;
|
||||
if (res.type === 'queue:enqueued') return res.job as Job;
|
||||
if (res.type === 'queue:error') throw new Error(res.error as string);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
async function listJobsViaWs(): Promise<Job[]> {
|
||||
const res = (await sendQueueCommand({ type: 'queue:list', id: nextQueueId() })) as Record<string, unknown>;
|
||||
if (res.type === 'queue:list') return res.jobs as Job[];
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: Record<string, unknown>, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id as string });
|
||||
break;
|
||||
|
||||
default:
|
||||
// Check if this is a queue response (from API server responding to our queue commands)
|
||||
if (
|
||||
typeof cmd.type === 'string' &&
|
||||
cmd.type.startsWith('queue:') &&
|
||||
cmd.id &&
|
||||
pendingQueue.has(cmd.id as string)
|
||||
) {
|
||||
const pending = pendingQueue.get(cmd.id as string)!;
|
||||
pendingQueue.delete(cmd.id as string);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
reply({
|
||||
type: 'error',
|
||||
id: cmd.id as string,
|
||||
error: `Unknown command type: ${cmd.type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'email',
|
||||
capabilities: ['email'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as Record<string, unknown>, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
// Start email cron once connected (so queue commands can reach API server)
|
||||
// initEmailCron({ enqueue: enqueueViaWs, listJobs: listJobsViaWs }); // TODO: re-enable after testing
|
||||
},
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[email] ${signal} received, shutting down...`);
|
||||
stopEmailCron();
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
Reference in New Issue
Block a user