Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { createAuth } from './auth'
import { createAuthMiddleware } from './auth/middleware'
import { createPromptTemplateRoutes } from './routes/prompt-templates'
import { createSessionPinRoutes } from './routes/session-pins'
import { createLogRoutes } from './routes/logs'
import { createInternalRoutes } from './routes/internal'
import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers'
import { createOpenCodeProxyRoutes } from './routes/opencode-proxy'
Expand Down Expand Up @@ -377,6 +378,7 @@ protectedApi.route('/notifications', createNotificationRoutes(notificationServic
protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
protectedApi.route('/session-pins', createSessionPinRoutes(db))
protectedApi.route('/schedules', createScheduleRoutes(scheduleService))
protectedApi.route('/logs', createLogRoutes())

app.route('/api', protectedApi)

Expand Down
17 changes: 17 additions & 0 deletions backend/src/routes/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Hono } from 'hono'
import { ManagerLogQuerySchema } from '@opencode-manager/shared/schemas'
import { readManagerLogEntries } from '../utils/log-buffer'

export function createLogRoutes() {
const app = new Hono()

app.get('/', (c) => {
const result = ManagerLogQuerySchema.safeParse(c.req.query())
if (!result.success) {
return c.json({ error: 'Invalid request', details: result.error.issues }, 400)
}
return c.json(readManagerLogEntries(result.data))
})

return app
}
14 changes: 13 additions & 1 deletion backend/src/services/opencode-single-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { resolveProcessIdentityProvider } from './opencode/process-identity'
import { SandboxRuntimeService } from './sandbox/runtime'
import { CredentialProvider } from './credential-provider'
import { mkdirSafe, writeFileAtomic } from '../utils/fs-safe'
import { createProcessLogForwarder } from '../utils/log-buffer'


const MIN_OPENCODE_VERSION = '1.0.137'
Expand Down Expand Up @@ -659,13 +660,24 @@ class OpenCodeServerManager {
}
)

const openCodeStdoutLog = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
const openCodeStderrLog = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'error' })

if (!isDevelopment && this.serverProcess.stderr) {
this.serverProcess.stderr.on('data', (data) => {
stderrOutput += data.toString()
const text = data.toString()
stderrOutput += text
if (stderrOutput.length > MAX_STDERR_SIZE) {
stderrOutput = stderrOutput.slice(-MAX_STDERR_SIZE)
}
openCodeStderrLog.write(data)
})
this.serverProcess.stderr.on('end', () => openCodeStderrLog.flush())
}

if (!isDevelopment && this.serverProcess.stdout) {
this.serverProcess.stdout.on('data', (data) => openCodeStdoutLog.write(data))
this.serverProcess.stdout.on('end', () => openCodeStdoutLog.flush())
}

const spawnedServerPid = this.serverProcess.pid
Expand Down
252 changes: 252 additions & 0 deletions backend/src/utils/log-buffer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { DEFAULTS } from '@opencode-manager/shared/config'
import {
appendManagerLogEntry,
createProcessLogForwarder,
readManagerLogEntries,
resetManagerLogBuffer,
} from './log-buffer'

