From 7e049ebdef6ba28a7f8f98490755cf794329dc6c Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Fri, 15 May 2026 01:58:03 +0200 Subject: [PATCH] feat(worker-logs): add worker run-log viewer page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 2 + app/api/worker-logs/[name]/route.ts | 32 ++ app/api/worker-logs/route.ts | 25 + .../_components/run-log-detail.tsx | 291 ++++++++++++ .../_components/worker-logs-view.tsx | 202 ++++++++ app/worker-logs/page.tsx | 34 ++ components/AppNav.tsx | 1 + lib/parse-worker-log.ts | 444 ++++++++++++++++++ lib/utils.ts | 12 + lib/worker-logs.ts | 116 +++++ 10 files changed, 1159 insertions(+) create mode 100644 app/api/worker-logs/[name]/route.ts create mode 100644 app/api/worker-logs/route.ts create mode 100644 app/worker-logs/_components/run-log-detail.tsx create mode 100644 app/worker-logs/_components/worker-logs-view.tsx create mode 100644 app/worker-logs/page.tsx create mode 100644 lib/parse-worker-log.ts create mode 100644 lib/worker-logs.ts diff --git a/.env.example b/.env.example index 07a27ea..542d6b8 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,5 @@ OPS_AGENT_URL="http://127.0.0.1:3099" REPO_PATHS="/srv/scrum4me/repos/scrum4me,/srv/ops/repos/ops-dashboard" # Comma-separated list of systemd unit names to show on the /systemd page (must match commands.yml allowed list) SYSTEMD_UNITS="scrum4me-web,ops-agent" +# Worker run-logs directory inside the container (read-only bind mount; see docker-compose.yml) +WORKER_LOGS_DIR="/var/worker-logs/idea" diff --git a/app/api/worker-logs/[name]/route.ts b/app/api/worker-logs/[name]/route.ts new file mode 100644 index 0000000..c0b8f28 --- /dev/null +++ b/app/api/worker-logs/[name]/route.ts @@ -0,0 +1,32 @@ +import { NextRequest } from 'next/server' +import { getCurrentUser } from '@/lib/session' +import { readRunLog, WorkerLogError } from '@/lib/worker-logs' +import { parseRunLog } from '@/lib/parse-worker-log' + +export const dynamic = 'force-dynamic' + +// GET /api/worker-logs/.log — full parsed timeline for one run-log. +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ name: string }> }, +) { + const user = await getCurrentUser() + if (!user) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + + const { name: rawName } = await params + const name = decodeURIComponent(rawName) + + try { + const raw = await readRunLog(name) + return Response.json(parseRunLog(raw, name)) + } catch (err) { + if (err instanceof WorkerLogError) { + const status = err.code === 'invalid' ? 400 : err.code === 'not-found' ? 404 : 500 + return Response.json({ error: err.message }, { status }) + } + const message = err instanceof Error ? err.message : 'failed to read worker log' + return Response.json({ error: message }, { status: 500 }) + } +} diff --git a/app/api/worker-logs/route.ts b/app/api/worker-logs/route.ts new file mode 100644 index 0000000..96c85d3 --- /dev/null +++ b/app/api/worker-logs/route.ts @@ -0,0 +1,25 @@ +import { NextRequest } from 'next/server' +import { getCurrentUser } from '@/lib/session' +import { listRunLogs } from '@/lib/worker-logs' + +export const dynamic = 'force-dynamic' + +// GET /api/worker-logs?limit=10 — newest-first run-log summaries for the table. +export async function GET(request: NextRequest) { + const user = await getCurrentUser() + if (!user) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + + const limitParam = request.nextUrl.searchParams.get('limit') + const limit = limitParam ? Number(limitParam) : 10 + + try { + const logs = await listRunLogs(limit) + return Response.json({ logs }) + } catch (err) { + // Surfaces a missing bind mount legibly (e.g. WORKER_LOGS_DIR not mounted). + const message = err instanceof Error ? err.message : 'failed to list worker logs' + return Response.json({ error: message }, { status: 500 }) + } +} diff --git a/app/worker-logs/_components/run-log-detail.tsx b/app/worker-logs/_components/run-log-detail.tsx new file mode 100644 index 0000000..299310d --- /dev/null +++ b/app/worker-logs/_components/run-log-detail.tsx @@ -0,0 +1,291 @@ +'use client' + +import { useCallback, useEffect, useState, type ReactElement } from 'react' +import type { LogEvent, MetaTag, ParsedRunLog } from '@/lib/parse-worker-log' +import { cn, formatDuration } from '@/lib/utils' + +async function fetchDetail(fileName: string): Promise { + const res = await fetch(`/api/worker-logs/${encodeURIComponent(fileName)}`, { cache: 'no-store' }) + const body = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(body?.error ?? `request failed (${res.status})`) + return body as ParsedRunLog +} + +const META_TAG_STYLES: Record = { + claim: 'text-muted-foreground', + auth: 'text-muted-foreground', + quota: 'text-muted-foreground', + 'no-job': 'text-muted-foreground', + claimed: 'text-blue-600 dark:text-blue-400', + worktree: 'text-muted-foreground', + config: 'text-blue-600 dark:text-blue-400', + payload: 'text-muted-foreground', + spawn: 'text-blue-600 dark:text-blue-400', + 'claude-done': 'text-blue-600 dark:text-blue-400', + cleanup: 'text-muted-foreground', + exit: 'text-muted-foreground', + error: 'text-destructive', + 'token-expired': 'text-destructive', + timeout: 'text-muted-foreground', + other: 'text-muted-foreground', +} + +function timeOnly(ts: string | null): string { + if (!ts) return '' + const d = new Date(ts) + return isNaN(d.getTime()) ? '' : d.toLocaleTimeString() +} + +function inputPreview(input: string): string { + const oneLine = input.replace(/\s+/g, ' ').trim() + return oneLine.length > 100 ? `${oneLine.slice(0, 100)}…` : oneLine +} + +function TruncNote({ chars }: { chars?: number }) { + return ( +
+ — afgekapt{chars != null ? ` (${chars} chars totaal)` : ''} +
+ ) +} + +function EventBlock({ event }: { event: LogEvent }): ReactElement { + switch (event.kind) { + case 'meta': + return ( +
+ {timeOnly(event.ts)} + + {event.tag} + + {event.text} +
+ ) + + case 'system-init': + return ( +
+
Sessie gestart
+
+
+ model {event.model} +
+
+ permission {event.permissionMode} +
+
+ claude v{event.version || '?'} +
+
+ {event.cwd && ( +
+ cwd: {event.cwd} +
+ )} + {(event.tools.length > 0 || event.mcpServers.length > 0) && ( +
+ + {event.tools.length} tools · {event.mcpServers.length} MCP-server(s) + +
+ {event.mcpServers.length > 0 &&
mcp: {event.mcpServers.join(', ')}
} +
{event.tools.join(', ')}
+
+
+ )} +
+ ) + + case 'assistant-text': + return ( +
+
{event.text}
+ {event.truncated && } +
+ ) + + case 'thinking': + return ( +
+ + thinking… + +
+ {event.text} + {event.truncated && } +
+
+ ) + + case 'tool-call': + return ( +
+ + + ▸ {event.name} + + {inputPreview(event.input)} + + + +
+            {event.input}
+          
+ {event.truncated && } +
+ ) + + case 'tool-result': + return ( +
+ + + {event.isError ? '✕ result (error)' : '◂ result'} + {event.fullLength} chars + + +
+            {event.body || '(body weggelaten — timeline ingekort)'}
+          
+ {event.truncated && } +
+ ) + + case 'rate-limit': + return ( +
+ + rate limit: {event.status} + +
+ ) + + case 'result': + return ( +
+
+ Resultaat: {event.subtype} + {event.durationMs != null && ( + {formatDuration(event.durationMs)} + )} + {event.numTurns != null && ( + {event.numTurns} turns + )} + {event.totalCostUsd != null && ( + ${event.totalCostUsd.toFixed(2)} + )} +
+ {event.resultText && ( +
+ {event.resultText} +
+ )} + {event.resultTruncated && } +
+ ) + + case 'raw': + return ( +
+ {event.text} +
+ ) + } +} + +export default function RunLogDetail({ fileName }: { fileName: string }) { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + const load = useCallback(async () => { + try { + const d = await fetchDetail(fileName) + setData(d) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'kon log niet laden') + } finally { + setLoading(false) + } + }, [fileName]) + + useEffect(() => { + setLoading(true) + setData(null) + setError(null) + load() + }, [load]) + + // Keep refreshing while the run is still in progress. + useEffect(() => { + if (!data?.inProgress) return + const id = setInterval(load, 5000) + return () => clearInterval(id) + }, [data?.inProgress, load]) + + if (loading) { + return
log laden…
+ } + if (error) { + return ( +
+ {error} +
+ ) + } + if (!data) return null + + const { summary, events } = data + + return ( +
+
+ {summary.fileName} + {summary.jobId && job {summary.jobId}} + {summary.model && {summary.model}} + {summary.permissionMode && {summary.permissionMode}} + {summary.durationMs != null && {formatDuration(summary.durationMs)}} + {data.inProgress && ( + ● running… + )} + {data.responseTruncated && ( + timeline ingekort (zeer grote log) + )} +
+ + {summary.errorSummary && ( +
+ {summary.errorSummary} +
+ )} + +
+ {events.length === 0 ? ( +
geen events
+ ) : ( + events.map((event, i) => ) + )} +
+
+ ) +} diff --git a/app/worker-logs/_components/worker-logs-view.tsx b/app/worker-logs/_components/worker-logs-view.tsx new file mode 100644 index 0000000..be2d6cd --- /dev/null +++ b/app/worker-logs/_components/worker-logs-view.tsx @@ -0,0 +1,202 @@ +'use client' + +import { Fragment, useCallback, useEffect, useState } from 'react' +import type { RunLogSummary, RunStatus } from '@/lib/parse-worker-log' +import { cn, formatDuration, relativeTime } from '@/lib/utils' +import RunLogDetail from './run-log-detail' + +const LIMIT_OPTIONS = [10, 25, 50, 100] +const COLUMN_COUNT = 7 + +const STATUS_STYLES: Record = { + idle: { + badge: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400', + dot: 'bg-zinc-400 dark:bg-zinc-500', + }, + running: { + badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + dot: 'bg-amber-500 dark:bg-amber-400', + }, + success: { + badge: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + dot: 'bg-green-500 dark:bg-green-400', + }, + error: { + badge: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + dot: 'bg-red-500 dark:bg-red-400', + }, + 'token-expired': { + badge: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + dot: 'bg-red-500 dark:bg-red-400', + }, + unknown: { + badge: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400', + dot: 'bg-zinc-400 dark:bg-zinc-500', + }, +} + +export function StatusBadge({ status }: { status: RunStatus }) { + const s = STATUS_STYLES[status] + return ( + + + {status} + + ) +} + +async function fetchLogs(limit: number): Promise { + const res = await fetch(`/api/worker-logs?limit=${limit}`, { cache: 'no-store' }) + const body = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(body?.error ?? `request failed (${res.status})`) + return (body.logs ?? []) as RunLogSummary[] +} + +type Props = { + initialLogs: RunLogSummary[] + initialError: string | null +} + +export default function WorkerLogsView({ initialLogs, initialError }: Props) { + const [logs, setLogs] = useState(initialLogs) + const [limit, setLimit] = useState(10) + const [selected, setSelected] = useState(null) + const [error, setError] = useState(initialError) + const [refreshing, setRefreshing] = useState(false) + const [lastUpdated, setLastUpdated] = useState(new Date()) + + const refresh = useCallback(async () => { + setRefreshing(true) + try { + const data = await fetchLogs(limit) + setLogs(data) + setError(null) + setLastUpdated(new Date()) + } catch (err) { + setError(err instanceof Error ? err.message : 'refresh failed') + } finally { + setRefreshing(false) + } + }, [limit]) + + useEffect(() => { + refresh() + const id = setInterval(refresh, 10000) + return () => clearInterval(id) + }, [refresh]) + + return ( +
+
+
+ toon + {LIMIT_OPTIONS.map((opt) => ( + + ))} + {refreshing && ( + refreshing… + )} +
+ + updated {lastUpdated.toLocaleTimeString()} · auto-refreshes every 10s + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + + + + + + {logs.length === 0 && !error ? ( + + + + ) : ( + logs.map((log) => { + const isSelected = selected === log.fileName + return ( + + setSelected(isSelected ? null : log.fileName)} + title={log.errorSummary ?? undefined} + className={cn( + 'cursor-pointer border-b border-border transition-colors', + isSelected ? 'bg-muted/50' : 'hover:bg-muted/30', + )} + > + + + + + + + + + {isSelected && ( + + + + )} + + ) + }) + )} + +
StartedStatusJobModelTurnsDurationCost
+ No worker runs found +
+ {log.startedAt ? ( + + {relativeTime(new Date(log.startedAt))} + + ) : ( + {log.runId} + )} + + + + {log.jobId ? `…${log.jobId.slice(-8)}` : '—'} + {log.model ?? '—'} + {log.numTurns ?? '—'} + + {log.durationMs != null ? formatDuration(log.durationMs) : '—'} + + {log.totalCostUsd != null ? `$${log.totalCostUsd.toFixed(2)}` : '—'} +
+ +
+
+
+ ) +} diff --git a/app/worker-logs/page.tsx b/app/worker-logs/page.tsx new file mode 100644 index 0000000..48edf2a --- /dev/null +++ b/app/worker-logs/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/session' +import { listRunLogs } from '@/lib/worker-logs' +import type { RunLogSummary } from '@/lib/parse-worker-log' +import WorkerLogsView from './_components/worker-logs-view' + +export const dynamic = 'force-dynamic' + +export default async function WorkerLogsPage() { + const user = await getCurrentUser() + if (!user) redirect('/login') + + let initialLogs: RunLogSummary[] = [] + let initialError: string | null = null + try { + initialLogs = await listRunLogs(10) + } catch (err) { + initialError = err instanceof Error ? err.message : 'Failed to read worker logs' + } + + return ( +
+
+
+

