Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ await Log.init({
if (Installation.isLocal()) return "DEBUG"
return "INFO"
})(),
path: process.env.OPENCODE_LOG_PATH || undefined,
rotate:
process.env.OPENCODE_LOG_ROTATE === "daily"
? { kind: "daily", retention: Number(process.env.OPENCODE_LOG_RETENTION) || undefined }
: undefined,
})

process.on("unhandledRejection", (e) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ const cli = yargs(hideBin(process.argv))
if (Installation.isLocal()) return "DEBUG"
return "INFO"
})(),
path: process.env.OPENCODE_LOG_PATH || undefined,
rotate:
process.env.OPENCODE_LOG_ROTATE === "daily"
? { kind: "daily", retention: Number(process.env.OPENCODE_LOG_RETENTION) || undefined }
: undefined,
})

process.env.AGENT = "1"
Expand Down
92 changes: 74 additions & 18 deletions packages/opencode/src/util/log.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from "path"
import fs from "fs/promises"
import { openSync, writeSync, closeSync } from "fs"
import { Global } from "../global"
import z from "zod"

Expand Down Expand Up @@ -40,10 +41,22 @@ export namespace Log {

export const Default = create({ service: "default" })

export type Rotate =
| {
kind: "startup"
retention?: number
}
| {
kind: "daily"
retention?: number
}

export interface Options {
print: boolean
dev?: boolean
level?: Level
path?: string
rotate?: Rotate
}

let logpath = ""
Expand All @@ -55,35 +68,78 @@ export namespace Log {
return msg.length
}

let fd: number | undefined
let deadline = 0

function today() {
const d = new Date()
return (
d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0")
)
}

function midnight() {
const d = new Date()
d.setHours(24, 0, 0, 0)
return d.getTime()
}

function rotate(dir: string) {
if (Date.now() < deadline && fd !== undefined) return
if (fd !== undefined) closeSync(fd)
deadline = midnight()
logpath = path.join(dir, `opencode-${today()}.log`)
fd = openSync(logpath, "a")
}

const writeStrategies: Record<Rotate["kind"], (options: Options) => Promise<void>> = {
async startup(options) {
const dir = options.path ?? Global.Path.log
const retention = options.rotate?.retention ?? 10
cleanup(dir, retention)
const name = options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log"
logpath = path.join(dir, name)
await fs.truncate(logpath).catch(() => {})
const writer = Bun.file(logpath).writer()
write = (msg: any) => {
const num = writer.write(msg)
writer.flush()
return num
}
},
async daily(options) {
const dir = options.path ?? Global.Path.log
const retention = options.rotate?.retention ?? 7
await fs.mkdir(dir, { recursive: true })
if (fd !== undefined) closeSync(fd)
fd = undefined
deadline = 0
rotate(dir)
cleanup(dir, retention)
write = (msg: any) => {
rotate(dir)
return writeSync(fd!, msg)
}
},
}

export async function init(options: Options) {
if (options.level) level = options.level
cleanup(Global.Path.log)
if (options.print) return
logpath = path.join(
Global.Path.log,
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer()
write = async (msg: any) => {
const num = writer.write(msg)
writer.flush()
return num
}
return writeStrategies[options.rotate?.kind ?? "startup"](options)
}

async function cleanup(dir: string) {
const glob = new Bun.Glob("????-??-??T??????.log")
async function cleanup(dir: string, retention: number) {
const glob = new Bun.Glob("*[0-9]*.log")
const files = await Array.fromAsync(
glob.scan({
cwd: dir,
absolute: true,
}),
)
if (files.length <= 5) return

const filesToDelete = files.slice(0, -10)
if (files.length <= retention) return
files.sort()
const filesToDelete = files.slice(0, -retention)
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
}

Expand Down
206 changes: 206 additions & 0 deletions packages/opencode/test/util/log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import os from "os"
import path from "path"
import fs from "fs/promises"
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { Log } from "../../src/util/log"

let tmpDir: string

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "log-test-"))
})

afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
})

