From 816137315eca4554b32d1578e3e81f3ccbfdfe0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 23 May 2026 06:21:06 +0000 Subject: [PATCH] feat: add uwf log subcommands (list, show, clean) - cmdLogList: list log files with sizes, sorted by date descending - cmdLogShow: filter entries by thread, process, and/or date - cmdLogClean: delete log files older than given date - 12 tests covering all functions and edge cases Fixes #413 --- .../cli-workflow/src/__tests__/log.test.ts | 161 ++++++++++++++++++ packages/cli-workflow/src/cli.ts | 44 +++++ packages/cli-workflow/src/commands/log.ts | 116 +++++++++++++ 3 files changed, 321 insertions(+) create mode 100644 packages/cli-workflow/src/__tests__/log.test.ts create mode 100644 packages/cli-workflow/src/commands/log.ts diff --git a/packages/cli-workflow/src/__tests__/log.test.ts b/packages/cli-workflow/src/__tests__/log.test.ts new file mode 100644 index 0000000..de4c2c3 --- /dev/null +++ b/packages/cli-workflow/src/__tests__/log.test.ts @@ -0,0 +1,161 @@ +import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { cmdLogClean, cmdLogList, cmdLogShow } from "../commands/log.js"; + +let storageRoot: string; + +beforeEach(async () => { + storageRoot = join(tmpdir(), `uwf-log-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(join(storageRoot, "logs"), { recursive: true }); +}); + +afterEach(async () => { + await rm(storageRoot, { recursive: true, force: true }); +}); + +const entry1 = JSON.stringify({ + ts: "2026-05-20T10:00:00.000Z", + pid: "1716200000000-1234", + tag: "W9F3RK2M", + msg: "process start", + thread: "01J1234ABCDEF", + workflow: "solve-issue", +}); + +const entry2 = JSON.stringify({ + ts: "2026-05-20T10:00:01.000Z", + pid: "1716200000000-1234", + tag: "ABC12345", + msg: "step executed", + thread: "01J1234ABCDEF", + workflow: "solve-issue", +}); + +const entry3 = JSON.stringify({ + ts: "2026-05-20T10:00:02.000Z", + pid: "1716200000000-5678", + tag: "XYZ98765", + msg: "different process", + thread: "01JOTHER000000", + workflow: "review-code", +}); + +const oldEntry = JSON.stringify({ + ts: "2026-05-19T08:00:00.000Z", + pid: "1716200000000-9999", + tag: "OLD1TAG1", + msg: "old entry", + thread: "01JOLD0000000", + workflow: "solve-issue", +}); + +const olderEntry = JSON.stringify({ + ts: "2026-05-18T08:00:00.000Z", + pid: "1716200000000-0001", + tag: "OLD2TAG2", + msg: "older entry", + thread: "01JOLDER00000", + workflow: "review-code", +}); + +async function writeLogFiles(): Promise { + const logsDir = join(storageRoot, "logs"); + await writeFile(join(logsDir, "2026-05-20.jsonl"), [entry1, entry2, entry3].join("\n") + "\n"); + await writeFile(join(logsDir, "2026-05-19.jsonl"), oldEntry + "\n"); + await writeFile(join(logsDir, "2026-05-18.jsonl"), olderEntry + "\n"); +} + +describe("cmdLogList", () => { + test("lists log files with sizes sorted by date descending", async () => { + await writeLogFiles(); + const result = await cmdLogList(storageRoot); + expect(result).toHaveLength(3); + expect(result[0].name).toBe("2026-05-20.jsonl"); + expect(result[0].date).toBe("2026-05-20"); + expect(result[0].size).toBeGreaterThan(0); + expect(result[1].name).toBe("2026-05-19.jsonl"); + expect(result[2].name).toBe("2026-05-18.jsonl"); + }); + + test("returns empty array when no log files exist", async () => { + const result = await cmdLogList(storageRoot); + expect(result).toEqual([]); + }); + + test("returns empty array when logs directory does not exist", async () => { + const noLogsRoot = join(storageRoot, "nonexistent"); + await mkdir(noLogsRoot, { recursive: true }); + const result = await cmdLogList(noLogsRoot); + expect(result).toEqual([]); + }); +}); + +describe("cmdLogShow", () => { + test("filters by thread ID", async () => { + await writeLogFiles(); + const result = await cmdLogShow(storageRoot, { thread: "01J1234ABCDEF", process: null, date: null }); + expect(result).toHaveLength(2); + expect(result.every((e) => e.thread === "01J1234ABCDEF")).toBe(true); + }); + + test("filters by process ID", async () => { + await writeLogFiles(); + const result = await cmdLogShow(storageRoot, { thread: null, process: "1716200000000-1234", date: null }); + expect(result).toHaveLength(2); + expect(result.every((e) => e.pid === "1716200000000-1234")).toBe(true); + }); + + test("filters by date", async () => { + await writeLogFiles(); + const result = await cmdLogShow(storageRoot, { thread: null, process: null, date: "2026-05-19" }); + expect(result).toHaveLength(1); + expect(result[0].msg).toBe("old entry"); + }); + + test("reads all files when no date filter", async () => { + await writeLogFiles(); + const result = await cmdLogShow(storageRoot, { thread: null, process: null, date: null }); + expect(result).toHaveLength(5); + // sorted by ts ascending + expect(result[0].ts).toBe("2026-05-18T08:00:00.000Z"); + expect(result[4].ts).toBe("2026-05-20T10:00:02.000Z"); + }); + + test("returns empty when no matches", async () => { + await writeLogFiles(); + const result = await cmdLogShow(storageRoot, { thread: "NONEXISTENT", process: null, date: null }); + expect(result).toEqual([]); + }); + + test("combined thread + date filter", async () => { + await writeLogFiles(); + const result = await cmdLogShow(storageRoot, { thread: "01J1234ABCDEF", process: null, date: "2026-05-20" }); + expect(result).toHaveLength(2); + expect(result.every((e) => e.thread === "01J1234ABCDEF")).toBe(true); + }); +}); + +describe("cmdLogClean", () => { + test("deletes files before given date", async () => { + await writeLogFiles(); + const result = await cmdLogClean(storageRoot, "2026-05-20"); + expect(result.deleted).toBe(2); + const remaining = await readdir(join(storageRoot, "logs")); + expect(remaining).toEqual(["2026-05-20.jsonl"]); + }); + + test("deletes nothing when all files are newer", async () => { + await writeLogFiles(); + const result = await cmdLogClean(storageRoot, "2026-05-18"); + expect(result.deleted).toBe(0); + }); + + test("handles missing logs directory gracefully", async () => { + const noLogsRoot = join(storageRoot, "nonexistent"); + await mkdir(noLogsRoot, { recursive: true }); + const result = await cmdLogClean(noLogsRoot, "2026-05-20"); + expect(result).toEqual({ deleted: 0 }); + }); +}); diff --git a/packages/cli-workflow/src/cli.ts b/packages/cli-workflow/src/cli.ts index 8ea225f..9b94ef7 100755 --- a/packages/cli-workflow/src/cli.ts +++ b/packages/cli-workflow/src/cli.ts @@ -29,6 +29,7 @@ import { THREAD_READ_DEFAULT_QUOTA, } from "./commands/thread.js"; import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js"; +import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js"; import { formatOutput, type OutputFormat } from "./format.js"; import { resolveStorageRoot } from "./store.js"; @@ -379,6 +380,49 @@ casSchema }); }); +const log = program.command("log").description("Process-level debug logs"); + +log + .command("list") + .description("List log files with sizes") + .action(() => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const result = await cmdLogList(storageRoot); + writeOutput(result); + }); + }); + +log + .command("show") + .description("Show and filter log entries") + .option("--thread ", "Filter by thread ID") + .option("--process ", "Filter by process ID") + .option("--date ", "Filter by date (YYYY-MM-DD)") + .action((opts: { thread: string | undefined; process: string | undefined; date: string | undefined }) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const result = await cmdLogShow(storageRoot, { + thread: opts.thread ?? null, + process: opts.process ?? null, + date: opts.date ?? null, + }); + writeOutput(result); + }); + }); + +log + .command("clean") + .description("Delete log files older than given date") + .requiredOption("--before ", "Delete files before this date (YYYY-MM-DD)") + .action((opts: { before: string }) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const result = await cmdLogClean(storageRoot, opts.before); + writeOutput(result); + }); + }); + program.parseAsync(process.argv).catch((e: unknown) => { const message = e instanceof Error ? e.message : String(e); process.stderr.write(`${message}\n`); diff --git a/packages/cli-workflow/src/commands/log.ts b/packages/cli-workflow/src/commands/log.ts new file mode 100644 index 0000000..72951d5 --- /dev/null +++ b/packages/cli-workflow/src/commands/log.ts @@ -0,0 +1,116 @@ +import { readFile, readdir, stat, unlink } from "node:fs/promises"; +import { join } from "node:path"; + +type LogListItem = { + name: string; + size: number; + date: string; +}; + +type LogShowFilter = { + thread: string | null; + process: string | null; + date: string | null; +}; + +type LogEntry = { + ts: string; + pid: string; + tag: string; + msg: string; + thread: string | null; + workflow: string | null; +}; + +type LogCleanResult = { + deleted: number; +}; + +function logsDir(storageRoot: string): string { + return join(storageRoot, "logs"); +} + +async function listLogFiles(dir: string): Promise> { + try { + const files = await readdir(dir); + return files.filter((f) => f.endsWith(".jsonl")).sort(); + } catch { + return []; + } +} + +function dateFromFilename(name: string): string { + return name.replace(".jsonl", ""); +} + +async function parseJsonlFile(path: string): Promise> { + const content = await readFile(path, "utf-8"); + const lines = content.trim().split("\n").filter((l) => l.length > 0); + return lines.map((line) => JSON.parse(line) as LogEntry); +} + +export async function cmdLogList(storageRoot: string): Promise> { + const dir = logsDir(storageRoot); + const files = await listLogFiles(dir); + const items: Array = []; + for (const name of files) { + const s = await stat(join(dir, name)); + items.push({ name, size: s.size, date: dateFromFilename(name) }); + } + // sort by date descending + items.sort((a, b) => (a.date > b.date ? -1 : a.date < b.date ? 1 : 0)); + return items; +} + +export async function cmdLogShow( + storageRoot: string, + filter: LogShowFilter, +): Promise> { + const dir = logsDir(storageRoot); + let files: Array; + + if (filter.date !== null) { + files = [`${filter.date}.jsonl`]; + } else { + files = await listLogFiles(dir); + } + + let entries: Array = []; + for (const file of files) { + try { + const parsed = await parseJsonlFile(join(dir, file)); + entries = entries.concat(parsed); + } catch { + // file doesn't exist or is unreadable, skip + } + } + + if (filter.thread !== null) { + entries = entries.filter((e) => e.thread === filter.thread); + } + if (filter.process !== null) { + entries = entries.filter((e) => e.pid === filter.process); + } + + entries.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0)); + return entries; +} + +export async function cmdLogClean( + storageRoot: string, + before: string, +): Promise { + const dir = logsDir(storageRoot); + const files = await listLogFiles(dir); + let deleted = 0; + + for (const name of files) { + const date = dateFromFilename(name); + if (date < before) { + await unlink(join(dir, name)); + deleted++; + } + } + + return { deleted }; +}