Worker Logs

+

+ Recente runs van de Scrum4Me-worker — klik een rij voor de uitgewerkte timeline +

+
+ +
+
+ ) +} diff --git a/components/AppNav.tsx b/components/AppNav.tsx index 6d848b5..3347739 100644 --- a/components/AppNav.tsx +++ b/components/AppNav.tsx @@ -12,6 +12,7 @@ const NAV_ITEMS = [ { href: '/caddy', label: 'Caddy' }, { href: '/flows', label: 'Flows' }, { href: '/audit', label: 'Audit' }, + { href: '/worker-logs', label: 'Worker Logs' }, { href: '/settings', label: 'Settings' }, ] diff --git a/lib/parse-worker-log.ts b/lib/parse-worker-log.ts new file mode 100644 index 0000000..eb3e175 --- /dev/null +++ b/lib/parse-worker-log.ts @@ -0,0 +1,444 @@ +// lib/parse-worker-log.ts +// +// Parser for Scrum4Me worker run-logs (/srv/scrum4me/worker-logs/idea/runs/*.log). +// Each file is produced by `tsx run-one-job.ts > run_log 2>&1` and is a mix of +// plain-text `[run-one-job]` annotation lines and Claude Code `stream-json` +// event lines (the worker spawns `claude --output-format stream-json --verbose`). +// +// Two entry points: +// summarizeRunLog(raw, fileName) — one cheap line scan, for the table. +// parseRunLog(raw, fileName) — full event timeline, for the detail panel. +// +// Pure module, no dependencies — mirrors lib/parse-docker.ts / lib/parse-systemd.ts. + +export type RunStatus = 'idle' | 'running' | 'success' | 'error' | 'token-expired' | 'unknown' + +export interface RunLogSummary { + fileName: string + runId: string + startedAt: string | null + status: RunStatus + jobId: string | null + model: string | null + permissionMode: string | null + durationMs: number | null + numTurns: number | null + totalCostUsd: number | null + exitCode: number | null + eventCount: number + inProgress: boolean + errorSummary: string | null +} + +export type MetaTag = + | 'claim' + | 'auth' + | 'quota' + | 'no-job' + | 'claimed' + | 'worktree' + | 'config' + | 'payload' + | 'spawn' + | 'claude-done' + | 'cleanup' + | 'exit' + | 'error' + | 'token-expired' + | 'timeout' + | 'other' + +export type LogEvent = + | { kind: 'meta'; ts: string | null; tag: MetaTag; text: string } + | { + kind: 'system-init' + ts: string | null + model: string + permissionMode: string + tools: string[] + mcpServers: string[] + sessionId: string + cwd: string + version: string + } + | { kind: 'assistant-text'; ts: string | null; text: string; truncated: boolean } + | { kind: 'thinking'; ts: string | null; text: string; truncated: boolean } + | { kind: 'tool-call'; ts: string | null; id: string; name: string; input: string; truncated: boolean } + | { + kind: 'tool-result' + ts: string | null + toolUseId: string + isError: boolean + body: string + truncated: boolean + fullLength: number + } + | { kind: 'rate-limit'; ts: string | null; status: string } + | { + kind: 'result' + ts: string | null + subtype: string + isError: boolean + durationMs: number | null + numTurns: number | null + totalCostUsd: number | null + resultText: string + resultTruncated: boolean + } + | { kind: 'raw'; ts: string | null; text: string } + +export interface ParsedRunLog { + summary: RunLogSummary + events: LogEvent[] + inProgress: boolean + responseTruncated: boolean +} + +// Per-item caps keep the detail payload bounded even for ~350 KB raw logs. +const TOOL_RESULT_CAP = 8 * 1024 +const TEXT_CAP = 16 * 1024 +const TOOL_INPUT_CAP = 4 * 1024 +const RESPONSE_CAP = 1_500_000 + +const META_RE = /^(\S+)\s+\[run-one-job\]\s+(.*)$/ + +function cap(s: string, max: number): { text: string; truncated: boolean } { + if (s.length <= max) return { text: s, truncated: false } + return { text: s.slice(0, max), truncated: true } +} + +/** Strip the `.log` / `.log.gz` suffix — the run id is the timestamp filename. */ +export function runIdFromFileName(fileName: string): string { + return fileName.replace(/\.log(\.gz)?$/, '') +} + +/** run-agent.sh names each file `$(date -u +%Y%m%dT%H%M%SZ).log`, so the name is the start time. */ +function startedAtFromRunId(runId: string): string | null { + const m = runId.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/) + if (!m) return null + return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z` +} + +function classifyMeta(msg: string): MetaTag { + if (msg.startsWith('claim attempt')) return 'claim' + if (msg.startsWith('auth ok')) return 'auth' + if (msg.startsWith('quota probe')) return 'quota' + if (msg.startsWith('no job claimed')) return 'no-job' + if (msg.startsWith('claimed job_id=')) return 'claimed' + if (msg.startsWith('worktree path=')) return 'worktree' + if (msg.startsWith('config ')) return 'config' + if (msg.startsWith('payload written')) return 'payload' + if (msg.startsWith('spawn claude')) return 'spawn' + if (msg.startsWith('claude done')) return 'claude-done' + if (msg.startsWith('cleanup')) return 'cleanup' + if (msg.startsWith('exit code=')) return 'exit' + if (msg.startsWith('ERROR')) return 'error' + if (msg.startsWith('TOKEN_EXPIRED detected')) return 'token-expired' + if (msg.startsWith('claim timeout')) return 'timeout' + return 'other' +} + +/** Cheap single-pass summary for the table — at most one JSON.parse (the result line). */ +export function summarizeRunLog(raw: string, fileName: string): RunLogSummary { + const runId = runIdFromFileName(fileName) + const lines = raw.split('\n') + + let jobId: string | null = null + let model: string | null = null + let permissionMode: string | null = null + let claudeExit: number | null = null + let runExit: number | null = null + let durationMs: number | null = null + let numTurns: number | null = null + let totalCostUsd: number | null = null + let eventCount = 0 + let hasResult = false + let resultIsError = false + let resultSubtype: string | null = null + let tokenExpired = false + let hasErrorLine = false + let firstErrorMsg: string | null = null + + for (const line of lines) { + if (!line) continue + const m = line.match(META_RE) + if (m) { + const msg = m[2] + if (msg.startsWith('claimed job_id=')) { + jobId = msg.slice('claimed job_id='.length).trim() || jobId + } else if (msg.startsWith('config ')) { + model = /\bmodel=(\S+)/.exec(msg)?.[1] ?? model + permissionMode = /\bpermission_mode=(\S+)/.exec(msg)?.[1] ?? permissionMode + } else if (msg.startsWith('claude done')) { + const e = /\bexit_code=(-?\d+)/.exec(msg) + if (e) claudeExit = Number(e[1]) + const d = /\bduration_ms=(\d+)/.exec(msg) + if (d) durationMs = Number(d[1]) + } else if (msg.startsWith('exit code=')) { + const e = /exit code=(-?\d+)/.exec(msg) + if (e) runExit = Number(e[1]) + } else if (msg.startsWith('TOKEN_EXPIRED detected')) { + tokenExpired = true + } else if (msg.startsWith('ERROR')) { + hasErrorLine = true + if (!firstErrorMsg) firstErrorMsg = msg.replace(/^ERROR\s*/, '').slice(0, 300) + } + continue + } + const trimmed = line.trimStart() + if (trimmed.startsWith('{')) { + eventCount++ + if (!hasResult && trimmed.startsWith('{"type":"result"')) { + try { + const obj = JSON.parse(trimmed) + hasResult = true + resultIsError = !!obj.is_error + resultSubtype = typeof obj.subtype === 'string' ? obj.subtype : null + if (typeof obj.num_turns === 'number') numTurns = obj.num_turns + if (typeof obj.total_cost_usd === 'number') totalCostUsd = obj.total_cost_usd + if (durationMs == null && typeof obj.duration_ms === 'number') durationMs = obj.duration_ms + } catch { + // malformed result line — ignore + } + } + } + } + + const exitCode = claudeExit ?? runExit + const terminal = runExit != null || hasResult || hasErrorLine || tokenExpired + const inProgress = !terminal + + let status: RunStatus + if (tokenExpired) { + status = 'token-expired' + } else if (jobId) { + if (inProgress) { + status = 'running' + } else if ( + resultIsError || + hasErrorLine || + (claudeExit != null && claudeExit !== 0) || + (runExit != null && runExit !== 0) + ) { + status = 'error' + } else { + status = 'success' + } + } else { + // No job was claimed this iteration — the worker was idle / waiting. + status = 'idle' + } + + let errorSummary: string | null = null + if (status === 'error' || status === 'token-expired') { + errorSummary = + firstErrorMsg ?? + (tokenExpired ? 'TOKEN_EXPIRED detected in output' : null) ?? + (resultIsError ? `result: ${resultSubtype ?? 'error'}` : null) ?? + (exitCode != null ? `exit code ${exitCode}` : null) + } + + return { + fileName, + runId, + startedAt: startedAtFromRunId(runId), + status, + jobId, + model, + permissionMode, + durationMs, + numTurns, + totalCostUsd, + exitCode, + eventCount, + inProgress, + errorSummary, + } +} + +function normalizeContent(content: unknown): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((b) => { + if (typeof b === 'string') return b + if (b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string') { + return (b as { text: string }).text + } + return JSON.stringify(b) + }) + .join('\n') + } + if (content == null) return '' + return JSON.stringify(content) +} + +/* eslint-disable @typescript-eslint/no-explicit-any -- stream-json events are genuinely dynamic */ +function pushJsonEvent(events: LogEvent[], obj: any): void { + const type = obj?.type + const ts: string | null = typeof obj?.timestamp === 'string' ? obj.timestamp : null + + if (type === 'system') { + const mcp = Array.isArray(obj.mcp_servers) + ? obj.mcp_servers.map((s: any) => (typeof s?.name === 'string' ? s.name : String(s))) + : [] + events.push({ + kind: 'system-init', + ts, + model: typeof obj.model === 'string' ? obj.model : '—', + permissionMode: typeof obj.permissionMode === 'string' ? obj.permissionMode : '—', + tools: Array.isArray(obj.tools) ? obj.tools.filter((t: unknown) => typeof t === 'string') : [], + mcpServers: mcp, + sessionId: typeof obj.session_id === 'string' ? obj.session_id : '', + cwd: typeof obj.cwd === 'string' ? obj.cwd : '', + version: typeof obj.claude_code_version === 'string' ? obj.claude_code_version : '', + }) + return + } + + if (type === 'rate_limit_event') { + events.push({ + kind: 'rate-limit', + ts, + status: typeof obj.rate_limit_info?.status === 'string' ? obj.rate_limit_info.status : 'unknown', + }) + return + } + + if (type === 'assistant') { + const content = obj?.message?.content + if (Array.isArray(content)) { + for (const block of content) { + if (block?.type === 'text' && typeof block.text === 'string') { + const c = cap(block.text, TEXT_CAP) + events.push({ kind: 'assistant-text', ts, text: c.text, truncated: c.truncated }) + } else if (block?.type === 'thinking' && typeof block.thinking === 'string') { + const c = cap(block.thinking, TEXT_CAP) + events.push({ kind: 'thinking', ts, text: c.text, truncated: c.truncated }) + } else if (block?.type === 'tool_use') { + let inputStr: string + try { + inputStr = JSON.stringify(block.input, null, 2) + } catch { + inputStr = String(block.input) + } + const c = cap(inputStr, TOOL_INPUT_CAP) + events.push({ + kind: 'tool-call', + ts, + id: typeof block.id === 'string' ? block.id : '', + name: typeof block.name === 'string' ? block.name : 'tool', + input: c.text, + truncated: c.truncated, + }) + } + } + } + return + } + + if (type === 'user') { + const content = obj?.message?.content + if (Array.isArray(content)) { + for (const block of content) { + if (block?.type === 'tool_result') { + const body = normalizeContent(block.content) + const c = cap(body, TOOL_RESULT_CAP) + events.push({ + kind: 'tool-result', + ts, + toolUseId: typeof block.tool_use_id === 'string' ? block.tool_use_id : '', + isError: !!block.is_error, + body: c.text, + truncated: c.truncated, + fullLength: body.length, + }) + } + } + } + return + } + + if (type === 'result') { + const c = cap(typeof obj.result === 'string' ? obj.result : '', TEXT_CAP) + events.push({ + kind: 'result', + ts, + subtype: typeof obj.subtype === 'string' ? obj.subtype : 'unknown', + isError: !!obj.is_error, + durationMs: typeof obj.duration_ms === 'number' ? obj.duration_ms : null, + numTurns: typeof obj.num_turns === 'number' ? obj.num_turns : null, + totalCostUsd: typeof obj.total_cost_usd === 'number' ? obj.total_cost_usd : null, + resultText: c.text, + resultTruncated: c.truncated, + }) + return + } + + // Unknown event type — keep a compact raw note so nothing is silently dropped. + events.push({ kind: 'raw', ts, text: cap(`${type ?? 'event'}: ${JSON.stringify(obj)}`, 2048).text }) +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +function estimateSize(e: LogEvent): number { + switch (e.kind) { + case 'assistant-text': + case 'thinking': + case 'raw': + return e.text.length + case 'tool-call': + return e.input.length + case 'tool-result': + return e.body.length + case 'result': + return e.resultText.length + default: + return 64 + } +} + +/** Bound the whole payload — drop tool-result bodies oldest-first if still too large. */ +function enforceResponseCap(events: LogEvent[]): boolean { + let total = 0 + for (const e of events) total += estimateSize(e) + if (total <= RESPONSE_CAP) return false + for (const e of events) { + if (total <= RESPONSE_CAP) break + if (e.kind === 'tool-result' && e.body) { + total -= e.body.length + e.body = '' + e.truncated = true + } + } + return true +} + +/** Full event timeline for the detail panel. */ +export function parseRunLog(raw: string, fileName: string): ParsedRunLog { + const summary = summarizeRunLog(raw, fileName) + const events: LogEvent[] = [] + + for (const line of raw.split('\n')) { + if (!line.trim()) continue + const m = line.match(META_RE) + if (m) { + events.push({ kind: 'meta', ts: m[1], tag: classifyMeta(m[2]), text: m[2] }) + continue + } + const trimmed = line.trimStart() + if (trimmed.startsWith('{')) { + try { + pushJsonEvent(events, JSON.parse(trimmed)) + } catch { + // partial / malformed JSON line (e.g. a log read mid-write) — keep it raw + events.push({ kind: 'raw', ts: null, text: cap(line, TOOL_RESULT_CAP).text }) + } + continue + } + // Non-JSON, non-meta noise (e.g. a bare `Warning: ...` from claude). + events.push({ kind: 'raw', ts: null, text: cap(line, TOOL_RESULT_CAP).text }) + } + + const responseTruncated = enforceResponseCap(events) + return { summary, events, inProgress: summary.inProgress, responseTruncated } +} diff --git a/lib/utils.ts b/lib/utils.ts index 49c77ca..af3e365 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -14,3 +14,15 @@ export function relativeTime(date: Date): string { if (hours < 24) return `${hours}u geleden` return `${Math.floor(hours / 24)}d geleden` } + +/** Human-readable duration from a millisecond count. */ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + const totalSec = Math.round(ms / 1000) + if (totalSec < 60) return `${totalSec}s` + const minutes = Math.floor(totalSec / 60) + const seconds = totalSec % 60 + if (minutes < 60) return `${minutes}m ${seconds}s` + const hours = Math.floor(minutes / 60) + return `${hours}u ${minutes % 60}m` +} diff --git a/lib/worker-logs.ts b/lib/worker-logs.ts new file mode 100644 index 0000000..0c4e003 --- /dev/null +++ b/lib/worker-logs.ts @@ -0,0 +1,116 @@ +// 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') + } +}