Merge pull request #11 from madhura68/feat/worker-log-viewer

feat(worker-logs): worker run-log viewer
This commit is contained in:
Janpeter Visser 2026-05-15 09:45:55 +02:00 committed by GitHub
commit 27cba872a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1159 additions and 0 deletions

View file

@ -7,3 +7,5 @@ OPS_AGENT_URL="http://127.0.0.1:3099"
REPO_PATHS="/srv/scrum4me/repos/scrum4me,/srv/ops/repos/ops-dashboard" 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) # 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" 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"

View file

@ -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/<file>.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 })
}
}

View file

@ -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 })
}
}

View file

@ -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<ParsedRunLog> {
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<MetaTag, string> = {
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 (
<div className="mt-0.5 text-[11px] italic text-muted-foreground">
afgekapt{chars != null ? ` (${chars} chars totaal)` : ''}
</div>
)
}
function EventBlock({ event }: { event: LogEvent }): ReactElement {
switch (event.kind) {
case 'meta':
return (
<div className="flex gap-2 py-0.5 font-mono text-[11px] leading-relaxed">
<span className="shrink-0 text-muted-foreground/60">{timeOnly(event.ts)}</span>
<span className={cn('shrink-0 uppercase tracking-wide', META_TAG_STYLES[event.tag])}>
{event.tag}
</span>
<span className="break-all text-muted-foreground">{event.text}</span>
</div>
)
case 'system-init':
return (
<div className="my-2 rounded-lg border border-border bg-card p-3 text-xs">
<div className="mb-1 font-medium text-foreground">Sessie gestart</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-muted-foreground sm:grid-cols-3">
<div>
<span className="text-foreground/70">model</span> {event.model}
</div>
<div>
<span className="text-foreground/70">permission</span> {event.permissionMode}
</div>
<div>
<span className="text-foreground/70">claude</span> v{event.version || '?'}
</div>
</div>
{event.cwd && (
<div className="mt-1 break-all font-mono text-[11px] text-muted-foreground">
cwd: {event.cwd}
</div>
)}
{(event.tools.length > 0 || event.mcpServers.length > 0) && (
<details className="mt-2">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
{event.tools.length} tools · {event.mcpServers.length} MCP-server(s)
</summary>
<div className="mt-1 font-mono text-[11px] text-muted-foreground">
{event.mcpServers.length > 0 && <div>mcp: {event.mcpServers.join(', ')}</div>}
<div className="break-words">{event.tools.join(', ')}</div>
</div>
</details>
)}
</div>
)
case 'assistant-text':
return (
<div className="my-1.5 border-l-2 border-blue-300 pl-3 dark:border-blue-700">
<div className="whitespace-pre-wrap text-sm text-foreground">{event.text}</div>
{event.truncated && <TruncNote />}
</div>
)
case 'thinking':
return (
<details className="my-1 pl-3">
<summary className="cursor-pointer text-xs italic text-muted-foreground hover:text-foreground">
thinking
</summary>
<div className="mt-1 whitespace-pre-wrap border-l-2 border-border pl-3 text-xs italic text-muted-foreground">
{event.text}
{event.truncated && <TruncNote />}
</div>
</details>
)
case 'tool-call':
return (
<details open className="my-1">
<summary className="cursor-pointer list-none">
<span className="inline-flex max-w-full items-center gap-2 rounded-md bg-muted px-2 py-1 text-xs">
<span className="shrink-0 font-medium text-foreground"> {event.name}</span>
<span className="truncate font-mono text-[11px] text-muted-foreground">
{inputPreview(event.input)}
</span>
</span>
</summary>
<pre className="ml-2 mt-1 overflow-x-auto rounded-md border border-border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed">
{event.input}
</pre>
{event.truncated && <TruncNote />}
</details>
)
case 'tool-result':
return (
<details className="my-1">
<summary className="cursor-pointer list-none">
<span
className={cn(
'inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs',
event.isError
? 'bg-destructive/10 text-destructive'
: 'bg-muted text-muted-foreground',
)}
>
<span>{event.isError ? '✕ result (error)' : '◂ result'}</span>
<span className="text-[11px] opacity-70">{event.fullLength} chars</span>
</span>
</summary>
<pre
className={cn(
'ml-2 mt-1 max-h-80 overflow-auto whitespace-pre-wrap break-all rounded-md border p-2 font-mono text-[11px] leading-relaxed',
event.isError ? 'border-destructive/30 bg-destructive/5' : 'border-border bg-muted/30',
)}
>
{event.body || '(body weggelaten — timeline ingekort)'}
</pre>
{event.truncated && <TruncNote chars={event.fullLength} />}
</details>
)
case 'rate-limit':
return (
<div className="my-1 text-xs">
<span className="rounded-md bg-amber-100 px-2 py-0.5 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
rate limit: {event.status}
</span>
</div>
)
case 'result':
return (
<div
className={cn(
'my-2 rounded-lg border p-3',
event.isError
? 'border-destructive/30 bg-destructive/10'
: 'border-green-300 bg-green-50 dark:border-green-800 dark:bg-green-900/20',
)}
>
<div className="flex flex-wrap items-center gap-3 text-xs">
<span className="font-medium text-foreground">Resultaat: {event.subtype}</span>
{event.durationMs != null && (
<span className="text-muted-foreground">{formatDuration(event.durationMs)}</span>
)}
{event.numTurns != null && (
<span className="text-muted-foreground">{event.numTurns} turns</span>
)}
{event.totalCostUsd != null && (
<span className="text-muted-foreground">${event.totalCostUsd.toFixed(2)}</span>
)}
</div>
{event.resultText && (
<div className="mt-2 whitespace-pre-wrap text-sm text-foreground">
{event.resultText}
</div>
)}
{event.resultTruncated && <TruncNote />}
</div>
)
case 'raw':
return (
<div className="break-all py-0.5 font-mono text-[11px] text-muted-foreground/70">
{event.text}
</div>
)
}
}
export default function RunLogDetail({ fileName }: { fileName: string }) {
const [data, setData] = useState<ParsedRunLog | null>(null)
const [error, setError] = useState<string | null>(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 <div className="animate-pulse text-xs text-muted-foreground">log laden</div>
}
if (error) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)
}
if (!data) return null
const { summary, events } = data
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="font-mono text-foreground">{summary.fileName}</span>
{summary.jobId && <span className="font-mono">job {summary.jobId}</span>}
{summary.model && <span>{summary.model}</span>}
{summary.permissionMode && <span>{summary.permissionMode}</span>}
{summary.durationMs != null && <span>{formatDuration(summary.durationMs)}</span>}
{data.inProgress && (
<span className="animate-pulse text-amber-600 dark:text-amber-400"> running</span>
)}
{data.responseTruncated && (
<span className="italic">timeline ingekort (zeer grote log)</span>
)}
</div>
{summary.errorSummary && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{summary.errorSummary}
</div>
)}
<div className="rounded-lg border border-border bg-background p-3">
{events.length === 0 ? (
<div className="text-xs text-muted-foreground">geen events</div>
) : (
events.map((event, i) => <EventBlock key={i} event={event} />)
)}
</div>
</div>
)
}

