Ops-dashboard/lib/worker-logs.ts
Janpeter Visser 7e049ebdef feat(worker-logs): add worker run-log viewer page
Nieuwe /worker-logs pagina: een tabel van de laatste N (10/25/50/100)
worker-runs met een inline detailpaneel dat de stream-json output van
Claude Code als leesbare timeline toont (system-init, assistant-tekst,
tool-calls/results, result-kaart).

- lib/parse-worker-log.ts: pure parser — summarizeRunLog (tabel) +
  parseRunLog (timeline), discriminated-union events, server-side
  truncatie van grote tool-results.
- lib/worker-logs.ts: server-only fs-toegang, leest uit WORKER_LOGS_DIR
  (read-only bind mount), naam-regex + pad-confinement, .gz support.
- app/api/worker-logs[/[name]]: GET-routes, auth-guarded, force-dynamic.
- app/worker-logs: server page + client view (tabel, N-selector,
  auto-refresh) + detail (timeline, auto-refresh tijdens in-progress run).

Vereist een read-only bind mount van /srv/scrum4me/worker-logs in de
ops-dashboard-container (docker-compose.yml + WORKER_LOGS_DIR in .env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:58:03 +02:00

116 lines
3.8 KiB
TypeScript

// 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<string> {
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<RunLogSummary[]> {
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<string> {
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')
}
}