diff --git a/.changeset/verbose-error-causes.md b/.changeset/verbose-error-causes.md new file mode 100644 index 00000000..9d038ff1 --- /dev/null +++ b/.changeset/verbose-error-causes.md @@ -0,0 +1,5 @@ +--- +"@gh-symphony/cli": patch +--- + +Fix issue #396 so `-v` / `--verbose` surfaces stack traces and error cause chains for top-level CLI and orchestrator failures, including daemon startup diagnostics. diff --git a/packages/cli/src/commands/__snapshots__/help.test.ts.snap b/packages/cli/src/commands/__snapshots__/help.test.ts.snap index 96e49426..4855fbec 100644 --- a/packages/cli/src/commands/__snapshots__/help.test.ts.snap +++ b/packages/cli/src/commands/__snapshots__/help.test.ts.snap @@ -34,7 +34,7 @@ exports[`help output > renders the colored grouped help snapshot 1`] = ` Global Options: --config  Config directory override (advanced; default uses initialized cwd runtime, then ~/.gh-symphony) - --verbose, -v Verbose output + --verbose, -v Verbose output, including top-level error stack traces --json JSON output --no-color Disable color output --help, -h Show help @@ -76,7 +76,7 @@ Maintenance: Global Options: --config Config directory override (advanced; default uses initialized cwd runtime, then ~/.gh-symphony) - --verbose, -v Verbose output + --verbose, -v Verbose output, including top-level error stack traces --json JSON output --no-color Disable color output --help, -h Show help diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index 528b7417..0ceb28e1 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -131,7 +131,7 @@ export const HELP_SECTIONS: HelpSection[] = [ }, { name: "--verbose, -v", - description: "Verbose output", + description: "Verbose output, including top-level error stack traces", }, { name: "--json", diff --git a/packages/cli/src/commands/start.test.ts b/packages/cli/src/commands/start.test.ts index 85ff8f77..a0fc6bad 100644 --- a/packages/cli/src/commands/start.test.ts +++ b/packages/cli/src/commands/start.test.ts @@ -23,6 +23,16 @@ const ghAuthMocks = vi.hoisted(() => ({ const promptMocks = vi.hoisted(() => ({ confirm: vi.fn(), })); +const childProcessMocks = vi.hoisted(() => ({ + spawn: vi.fn(() => ({ + pid: 2468, + unref: vi.fn(), + })), +})); + +vi.mock("node:child_process", () => ({ + spawn: childProcessMocks.spawn, +})); vi.mock("@gh-symphony/orchestrator", () => ({ acquireProjectLock, @@ -114,6 +124,11 @@ beforeEach(() => { ghAuthMocks.runGhAuthRefresh.mockReset(); promptMocks.confirm.mockReset(); promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockClear(); + childProcessMocks.spawn.mockReturnValue({ + pid: 2468, + unref: vi.fn(), + }); process.env.GITHUB_GRAPHQL_TOKEN = originalGithubToken; process.env.LINEAR_API_KEY = originalLinearApiKey; serviceDependencies.length = 0; @@ -608,6 +623,47 @@ describe("start command foreground locking", () => { expect(exitSpy).toHaveBeenCalledWith(0); }); + it("maps the global verbose option to orchestrator verbose logs", async () => { + const configDir = await createConfigFixture({ + activeProject: "tenant-a", + projects: [createProject("tenant-a", "acme", "platform")], + }); + run.mockResolvedValue(undefined); + + await startModule.default([], { + ...baseOptions(configDir), + verbose: true, + }); + + expect(serviceDependencies.at(-1)).toMatchObject({ + logLevel: "verbose", + }); + }); + + it("passes global verbose through to daemon child diagnostics", async () => { + const configDir = await createConfigFixture({ + activeProject: "tenant-a", + projects: [createProject("tenant-a", "acme", "platform")], + }); + + await startModule.default(["--daemon"], { + ...baseOptions(configDir), + verbose: true, + }); + + expect(childProcessMocks.spawn).toHaveBeenCalledTimes(1); + const childArgs = childProcessMocks.spawn.mock.calls[0]?.[1]; + expect(childArgs).toEqual( + expect.arrayContaining([ + "repo", + "start", + "--verbose", + "--log-level", + "verbose", + ]) + ); + }); + it("tails completed worker logs from the flat runtime run path", async () => { const configDir = await createConfigFixture({ activeProject: "tenant-a", diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 21114cb8..de2c21bb 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -686,10 +686,11 @@ const handler = async ( const runtimeRoot = resolveRuntimeRoot(options.configDir); const projectId = projectConfig.projectId; let logLevel: OrchestratorLogLevel; + const requestedLogLevel = + parsed.logLevel ?? + (options.verbose ? "verbose" : process.env.SYMPHONY_LOG_LEVEL); try { - logLevel = resolveOrchestratorLogLevel( - parsed.logLevel ?? process.env.SYMPHONY_LOG_LEVEL - ); + logLevel = resolveOrchestratorLogLevel(requestedLogLevel); } catch (error) { process.stderr.write( `${error instanceof Error ? error.message : "Unsupported log level"}\n` @@ -709,7 +710,7 @@ const handler = async ( await startDaemon( options, projectId, - parsed.logLevel, + parsed.logLevel ?? (options.verbose ? "verbose" : undefined), parsed.httpPort, parsed.webPort, parsed.assignedOnly === true @@ -1080,6 +1081,7 @@ async function startDaemon( process.argv[1]!, "repo", "start", + ...(options.verbose ? ["--verbose"] : []), ...(assignedOnly ? ["--assigned-only"] : []), ...(httpPort !== undefined ? ["--http", String(httpPort)] : []), ...(webPort !== undefined ? ["--web", String(webPort)] : []), diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 66adc808..1a09754a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,6 @@ import { realpathSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { formatErrorForTerminal, hasVerboseFlag } from "@gh-symphony/core"; import { Command, CommanderError, @@ -89,7 +90,7 @@ function addGlobalOptions(command: Command): Command { return command .option("--config ", "Config directory") .addOption(new Option("--config-dir ").hideHelp()) - .option("-v, --verbose", "Enable verbose output") + .option("-v, --verbose", "Enable verbose output with stack traces") .option("--json", "Output in JSON format") .option("--no-color", "Disable color output"); } @@ -763,7 +764,9 @@ if ( ) { main().catch((error: unknown) => { process.stderr.write( - `${error instanceof Error ? error.message : "Unknown error"}\n` + formatErrorForTerminal(error, { + verbose: hasVerboseFlag(process.argv.slice(2)), + }) ); process.exitCode = 1; }); diff --git a/packages/core/src/observability/error-format.test.ts b/packages/core/src/observability/error-format.test.ts new file mode 100644 index 00000000..8b71c2a8 --- /dev/null +++ b/packages/core/src/observability/error-format.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { formatErrorForTerminal, hasVerboseFlag } from "./error-format.js"; + +describe("terminal error formatting", () => { + it("keeps non-verbose errors to a single message line", () => { + const error = new Error("top-level failure", { + cause: new Error("root cause"), + }); + + expect(formatErrorForTerminal(error)).toBe("top-level failure\n"); + }); + + it("prints stacks and walks causes when verbose", () => { + const cause = new Error("root cause"); + cause.stack = "Error: root cause\n at root"; + const error = new Error("top-level failure", { cause }); + error.stack = "Error: top-level failure\n at top"; + + expect(formatErrorForTerminal(error, { verbose: true })).toBe( + [ + "Error: top-level failure", + " at top", + "Caused by: Error: root cause", + " at root", + "", + ].join("\n") + ); + }); + + it("detects circular cause chains before repeating the top-level error", () => { + const cause = new Error("root cause"); + cause.stack = "Error: root cause\n at root"; + const error = new Error("top-level failure", { cause }); + error.stack = "Error: top-level failure\n at top"; + cause.cause = error; + + expect(formatErrorForTerminal(error, { verbose: true })).toBe( + [ + "Error: top-level failure", + " at top", + "Caused by: Error: root cause", + " at root", + "Caused by: [Circular cause]", + "", + ].join("\n") + ); + }); + + it("detects both verbose flags", () => { + expect(hasVerboseFlag(["repo", "start", "--verbose"])).toBe(true); + expect(hasVerboseFlag(["-v", "repo", "start"])).toBe(true); + expect(hasVerboseFlag(["repo", "start"])).toBe(false); + }); +}); diff --git a/packages/core/src/observability/error-format.ts b/packages/core/src/observability/error-format.ts new file mode 100644 index 00000000..1d820057 --- /dev/null +++ b/packages/core/src/observability/error-format.ts @@ -0,0 +1,50 @@ +export type TerminalErrorFormatOptions = { + verbose?: boolean; +}; + +export function hasVerboseFlag(argv: readonly string[]): boolean { + return argv.some((arg) => arg === "--verbose" || arg === "-v"); +} + +export function formatErrorForTerminal( + error: unknown, + options: TerminalErrorFormatOptions = {} +): string { + if (!options.verbose) { + return `${error instanceof Error ? error.message : "Unknown error"}\n`; + } + + const lines = [formatSingleError(error)]; + const seenCauses = new Set(); + if (typeof error === "object" && error !== null) { + seenCauses.add(error); + } + let cause = resolveCause(error); + + while (cause !== undefined) { + if (typeof cause === "object" && cause !== null) { + if (seenCauses.has(cause)) { + lines.push("Caused by: [Circular cause]"); + break; + } + seenCauses.add(cause); + } + + lines.push(`Caused by: ${formatSingleError(cause)}`); + cause = resolveCause(cause); + } + + return `${lines.join("\n")}\n`; +} + +function formatSingleError(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? error.message; + } + + return String(error); +} + +function resolveCause(error: unknown): unknown { + return error instanceof Error ? error.cause : undefined; +} diff --git a/packages/core/src/observability/index.ts b/packages/core/src/observability/index.ts index a41c6bd0..b3b7e7a5 100644 --- a/packages/core/src/observability/index.ts +++ b/packages/core/src/observability/index.ts @@ -4,3 +4,4 @@ export * from "./fs-reader.js"; export * from "./event-formatter.js"; export * from "./redaction.js"; export * from "./status-assembler.js"; +export * from "./error-format.js"; diff --git a/packages/orchestrator/src/index.test.ts b/packages/orchestrator/src/index.test.ts index c456c03b..675d7a07 100644 --- a/packages/orchestrator/src/index.test.ts +++ b/packages/orchestrator/src/index.test.ts @@ -78,6 +78,37 @@ describe("orchestrator CLI", () => { ); }); + it("treats -v as verbose log level", async () => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "orchestrator-cli-")); + const service = createMockService(); + const createService = vi.fn< + ( + runtimeRoot: string, + projectId?: string, + options?: { + eventsDir?: string; + logLevel: OrchestratorLogLevel; + stderr: Pick; + } + ) => OrchestratorService + >(() => service); + + await runCli( + ["run", "--runtime-root", runtimeRoot, "--project-id", "tenant-1", "-v"], + { + createService, + } + ); + + expect(createService).toHaveBeenCalledWith( + runtimeRoot, + "tenant-1", + expect.objectContaining({ + logLevel: "verbose", + }) + ); + }); + it("passes --events-dir to service creation", async () => { const runtimeRoot = await mkdtemp(join(tmpdir(), "orchestrator-cli-")); const service = createMockService(); diff --git a/packages/orchestrator/src/index.ts b/packages/orchestrator/src/index.ts index 7aefce63..ec536784 100644 --- a/packages/orchestrator/src/index.ts +++ b/packages/orchestrator/src/index.ts @@ -1,5 +1,6 @@ import { pathToFileURL } from "node:url"; import { resolve } from "node:path"; +import { formatErrorForTerminal, hasVerboseFlag } from "@gh-symphony/core"; import { createStore, OrchestratorService, @@ -244,7 +245,7 @@ function parseArgs(args: string[]): { const argument = args[index]; const value = args[index + 1]; - if (!argument?.startsWith("--")) { + if (!argument?.startsWith("-")) { continue; } @@ -276,6 +277,10 @@ function parseArgs(args: string[]): { parsed.logLevel = value; index += 1; break; + case "--verbose": + case "-v": + parsed.logLevel = "verbose"; + break; default: throw new Error(`Unknown option: ${argument}`); } @@ -298,8 +303,20 @@ if ( ) { main().catch((error: unknown) => { process.stderr.write( - `${error instanceof Error ? error.message : "Unknown error"}\n` + formatErrorForTerminal(error, { + verbose: hasVerboseOrchestratorDiagnostics(process.argv.slice(2)), + }) ); process.exitCode = 1; }); } + +function hasVerboseOrchestratorDiagnostics(argv: readonly string[]): boolean { + if (hasVerboseFlag(argv) || process.env.SYMPHONY_LOG_LEVEL === "verbose") { + return true; + } + + return argv.some( + (arg, index) => arg === "--log-level" && argv[index + 1] === "verbose" + ); +}