View file

@ -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<RunStatus, { badge: string; dot: string }> = {
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 (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium',
s.badge,
)}
>
<span className={cn('size-1.5 rounded-full', s.dot)} />
{status}
</span>
)
}
async function fetchLogs(limit: number): Promise<RunLogSummary[]> {
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<RunLogSummary[]>(initialLogs)
const [limit, setLimit] = useState(10)
const [selected, setSelected] = useState<string | null>(null)
const [error, setError] = useState<string | null>(initialError)
const [refreshing, setRefreshing] = useState(false)
const [lastUpdated, setLastUpdated] = useState<Date>(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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">toon</span>
{LIMIT_OPTIONS.map((opt) => (
<button
key={opt}
onClick={() => setLimit(opt)}
className={cn(
'rounded-md border px-2 py-1 text-xs transition-colors',
limit === opt
? 'border-foreground/30 bg-muted font-medium text-foreground'
: 'border-border text-muted-foreground hover:bg-muted/50',
)}
>
{opt}
</button>
))}
{refreshing && (
<span className="text-xs text-muted-foreground animate-pulse">refreshing</span>
)}
</div>
<span className="text-xs text-muted-foreground">
updated {lastUpdated.toLocaleTimeString()} · auto-refreshes every 10s
</span>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Started</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Job</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Model</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Turns</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Duration</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Cost</th>
</tr>
</thead>
<tbody>
{logs.length === 0 && !error ? (
<tr>
<td colSpan={COLUMN_COUNT} className="px-4 py-8 text-center text-muted-foreground">
No worker runs found
</td>
</tr>
) : (
logs.map((log) => {
const isSelected = selected === log.fileName
return (
<Fragment key={log.fileName}>
<tr
onClick={() => 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',
)}
>
<td className="px-4 py-3 text-xs">
{log.startedAt ? (
<span title={new Date(log.startedAt).toLocaleString()}>
{relativeTime(new Date(log.startedAt))}
</span>
) : (
<span className="font-mono">{log.runId}</span>
)}
</td>
<td className="px-4 py-3">
<StatusBadge status={log.status} />
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{log.jobId ? `${log.jobId.slice(-8)}` : '—'}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{log.model ?? '—'}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{log.numTurns ?? '—'}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{log.durationMs != null ? formatDuration(log.durationMs) : '—'}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{log.totalCostUsd != null ? `$${log.totalCostUsd.toFixed(2)}` : '—'}
</td>
</tr>
{isSelected && (
<tr className="border-b border-border bg-muted/20">
<td colSpan={COLUMN_COUNT} className="px-4 py-4">
<RunLogDetail fileName={log.fileName} />
</td>
</tr>
)}
</Fragment>
)
})
)}
</tbody>
</table>
</div>
</div>
)
}

34
app/worker-logs/page.tsx Normal file
View file

@ -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 (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Worker Logs</h1>
<p className="text-sm text-muted-foreground">
Recente runs van de Scrum4Me-worker klik een rij voor de uitgewerkte timeline
</p>
</div>
<WorkerLogsView initialLogs={initialLogs} initialError={initialError} />
</div>
</div>
)
}

View file

@ -12,6 +12,7 @@ const NAV_ITEMS = [
{ href: '/caddy', label: 'Caddy' }, { href: '/caddy', label: 'Caddy' },
{ href: '/flows', label: 'Flows' }, { href: '/flows', label: 'Flows' },
{ href: '/audit', label: 'Audit' }, { href: '/audit', label: 'Audit' },
{ href: '/worker-logs', label: 'Worker Logs' },
{ href: '/settings', label: 'Settings' }, { href: '/settings', label: 'Settings' },
] ]

444
lib/parse-worker-log.ts Normal file
View file

@ -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 }
}

View file

@ -14,3 +14,15 @@ export function relativeTime(date: Date): string {
if (hours < 24) return `${hours}u geleden` if (hours < 24) return `${hours}u geleden`
return `${Math.floor(hours / 24)}d 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`
}

116
lib/worker-logs.ts Normal file
View file

@ -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<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')
}
}