describe('log buffer', () => {
beforeEach(() => {
resetManagerLogBuffer()
})

it('returns an appended entry with seq 1, ISO timestamp and latestSeq 1', () => {
appendManagerLogEntry({ level: 'info', source: 'manager', message: 'hello' })

const result = readManagerLogEntries({})
expect(result.entries).toHaveLength(1)
const entry = result.entries.at(0)
expect(entry?.seq).toBe(1)
expect(entry?.level).toBe('info')
expect(entry?.source).toBe('manager')
expect(entry?.message).toBe('hello')
expect(() => new Date(entry?.timestamp ?? '').toISOString()).not.toThrow()
expect(result.latestSeq).toBe(1)
})

it('afterSeq returns only newer entries', () => {
appendManagerLogEntry({ level: 'info', source: 'manager', message: 'one' })
appendManagerLogEntry({ level: 'info', source: 'manager', message: 'two' })
appendManagerLogEntry({ level: 'info', source: 'manager', message: 'three' })

const result = readManagerLogEntries({ afterSeq: 2 })
expect(result.entries.map((entry) => entry.message)).toEqual(['three'])
})

it('an afterSeq query with more than DEFAULT_PAGE_SIZE matches drains pages oldest-first without gaps', () => {
const total = DEFAULTS.LOGS.DEFAULT_PAGE_SIZE + 2
for (let i = 0; i < total; i++) {
appendManagerLogEntry({ level: 'info', source: 'manager', message: `entry-${i}` })
}

const firstPage = readManagerLogEntries({ afterSeq: 0 })
expect(firstPage.entries).toHaveLength(DEFAULTS.LOGS.DEFAULT_PAGE_SIZE)
expect(firstPage.entries[0]?.seq).toBe(1)
expect(firstPage.entries.at(-1)?.seq).toBe(DEFAULTS.LOGS.DEFAULT_PAGE_SIZE)

const secondPage = readManagerLogEntries({
afterSeq: firstPage.entries.at(-1)?.seq,
})
expect(secondPage.entries.map((entry) => entry.seq)).toEqual([
DEFAULTS.LOGS.DEFAULT_PAGE_SIZE + 1,
DEFAULTS.LOGS.DEFAULT_PAGE_SIZE + 2,
])

const drainedSeqs = [...firstPage.entries, ...secondPage.entries].map((entry) => entry.seq)
expect(drainedSeqs).toHaveLength(total)
drainedSeqs.forEach((seq, index) => {
expect(seq).toBe(index + 1)
})
})

it('keeps only the newest BUFFER_CAPACITY entries and reports evictions', () => {
const total = DEFAULTS.LOGS.BUFFER_CAPACITY + 5
for (let i = 0; i < total; i++) {
appendManagerLogEntry({ level: 'info', source: 'manager', message: `entry-${i}` })
}

const newestPage = readManagerLogEntries({ limit: DEFAULTS.LOGS.MAX_PAGE_SIZE })
expect(newestPage.dropped).toBe(5)
expect(newestPage.oldestSeq).toBe(6)
expect(newestPage.latestSeq).toBe(total)
expect(newestPage.entries.at(0)?.message).toBe(`entry-${total - DEFAULTS.LOGS.MAX_PAGE_SIZE}`)
expect(newestPage.entries.at(-1)?.message).toBe(`entry-${total - 1}`)

const retainedCount = newestPage.latestSeq - newestPage.oldestSeq + 1
expect(retainedCount).toBe(DEFAULTS.LOGS.BUFFER_CAPACITY)
})

it('level filter keeps minimum severity: warn returns warn and error only', () => {
appendManagerLogEntry({ level: 'debug', source: 'manager', message: 'debug' })
appendManagerLogEntry({ level: 'info', source: 'manager', message: 'info' })
appendManagerLogEntry({ level: 'warn', source: 'manager', message: 'warn' })
appendManagerLogEntry({ level: 'error', source: 'manager', message: 'error' })

const result = readManagerLogEntries({ level: 'warn' })
expect(result.entries.map((entry) => entry.level)).toEqual(['warn', 'error'])
})

it('source filter excludes other sources', () => {
appendManagerLogEntry({ level: 'info', source: 'manager', message: 'from manager' })
appendManagerLogEntry({ level: 'info', source: 'opencode', message: 'from opencode' })

const result = readManagerLogEntries({ source: 'opencode' })
expect(result.entries.map((entry) => entry.message)).toEqual(['from opencode'])
})

it('limit above MAX_PAGE_SIZE is capped to MAX_PAGE_SIZE newest matches', () => {
const total = DEFAULTS.LOGS.MAX_PAGE_SIZE + 10
for (let i = 0; i < total; i++) {
appendManagerLogEntry({ level: 'info', source: 'manager', message: `entry-${i}` })
}

const result = readManagerLogEntries({ limit: DEFAULTS.LOGS.MAX_PAGE_SIZE * 10 })
expect(result.entries).toHaveLength(DEFAULTS.LOGS.MAX_PAGE_SIZE)
expect(result.entries.at(0)?.message).toBe(`entry-${total - DEFAULTS.LOGS.MAX_PAGE_SIZE}`)
expect(result.entries.at(-1)?.message).toBe(`entry-${total - 1}`)
})

it('truncates messages longer than MAX_ENTRY_LENGTH with the truncated marker', () => {
const longMessage = 'x'.repeat(DEFAULTS.LOGS.MAX_ENTRY_LENGTH + 100)
appendManagerLogEntry({ level: 'info', source: 'manager', message: longMessage })

const result = readManagerLogEntries({})
expect(result.entries).toHaveLength(1)
const storedMessage = result.entries.at(0)?.message
expect(storedMessage?.endsWith(' …[truncated]')).toBe(true)
expect(storedMessage?.length).toBe(DEFAULTS.LOGS.MAX_ENTRY_LENGTH + ' …[truncated]'.length)
})
})

