Skip to content
Merged
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
13 changes: 10 additions & 3 deletions packages/loopover-miner/lib/portfolio-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// any future consumer beyond this CLI's own text/JSON output.

import { initPortfolioQueueStore } from "./portfolio-queue.js";
import { argsWantJson, reportCliFailure } from "./cli-error.js";
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";

const QUEUE_STATUS_KEYS = ["queued", "in_progress", "done"] as const;
type QueueStatusKey = (typeof QUEUE_STATUS_KEYS)[number];
Expand Down Expand Up @@ -149,16 +149,23 @@ export function runPortfolioDashboard(
if ("error" in parsed) {
return reportCliFailure(argsWantJson(args), parsed.error);
}
// Open the store INSIDE the try so an opener failure (a corrupt portfolio-queue.sqlite3, an unreadable state
// dir, an invalid LOOPOVER_MINER_PORTFOLIO_QUEUE_DB) returns 2 like every sibling `queue` subcommand instead of
// escaping runQueueCli with a raw stack trace; the finally guards the close with `?.` since the initializer may
// have thrown before assigning (mirrors runQueueClaimBatch, portfolio-queue-cli.ts:520-541).
const ownsQueue = options.initPortfolioQueue === undefined;
const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)();
let portfolioQueue: { listQueue(repoFullName: string | null): unknown[]; close(): void } | undefined;
try {
portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)();
const summary = collectPortfolioDashboard(
{ portfolioQueue },
{ nowMs: Number.isFinite(options.nowMs) ? (options.nowMs as number) : Date.now() },
);
console.log(parsed.json ? JSON.stringify(summary, null, 2) : renderPortfolioDashboardTable(summary));
return 0;
} catch (error) {
return reportCliFailure(parsed.json, describeCliError(error));
} finally {
if (ownsQueue) portfolioQueue.close();
if (ownsQueue) portfolioQueue?.close();
}
}
61 changes: 61 additions & 0 deletions test/unit/miner-portfolio-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,64 @@ describe("runPortfolioDashboard (#4287)", () => {
expect(String(log.mock.calls[0]?.[0])).toContain("portfolio queue is empty");
});
});

describe("runPortfolioDashboard store-failure handling (#9690)", () => {
it("REGRESSION: an opener failure returns 2 instead of escaping runQueueCli (was: threw)", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const boom = () => {
throw new Error("invalid_portfolio_queue_db_path");
};
// Before the fix the store was opened OUTSIDE the try, so this threw straight out of runQueueCli.
expect(runPortfolioDashboard([], { initPortfolioQueue: boom })).toBe(2);
expect(error).toHaveBeenCalledWith("invalid_portfolio_queue_db_path");
});

it("REGRESSION: an opener failure under --json emits { ok: false, error } on stdout and returns 2", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const boom = () => {
throw new Error("invalid_portfolio_queue_db_path");
};
expect(runPortfolioDashboard(["--json"], { initPortfolioQueue: boom })).toBe(2);
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
ok: false,
error: "invalid_portfolio_queue_db_path",
});
expect(error).not.toHaveBeenCalled();
});

it("REGRESSION: a store whose listQueue throws mid-run still closes the owned store and returns 2", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const store = {
listQueue: () => {
throw new Error("db_read_failed");
},
close: vi.fn(),
};
// No injected initPortfolioQueue → ownsQueue=true → the default factory is used and must be closed in `finally`.
const pqModule = await import("../../packages/loopover-miner/lib/portfolio-queue");
const initSpy = vi.spyOn(pqModule, "initPortfolioQueueStore").mockReturnValue(store as never);
try {
expect(runPortfolioDashboard([])).toBe(2);
expect(error).toHaveBeenCalledWith("db_read_failed");
expect(store.close).toHaveBeenCalledTimes(1);
} finally {
initSpy.mockRestore();
}
});

it("REGRESSION: an owned-store opener failure returns 2 with nothing to close (`?.` guard)", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
// ownsQueue=true AND the default factory throws before assigning → the `?.` in finally must short-circuit.
const pqModule = await import("../../packages/loopover-miner/lib/portfolio-queue");
const initSpy = vi.spyOn(pqModule, "initPortfolioQueueStore").mockImplementation(() => {
throw new Error("invalid_portfolio_queue_db_path");
});
try {
expect(runPortfolioDashboard([])).toBe(2);
expect(error).toHaveBeenCalledWith("invalid_portfolio_queue_db_path");
} finally {
initSpy.mockRestore();
}
});
});