From b9031e5d364b020f4d8d4d2d361fc1904a23f476 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Mon, 22 Jun 2026 22:54:50 +0900 Subject: [PATCH 1/3] chore(cli): open issue 386 work branch From b59df636390a06281b40d54a1fd0a4d37ad6cc6f Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Mon, 22 Jun 2026 22:57:58 +0900 Subject: [PATCH 2/3] fix(cli): derive repo log levels --- packages/cli/src/commands/logs.test.ts | 111 +++++++++++++++++++++++++ packages/cli/src/commands/logs.ts | 26 +++++- 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/logs.test.ts b/packages/cli/src/commands/logs.test.ts index dabdede..db957d5 100644 --- a/packages/cli/src/commands/logs.test.ts +++ b/packages/cli/src/commands/logs.test.ts @@ -250,6 +250,117 @@ describe("logs command", () => { ); }); + it("filters scanned events by derived --level values", async () => { + const configDir = await createConfigFixture(); + await writeRunEvents(configDir, "tenant-a", "run-1", [ + { + at: "2026-03-16T00:00:00.000Z", + event: "run-started", + issueIdentifier: "acme/platform#1", + projectId: "tenant-a", + }, + { + at: "2026-03-16T00:01:00.000Z", + event: "run-failed", + issueIdentifier: "acme/platform#2", + projectId: "tenant-a", + attempt: 1, + lastError: "worker failed", + }, + { + at: "2026-03-16T00:02:00.000Z", + event: "run-retried", + issueIdentifier: "acme/platform#3", + projectId: "tenant-a", + attempt: 2, + retryKind: "backoff", + nextRetryAt: "2026-03-16T00:05:00.000Z", + }, + ]); + const stdout = captureWrites(process.stdout); + + try { + await logsCommand(["--level", "error"], { + configDir, + verbose: false, + json: false, + noColor: false, + }); + } finally { + stdout.restore(); + } + + const output = stdout.output(); + expect(output).toContain("run-failed acme/platform#2 worker failed"); + expect(output).not.toContain("run-started"); + expect(output).not.toContain("run-retried"); + }); + + it("filters a specific run by derived warning level", async () => { + const configDir = await createConfigFixture(); + await writeRunEvents(configDir, "tenant-a", "run-1", [ + { + at: "2026-03-16T00:00:00.000Z", + event: "hook-failed", + projectId: "tenant-a", + hook: "after-create", + error: "hook failed", + }, + { + at: "2026-03-16T00:01:00.000Z", + event: "run-suppressed", + issueIdentifier: "acme/platform#2", + projectId: "tenant-a", + reason: "lease already active", + }, + ]); + const stdout = captureWrites(process.stdout); + + try { + await logsCommand(["--run", "run-1", "--level", "warn"], { + configDir, + verbose: false, + json: false, + noColor: false, + }); + } finally { + stdout.restore(); + } + + const output = stdout.output(); + expect(output).toContain("run-suppressed acme/platform#2"); + expect(output).not.toContain("hook-failed"); + }); + + it("prints a notice when --level matches no scanned events", async () => { + const configDir = await createConfigFixture(); + await writeRunEvents(configDir, "tenant-a", "run-1", [ + { + at: "2026-03-16T00:00:00.000Z", + event: "run-started", + issueIdentifier: "acme/platform#1", + projectId: "tenant-a", + }, + ]); + const stdout = captureWrites(process.stdout); + const stderr = captureWrites(process.stderr); + + try { + await logsCommand(["--level", "error"], { + configDir, + verbose: false, + json: false, + noColor: false, + }); + } finally { + stdout.restore(); + stderr.restore(); + } + + expect(stdout.output()).toBe(""); + expect(stderr.output()).toBe("No events matched --level error.\n"); + }); + it("sorts scanned events chronologically across project run directories", async () => { const configDir = await createConfigFixture(); await writeRunEvents(configDir, "tenant-a", "run-later", [ diff --git a/packages/cli/src/commands/logs.ts b/packages/cli/src/commands/logs.ts index b1f3d95..f9b0027 100644 --- a/packages/cli/src/commands/logs.ts +++ b/packages/cli/src/commands/logs.ts @@ -11,6 +11,7 @@ import { } from "../project-selection.js"; type LoggedEvent = Record & { at?: string }; +type LogLevel = "error" | "warn" | "info"; function parseLogsArgs(args: string[]): { follow: boolean; @@ -78,6 +79,7 @@ const handler = async ( try { const content = await readFile(eventsPath, "utf8"); const lines = content.trim().split("\n").filter(Boolean); + let matchedEvents = 0; for (const line of lines) { const event = JSON.parse(line) as LoggedEvent; if (parsed.projectId && getProjectId(event) !== parsed.projectId) @@ -86,6 +88,10 @@ const handler = async ( if (parsed.issue && getIssueIdentifier(event) !== parsed.issue) continue; process.stdout.write(formatEvent(event) + "\n"); + matchedEvents += 1; + } + if (parsed.level && matchedEvents === 0) { + process.stderr.write(`No events matched --level ${parsed.level}.\n`); } } catch { process.stderr.write(`No events found for run: ${parsed.run}\n`); @@ -147,6 +153,7 @@ const handler = async ( ? [join(runtimeRoot, "projects", parsed.projectId, "runs")] : await listProjectRunRoots(runtimeRoot); let foundRuns = false; + let matchedEvents = 0; const events: LoggedEvent[] = []; try { @@ -169,6 +176,7 @@ const handler = async ( if (parsed.issue && getIssueIdentifier(event) !== parsed.issue) continue; events.push(event); + matchedEvents += 1; } } catch { // Skip runs without events @@ -183,6 +191,10 @@ const handler = async ( process.stderr.write("No runs found. Start the orchestrator first.\n"); return; } + if (parsed.level && matchedEvents === 0) { + process.stderr.write(`No events matched --level ${parsed.level}.\n`); + return; + } events.sort((left, right) => String(left.at ?? "").localeCompare(String(right.at ?? "")) @@ -208,8 +220,18 @@ function getProjectId(event: LoggedEvent): string | undefined { return typeof event.projectId === "string" ? event.projectId : undefined; } -function getLevel(event: LoggedEvent): string | undefined { - return typeof event.level === "string" ? event.level : undefined; +function getLevel(event: LoggedEvent): LogLevel { + switch (event.event) { + case "run-failed": + case "worker-error": + case "hook-failed": + return "error"; + case "run-suppressed": + case "run-retried": + return "warn"; + default: + return "info"; + } } function getIssueIdentifier(event: LoggedEvent): string { From 25f49a5b09f9cf148080851a737ea507050961ee Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 23 Jun 2026 01:31:14 +0900 Subject: [PATCH 3/3] fix(cli): harden repo log level filter --- .changeset/repo-logs-level-filter.md | 5 ++ packages/cli/src/commands/logs.test.ts | 80 +++++++++++++++++++++++++- packages/cli/src/commands/logs.ts | 37 ++++++++++-- 3 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 .changeset/repo-logs-level-filter.md diff --git a/.changeset/repo-logs-level-filter.md b/.changeset/repo-logs-level-filter.md new file mode 100644 index 0000000..e87748a --- /dev/null +++ b/.changeset/repo-logs-level-filter.md @@ -0,0 +1,5 @@ +--- +"@gh-symphony/cli": patch +--- + +Fix `gh-symphony repo logs --level` to derive levels from structured event types, include turn failures in error results, validate unsupported level values, and report empty filtered results clearly for issue #386. diff --git a/packages/cli/src/commands/logs.test.ts b/packages/cli/src/commands/logs.test.ts index db957d5..de58ec3 100644 --- a/packages/cli/src/commands/logs.test.ts +++ b/packages/cli/src/commands/logs.test.ts @@ -52,7 +52,10 @@ async function createConfigFixture(): Promise { "utf8" ); - for (const project of [createProject("tenant-a"), createProject("tenant-b")]) { + for (const project of [ + createProject("tenant-a"), + createProject("tenant-b"), + ]) { const projectDir = join(configDir, "projects", project.projectId); await mkdir(projectDir, { recursive: true }); await writeFile( @@ -276,6 +279,21 @@ describe("logs command", () => { retryKind: "backoff", nextRetryAt: "2026-03-16T00:05:00.000Z", }, + { + at: "2026-03-16T00:03:00.000Z", + event: "turn_failed", + issueIdentifier: "acme/platform#4", + projectId: "tenant-a", + turnCount: 1, + startedAt: "2026-03-16T00:02:30.000Z", + durationMs: 30_000, + tokenUsage: { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }, + error: "turn failed", + }, ]); const stdout = captureWrites(process.stdout); @@ -292,6 +310,7 @@ describe("logs command", () => { const output = stdout.output(); expect(output).toContain("run-failed acme/platform#2 worker failed"); + expect(output).toContain("turn_failed acme/platform#4 turn failed"); expect(output).not.toContain("run-started"); expect(output).not.toContain("run-retried"); }); @@ -358,7 +377,64 @@ describe("logs command", () => { } expect(stdout.output()).toBe(""); - expect(stderr.output()).toBe("No events matched --level error.\n"); + expect(stderr.output()).toBe( + "No events matched the provided filters (--level error).\n" + ); + }); + + it("prints a notice when --level matches no events for a specific run", async () => { + const configDir = await createConfigFixture(); + await writeRunEvents(configDir, "tenant-a", "run-1", [ + { + at: "2026-03-16T00:00:00.000Z", + event: "run-started", + issueIdentifier: "acme/platform#1", + projectId: "tenant-a", + }, + ]); + const stdout = captureWrites(process.stdout); + const stderr = captureWrites(process.stderr); + + try { + await logsCommand(["--run", "run-1", "--level", "error"], { + configDir, + verbose: false, + json: false, + noColor: false, + }); + } finally { + stdout.restore(); + stderr.restore(); + } + + expect(stdout.output()).toBe(""); + expect(stderr.output()).toBe( + "No events matched the provided filters (--level error).\n" + ); + }); + + it("rejects unsupported --level values", async () => { + const configDir = await createConfigFixture(); + const stdout = captureWrites(process.stdout); + const stderr = captureWrites(process.stderr); + + try { + await logsCommand(["--level", "debug"], { + configDir, + verbose: false, + json: false, + noColor: false, + }); + } finally { + stdout.restore(); + stderr.restore(); + } + + expect(stdout.output()).toBe(""); + expect(stderr.output()).toBe( + 'Unknown --level "debug". Valid values: error, warn, info.\n' + ); + expect(process.exitCode).toBe(1); }); it("sorts scanned events chronologically across project run directories", async () => { diff --git a/packages/cli/src/commands/logs.ts b/packages/cli/src/commands/logs.ts index f9b0027..e9e6096 100644 --- a/packages/cli/src/commands/logs.ts +++ b/packages/cli/src/commands/logs.ts @@ -12,6 +12,7 @@ import { type LoggedEvent = Record & { at?: string }; type LogLevel = "error" | "warn" | "info"; +const LOG_LEVELS: readonly LogLevel[] = ["error", "warn", "info"]; function parseLogsArgs(args: string[]): { follow: boolean; @@ -57,6 +58,16 @@ const handler = async ( options: GlobalOptions ): Promise => { const parsed = parseLogsArgs(args); + const level = parseLogLevel(parsed.level); + if (parsed.level && !level) { + process.stderr.write( + `Unknown --level "${parsed.level}". Valid values: ${LOG_LEVELS.join( + ", " + )}.\n` + ); + process.exitCode = 1; + return; + } const runtimeRoot = resolve(options.configDir); // If --run is specified, read that run's events @@ -84,14 +95,14 @@ const handler = async ( const event = JSON.parse(line) as LoggedEvent; if (parsed.projectId && getProjectId(event) !== parsed.projectId) continue; - if (parsed.level && getLevel(event) !== parsed.level) continue; + if (level && getLevel(event) !== level) continue; if (parsed.issue && getIssueIdentifier(event) !== parsed.issue) continue; process.stdout.write(formatEvent(event) + "\n"); matchedEvents += 1; } - if (parsed.level && matchedEvents === 0) { - process.stderr.write(`No events matched --level ${parsed.level}.\n`); + if (level && matchedEvents === 0) { + process.stderr.write(noMatchedEventsMessage(level)); } } catch { process.stderr.write(`No events found for run: ${parsed.run}\n`); @@ -172,7 +183,7 @@ const handler = async ( const event = JSON.parse(line) as LoggedEvent; if (parsed.projectId && getProjectId(event) !== parsed.projectId) continue; - if (parsed.level && getLevel(event) !== parsed.level) continue; + if (level && getLevel(event) !== level) continue; if (parsed.issue && getIssueIdentifier(event) !== parsed.issue) continue; events.push(event); @@ -191,8 +202,8 @@ const handler = async ( process.stderr.write("No runs found. Start the orchestrator first.\n"); return; } - if (parsed.level && matchedEvents === 0) { - process.stderr.write(`No events matched --level ${parsed.level}.\n`); + if (level && matchedEvents === 0) { + process.stderr.write(noMatchedEventsMessage(level)); return; } @@ -223,6 +234,7 @@ function getProjectId(event: LoggedEvent): string | undefined { function getLevel(event: LoggedEvent): LogLevel { switch (event.event) { case "run-failed": + case "turn_failed": case "worker-error": case "hook-failed": return "error"; @@ -234,6 +246,19 @@ function getLevel(event: LoggedEvent): LogLevel { } } +function parseLogLevel(level: string | undefined): LogLevel | undefined { + if (!level) { + return undefined; + } + return LOG_LEVELS.includes(level as LogLevel) + ? (level as LogLevel) + : undefined; +} + +function noMatchedEventsMessage(level: LogLevel): string { + return `No events matched the provided filters (--level ${level}).\n`; +} + function getIssueIdentifier(event: LoggedEvent): string { return typeof event.issueIdentifier === "string" ? event.issueIdentifier : ""; }