describe('process log forwarder', () => {
beforeEach(() => {
resetManagerLogBuffer()
})

it('emits one entry per complete line and nothing for a trailing partial line', () => {
const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
forwarder.write('first\nsecond\npartial')

expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([
'first',
'second',
])

forwarder.write('\n')
expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([
'first',
'second',
'partial',
])
})

it('joins a line split across two writes and emits it once', () => {
const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
forwarder.write('hel')
forwarder.write('lo\n')

expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual(['hello'])
})

it('flush emits a trailing partial line', () => {
const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
forwarder.write('tail without newline')
forwarder.flush()

expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([
'tail without newline',
])
})

it('skips blank and whitespace-only lines', () => {
const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
forwarder.write('\n \n\t\nreal line\n')

expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual(['real line'])
})

it('preserves trailing spaces and tabs on nonblank lines', () => {
const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
forwarder.write('formatted value: \n\taligned diagnostic\t\n')

expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([
'formatted value: ',
'\taligned diagnostic\t',
])
})

it('parses embedded levels and falls back to the forwarder default', () => {
const stdoutForwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
const stderrForwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'error' })

stdoutForwarder.write('ERROR boom\n')
stderrForwarder.write('INFO fine\n')
stderrForwarder.write('no level token here\n')

expect(readManagerLogEntries({ source: 'opencode' }).entries).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: 'ERROR boom', level: 'error', source: 'opencode' }),
expect.objectContaining({ message: 'INFO fine', level: 'info', source: 'opencode' }),
expect.objectContaining({ message: 'no level token here', level: 'error', source: 'opencode' }),
]),
)
})

it('carries the configured source on every entry', () => {
const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
forwarder.write('one\ntwo\n')

const result = readManagerLogEntries({ source: 'opencode' })
expect(result.entries).toHaveLength(2)
expect(result.entries.every((entry) => entry.source === 'opencode')).toBe(true)
})

it('appends an endless unterminated line once it exceeds MAX_ENTRY_LENGTH instead of growing unbounded', () => {
const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
forwarder.write('a'.repeat(DEFAULTS.LOGS.MAX_ENTRY_LENGTH + 1))
expect(readManagerLogEntries({ source: 'opencode' }).entries).toHaveLength(1)

forwarder.write('b'.repeat(10))
forwarder.flush()

const entries = readManagerLogEntries({ source: 'opencode' }).entries
expect(entries).toHaveLength(2)
expect(entries[0]?.message.startsWith('a')).toBe(true)
expect(entries[1]?.message.startsWith('b')).toBe(true)
})

it('rejoins a multibyte UTF-8 character whose bytes are split across two writes', () => {
const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
const bytes = new TextEncoder().encode('opencode log: 😀 done\n')
forwarder.write(bytes.slice(0, 16))
forwarder.write(bytes.slice(16))

expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([
'opencode log: 😀 done',
])
})

it('flush finalizes a trailing incomplete UTF-8 sequence instead of losing it', () => {
const forwarder = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' })
const bytes = new TextEncoder().encode('warn: café')
forwarder.write(bytes.slice(0, bytes.length - 1))
forwarder.flush()

expect(readManagerLogEntries({ source: 'opencode' }).entries.map((entry) => entry.message)).toEqual([
'warn: caf\uFFFD',
])
})

it('returns the same instanceId on every read and ignores buffer resets', () => {
const first = readManagerLogEntries({})
appendManagerLogEntry({ level: 'info', source: 'manager', message: 'after reset' })
resetManagerLogBuffer()
appendManagerLogEntry({ level: 'info', source: 'manager', message: 'after reset' })
const second = readManagerLogEntries({})

expect(first.instanceId).toMatch(/^[0-9a-f-]{36}$/i)
expect(second.instanceId).toBe(first.instanceId)
})
})
Loading