describe("startup strategy", () => {
test("each init creates a new file", async () => {
const dir1 = path.join(tmpDir, "run1")
const dir2 = path.join(tmpDir, "run2")
await fs.mkdir(dir1, { recursive: true })
await fs.mkdir(dir2, { recursive: true })

await Log.init({ print: false, path: dir1, rotate: { kind: "startup" } })
const first = Log.file()

await Log.init({ print: false, path: dir2, rotate: { kind: "startup" } })
const second = Log.file()

expect(first).toBeTruthy()
expect(second).toBeTruthy()
expect(first).not.toBe(second)
})

test("file name matches YYYY-MM-DDTHHMMSS.log format", async () => {
const dir = path.join(tmpDir, "startup-fmt")
await fs.mkdir(dir, { recursive: true })

await Log.init({ print: false, path: dir, rotate: { kind: "startup" } })
const file = path.basename(Log.file())

expect(file).toMatch(/^\d{4}-\d{2}-\d{2}T\d{6}\.log$/)
})

test("dev mode uses dev.log as file name", async () => {
const dir = path.join(tmpDir, "startup-dev")
await fs.mkdir(dir, { recursive: true })

await Log.init({ print: false, dev: true, path: dir, rotate: { kind: "startup" } })
expect(path.basename(Log.file())).toBe("dev.log")
})

test("cleans up old files beyond retention", async () => {
const dir = path.join(tmpDir, "startup-retention")
await fs.mkdir(dir, { recursive: true })

// create 5 old log files that match the cleanup glob (*[0-9]*.log)
const oldFiles = Array.from({ length: 5 }, (_, i) => {
const name = `2024-01-0${i + 1}T000000.log`
return path.join(dir, name)
})
await Promise.all(oldFiles.map((f) => fs.writeFile(f, "old")))

// init with retention=3: cleanup runs before new file is created,
// so it sees 5 old files and deletes the 2 oldest
await Log.init({ print: false, path: dir, rotate: { kind: "startup", retention: 3 } })

// poll until the 2 oldest files are deleted
const oldest = [oldFiles[0], oldFiles[1]]
const end = Date.now() + 2000
while (Date.now() < end) {
const gone = await Promise.all(oldest.map((f) => fs.access(f).then(() => false).catch(() => true)))
if (gone.every(Boolean)) break
await Bun.sleep(50)
}

// the 2 oldest files should be deleted
for (const f of oldest) {
await expect(fs.access(f)).rejects.toThrow()
}
// the 3 newest old files should still exist
for (const f of oldFiles.slice(2)) {
await expect(fs.stat(f)).resolves.toBeTruthy()
}
})

test("log.info writes content to file", async () => {
const dir = path.join(tmpDir, "startup-write")
await fs.mkdir(dir, { recursive: true })

await Log.init({ print: false, level: "DEBUG", path: dir, rotate: { kind: "startup" } })
const logger = Log.create({ service: "test-startup-write" })
logger.info("hello startup")

// flush happens synchronously via writer.flush()
await Bun.sleep(50)
const content = await fs.readFile(Log.file(), "utf-8")
expect(content).toContain("hello startup")
})
})

describe("daily strategy", () => {
test("same day init uses the same file", async () => {
const dir = path.join(tmpDir, "daily-same")
await fs.mkdir(dir, { recursive: true })

await Log.init({ print: false, path: dir, rotate: { kind: "daily" } })
const first = Log.file()

await Log.init({ print: false, path: dir, rotate: { kind: "daily" } })
const second = Log.file()

expect(first).toBeTruthy()
expect(second).toBeTruthy()
expect(path.basename(first)).toBe(path.basename(second))
})

test("file name matches opencode-YYYY-MM-DD.log format", async () => {
const dir = path.join(tmpDir, "daily-fmt")
await fs.mkdir(dir, { recursive: true })

await Log.init({ print: false, path: dir, rotate: { kind: "daily" } })
const file = path.basename(Log.file())

expect(file).toMatch(/^opencode-\d{4}-\d{2}-\d{2}\.log$/)
})

test("cleans up old files beyond retention", async () => {
const dir = path.join(tmpDir, "daily-retention")
await fs.mkdir(dir, { recursive: true })

// create 10 old daily log files
const oldFiles = Array.from({ length: 10 }, (_, i) => {
const day = String(i + 1).padStart(2, "0")
return path.join(dir, `opencode-2024-01-${day}.log`)
})
await Promise.all(oldFiles.map((f) => fs.writeFile(f, "old")))

// init with retention=5
await Log.init({ print: false, path: dir, rotate: { kind: "daily", retention: 5 } })

// wait for async cleanup
await Bun.sleep(100)

const remaining = await fs.readdir(dir)
const logFiles = remaining.filter((f) => f.endsWith(".log"))
// 5 old + 1 today = 6, but cleanup keeps only retention=5
expect(logFiles.length).toBeLessThanOrEqual(5)
})

test("log.info writes content to file", async () => {
const dir = path.join(tmpDir, "daily-write")
await fs.mkdir(dir, { recursive: true })

await Log.init({ print: false, level: "DEBUG", path: dir, rotate: { kind: "daily" } })
const logger = Log.create({ service: "test-daily-write" })
logger.info("hello daily")

await Bun.sleep(50)
const content = await fs.readFile(Log.file(), "utf-8")
expect(content).toContain("hello daily")
})

test("appends to existing file on re-init same day", async () => {
const dir = path.join(tmpDir, "daily-append")
await fs.mkdir(dir, { recursive: true })

await Log.init({ print: false, level: "DEBUG", path: dir, rotate: { kind: "daily" } })
const logger1 = Log.create({ service: "test-daily-append-1" })
logger1.info("first message")
await Bun.sleep(50)

const filePath = Log.file()
const contentAfterFirst = await fs.readFile(filePath, "utf-8")
expect(contentAfterFirst).toContain("first message")

// re-init same day same dir
await Log.init({ print: false, level: "DEBUG", path: dir, rotate: { kind: "daily" } })
const logger2 = Log.create({ service: "test-daily-append-2" })
logger2.info("second message")
await Bun.sleep(50)

const contentAfterSecond = await fs.readFile(Log.file(), "utf-8")
expect(contentAfterSecond).toContain("second message")
})
})

describe("Log.file()", () => {
test("returns non-empty path after startup init", async () => {
const dir = path.join(tmpDir, "file-path-startup")
await fs.mkdir(dir, { recursive: true })

await Log.init({ print: false, path: dir, rotate: { kind: "startup" } })
expect(Log.file()).toBeTruthy()
expect(Log.file()).toContain(dir)
})

test("returns non-empty path after daily init", async () => {
const dir = path.join(tmpDir, "file-path-daily")
await fs.mkdir(dir, { recursive: true })

await Log.init({ print: false, path: dir, rotate: { kind: "daily" } })
expect(Log.file()).toBeTruthy()
expect(Log.file()).toContain(dir)
})
})