diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e63f10ba80c..e0c0786bf0a 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -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) => { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6dc5e99e91e..a4beec3e167 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -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" diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 6941310bbbd..47a82492a69 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -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" @@ -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 = "" @@ -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 Promise> = { + 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(() => {}))) } diff --git a/packages/opencode/test/util/log.test.ts b/packages/opencode/test/util/log.test.ts new file mode 100644 index 00000000000..5c899022bc8 --- /dev/null +++ b/packages/opencode/test/util/log.test.ts @@ -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) + }) +})