// lib/worker-logs.ts // // Server-only filesystem access to the worker run-logs. The directory is // mounted read-only into the ops-dashboard container (see docker-compose.yml: // `/srv/scrum4me/worker-logs:/var/worker-logs:ro`). Path configurable via the // WORKER_LOGS_DIR env var. // // Only imported by server components and route handlers — never by a // 'use client' file. import 'server-only' import { readdir, readFile } from 'node:fs/promises' import { gunzipSync } from 'node:zlib' import { join, resolve } from 'node:path' import { summarizeRunLog, type RunLogSummary } from './parse-worker-log' const WORKER_LOGS_DIR = process.env.WORKER_LOGS_DIR ?? '/var/worker-logs/idea' const RUNS_DIR = join(WORKER_LOGS_DIR, 'runs') /** Selectable row counts for the table. */ export const LIMIT_OPTIONS = [10, 25, 50, 100] as const const DEFAULT_LIMIT = 10 // Filenames are `$(date -u +%Y%m%dT%H%M%SZ).log` — no slashes, no dots beyond // the literal suffix, so this regex alone rules out path traversal. const NAME_RE = /^\d{8}T\d{6}Z\.log(\.gz)?$/ export type WorkerLogErrorCode = 'invalid' | 'not-found' | 'unavailable' export class WorkerLogError extends Error { readonly code: WorkerLogErrorCode constructor(message: string, code: WorkerLogErrorCode) { super(message) this.name = 'WorkerLogError' this.code = code } } /** Clamp an arbitrary requested limit down to the largest allowed option. */ export function clampLimit(n: number): number { if (!Number.isFinite(n)) return DEFAULT_LIMIT let chosen: number = DEFAULT_LIMIT for (const opt of LIMIT_OPTIONS) { if (n >= opt) chosen = opt } return chosen } export function isValidLogName(name: string): boolean { return NAME_RE.test(name) } function resolveLogPath(name: string): string { if (!isValidLogName(name)) { throw new WorkerLogError(`invalid log name: ${name}`, 'invalid') } const base = resolve(RUNS_DIR) const full = resolve(base, name) // Defense-in-depth: the regex already forbids traversal, but confirm anyway. if (full !== join(base, name)) { throw new WorkerLogError(`path escapes worker logs dir: ${name}`, 'invalid') } return full } async function readLogFile(name: string): Promise { const full = resolveLogPath(name) if (name.endsWith('.gz')) { const buf = await readFile(full) return gunzipSync(buf).toString('utf8') } return readFile(full, 'utf8') } /** Newest-first summaries for the table. Sorts by filename, slices, then reads. */ export async function listRunLogs(limit: number): Promise { const n = clampLimit(limit) let entries: string[] try { entries = await readdir(RUNS_DIR) } catch (err) { throw new WorkerLogError( `cannot read worker logs dir ${RUNS_DIR}: ${(err as Error).message}`, 'unavailable', ) } // Filename is `YYYYMMDDTHHMMSSZ` — lexicographic order == chronological order. // Sort + slice BEFORE touching file content (the dir holds ~12k files). const names = entries.filter(isValidLogName).sort().reverse().slice(0, n) return Promise.all( names.map(async (name) => { try { return summarizeRunLog(await readLogFile(name), name) } catch { // A single unreadable / mid-rotation file must not break the table. return { ...summarizeRunLog('', name), status: 'unknown' as const, inProgress: false } } }), ) } /** Raw contents of one run-log (gunzipped if needed). */ export async function readRunLog(name: string): Promise { try { return await readLogFile(name) } catch (err) { if (err instanceof WorkerLogError) throw err if ((err as NodeJS.ErrnoException).code === 'ENOENT') { throw new WorkerLogError(`log not found: ${name}`, 'not-found') } throw new WorkerLogError(`cannot read log ${name}: ${(err as Error).message}`, 'unavailable') } }