diff --git a/packages/loopover-miner/README.md b/packages/loopover-miner/README.md index 97e033dc2d..8d2ee00d41 100644 --- a/packages/loopover-miner/README.md +++ b/packages/loopover-miner/README.md @@ -244,6 +244,7 @@ It exposes these read-only tools: - `loopover_miner_ping` (#5153) — a health check returning a static `{ "status": "ok", "tool": "loopover_miner_ping" }` object. Reads no AMS state, takes no arguments. - `loopover_miner_get_portfolio_dashboard` (#5155) — the per-repo portfolio-queue backlog dashboard: status counts (queued / in_progress / done), totals, and the oldest-queued age. Wraps `collectPortfolioDashboard()` (no new logic) — the same data `loopover-miner queue dashboard --json` prints locally. Read-only, takes no arguments. +- `loopover_miner_get_manage_status` (#5822) — read-only manage-phase status: the per-managed-PR rows (branch, CI state, gate verdict, outcome, last-polled-at, queue status/priority) plus the run-level portfolio view (one row per tracked repo: run state, updated-at, PR count). Joins the portfolio queue, the event ledger, and run-state by reusing `collectManageStatus()` / `collectRunPortfolio()` (no new join logic) — the same `{ rows, runPortfolio }` shape `loopover-miner manage status --json` prints. Read-only: never calls GitHub, never mutates local stores; takes no arguments. - `loopover_miner_list_claims` (#5156) — lists the local claim ledger (repo, issue number, status, claimed-at, note) via `listClaims()`. Optional `repoFullName` / `status` filters pass through to the query. Read-only — exposes no claim/release mutation. - `loopover_miner_get_audit_feed` (#5158) — read-only, metadata-only event-ledger audit feed (`eventType`, `repoFullName`, `outcome`, `actor`, `detail`, `createdAt`). Wraps `collectEventLedgerAuditFeed()` with the same filters as `loopover-miner ledger list` (`--repo`, `--since`, `--type`). Never returns `payload_json` or other raw ledger columns. diff --git a/packages/loopover-miner/bin/loopover-miner-mcp.d.ts b/packages/loopover-miner/bin/loopover-miner-mcp.d.ts index 9168980017..74e1b7f117 100644 --- a/packages/loopover-miner/bin/loopover-miner-mcp.d.ts +++ b/packages/loopover-miner/bin/loopover-miner-mcp.d.ts @@ -65,9 +65,10 @@ export interface MinerMcpServerOptions { /** * Build the miner MCP server with its tools registered (loopover_miner_ping, - * loopover_miner_get_portfolio_dashboard, loopover_miner_list_claims, loopover_miner_get_audit_feed, - * loopover_miner_get_run_state, loopover_miner_list_plans, loopover_miner_get_plan, - * loopover_miner_get_governor_decisions, loopover_miner_status, loopover_miner_get_calibration_report). `options` - * supplies test injection seams; production callers pass nothing. + * loopover_miner_get_portfolio_dashboard, loopover_miner_get_manage_status, loopover_miner_list_claims, + * loopover_miner_get_audit_feed, loopover_miner_get_run_state, loopover_miner_list_plans, + * loopover_miner_get_plan, loopover_miner_get_governor_decisions, loopover_miner_status, + * loopover_miner_get_calibration_report). `options` supplies test injection seams; production callers + * pass nothing. */ export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer; diff --git a/packages/loopover-miner/bin/loopover-miner-mcp.js b/packages/loopover-miner/bin/loopover-miner-mcp.js index 4609ccada0..c617db18b3 100755 --- a/packages/loopover-miner/bin/loopover-miner-mcp.js +++ b/packages/loopover-miner/bin/loopover-miner-mcp.js @@ -10,6 +10,7 @@ import { normalizeAuditFeedMcpFilter, } from "../lib/event-ledger-cli.js"; import { initEventLedger } from "../lib/event-ledger.js"; +import { collectManageStatus, collectRunPortfolio } from "../lib/manage-status.js"; import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js"; import { initPortfolioQueueStore } from "../lib/portfolio-queue.js"; import { initRunStateStore } from "../lib/run-state.js"; @@ -25,6 +26,9 @@ import { initPredictionLedger } from "../lib/prediction-ledger.js"; // - loopover_miner_ping (#5153): trivial static health check, reads no AMS state. // - loopover_miner_get_portfolio_dashboard (#5155): read-only per-repo backlog dashboard, wrapping the // existing collectPortfolioDashboard aggregator (no new logic; same data as `queue dashboard --json`). +// - loopover_miner_get_manage_status (#5822): read-only manage-phase status joining the portfolio queue, the +// event ledger, and run-state via manage-status.js's collectManageStatus/collectRunPortfolio (no new join +// logic; same { rows, runPortfolio } shape as `manage status --json`). Never calls GitHub, never mutates. // - loopover_miner_list_claims (#5156): read-only listing of the local claim ledger (optional repo/status // filter passed through to listClaims); exposes no claim/release mutation. // - loopover_miner_get_audit_feed (#5158): read-only metadata-only event-ledger audit feed via @@ -95,6 +99,36 @@ export function createMinerMcpServer(options = {}) { } }, ); + server.registerTool( + "loopover_miner_get_manage_status", + { + description: + "Read-only manage-phase status: the per-managed-PR rows `loopover-miner manage status` reports (branch, CI " + + "state, gate verdict, outcome, last-polled-at, queue status/priority) plus the run-level portfolio view " + + "(one row per tracked repo: run state, updated-at, PR count). Joins the portfolio queue, the append-only " + + "event ledger, and run-state by reusing the existing collectManageStatus/collectRunPortfolio aggregators " + + "-- no new join logic -- returning the same { rows, runPortfolio } shape `manage status --json` prints. " + + "Read-only: never calls GitHub, never mutates local stores. Takes no arguments.", + inputSchema: {}, + }, + async () => { + const ownsPortfolioQueue = options.initPortfolioQueue === undefined; + const ownsEventLedger = options.initEventLedger === undefined; + const ownsRunStateStore = options.initRunStateStore === undefined; + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + const eventLedger = (options.initEventLedger ?? initEventLedger)(); + const runStateStore = (options.initRunStateStore ?? initRunStateStore)(); + try { + const rows = collectManageStatus({ portfolioQueue, eventLedger }); + const runPortfolio = collectRunPortfolio({ portfolioQueue, eventLedger, runStateStore }); + return { content: [{ type: "text", text: JSON.stringify({ rows, runPortfolio }) }] }; + } finally { + if (ownsPortfolioQueue) portfolioQueue.close(); + if (ownsEventLedger) eventLedger.close(); + if (ownsRunStateStore) runStateStore.close(); + } + }, + ); server.registerTool( "loopover_miner_list_claims", { diff --git a/test/unit/miner-mcp-contract.test.ts b/test/unit/miner-mcp-contract.test.ts index c9bbb0939d..9853e2da5b 100644 --- a/test/unit/miner-mcp-contract.test.ts +++ b/test/unit/miner-mcp-contract.test.ts @@ -105,6 +105,21 @@ type ToolContract = { excluded: string[]; }; +// manage-status is the one tool that opens THREE stores at once, so its rows seed all three seams and vary only the +// one under test — an un-stubbed seam would otherwise fall through to a real on-disk store. +const benignEventLedger: MinerMcpServerOptions["initEventLedger"] = () => ({ + dbPath: "", + appendEvent: readThrows, + readEvents: () => [], + purgeByRepo: readThrows, + close() {}, +}); +const benignRunStateStore: MinerMcpServerOptions["initRunStateStore"] = () => ({ + getRunState: () => null, + listRunStates: () => [], + close() {}, +}); + const READ_ONLY_TOOLS: ToolContract[] = [ { tool: "loopover_miner_status", @@ -132,6 +147,26 @@ const READ_ONLY_TOOLS: ToolContract[] = [ corrupt: { initPortfolioQueue: () => ({ listQueue: readThrows, close() {} }) }, excluded: [], }, + { + tool: "loopover_miner_get_manage_status", + args: {}, + valid: { + initPortfolioQueue: () => ({ listQueue: () => [], close() {} }), + initEventLedger: benignEventLedger, + initRunStateStore: benignRunStateStore, + }, + missing: { + initPortfolioQueue: openerThrows, + initEventLedger: benignEventLedger, + initRunStateStore: benignRunStateStore, + }, + corrupt: { + initPortfolioQueue: () => ({ listQueue: readThrows, close() {} }), + initEventLedger: benignEventLedger, + initRunStateStore: benignRunStateStore, + }, + excluded: [], + }, { tool: "loopover_miner_list_claims", args: {}, diff --git a/test/unit/miner-mcp-manage-status.test.ts b/test/unit/miner-mcp-manage-status.test.ts new file mode 100644 index 0000000000..3a7cad61a8 --- /dev/null +++ b/test/unit/miner-mcp-manage-status.test.ts @@ -0,0 +1,179 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMinerMcpServer } from "../../packages/loopover-miner/bin/loopover-miner-mcp.js"; +import { + MANAGE_PR_UPDATE_EVENT, + collectManageStatus, + collectRunPortfolio, +} from "../../packages/loopover-miner/lib/manage-status.js"; +import { + closeDefaultEventLedger, + initEventLedger, +} from "../../packages/loopover-miner/lib/event-ledger.js"; +import { + closeDefaultPortfolioQueueStore, + initPortfolioQueueStore, +} from "../../packages/loopover-miner/lib/portfolio-queue.js"; +import { + closeDefaultRunStateStore, + initRunStateStore, +} from "../../packages/loopover-miner/lib/run-state.js"; + +type Content = { content: Array<{ type: string; text?: string }>; isError?: boolean }; +type Stores = { + portfolioQueue: ReturnType; + eventLedger: ReturnType; + runStateStore: ReturnType; +}; + +const roots: string[] = []; +const stores: Array<{ close(): void }> = []; + +function tempStores(): Stores { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-mcp-manage-status-")); + roots.push(root); + const portfolioQueue = initPortfolioQueueStore(join(root, "portfolio-queue.sqlite3")); + const eventLedger = initEventLedger(join(root, "event-ledger.sqlite3")); + const runStateStore = initRunStateStore(join(root, "run-state.sqlite3")); + stores.push(portfolioQueue, eventLedger, runStateStore); + return { portfolioQueue, eventLedger, runStateStore }; +} + +afterEach(() => { + for (const store of stores.splice(0)) store.close(); + closeDefaultPortfolioQueueStore(); + closeDefaultEventLedger(); + closeDefaultRunStateStore(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +async function connectedClient(sources: Stores): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "miner-mcp-manage-status-test", version: "0.0.0" }); + await Promise.all([ + createMinerMcpServer({ + initPortfolioQueue: () => sources.portfolioQueue, + initEventLedger: () => sources.eventLedger, + initRunStateStore: () => sources.runStateStore, + }).connect(serverTransport), + client.connect(clientTransport), + ]); + return client; +} + +function toolText(result: Content): string { + const first = result.content[0]; + if (!first || first.type !== "text" || typeof first.text !== "string") { + throw new Error("expected a single text content block"); + } + return first.text; +} + +const callManageStatus = async (sources: Stores): Promise => + (await (await connectedClient(sources)).callTool({ + name: "loopover_miner_get_manage_status", + arguments: {}, + })) as Content; + +/** A managed PR in the queue with a manage snapshot, plus a run-state-only repo that has no PRs yet. */ +function seed(sources: Stores) { + sources.portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "pr:12", priority: 3 }); + sources.eventLedger.appendEvent({ + type: MANAGE_PR_UPDATE_EVENT, + repoFullName: "acme/widgets", + payload: { + prNumber: 12, + branch: "feat/widget-cursor", + ciState: "passing", + gateVerdict: "approve", + outcome: "merged", + lastPolledAt: "2026-07-04T12:00:00.000Z", + }, + }); + sources.runStateStore.setRunState("acme/widgets", "preparing"); + sources.runStateStore.setRunState("acme/discovering-only", "discovering"); +} + +describe("loopover_miner_get_manage_status (#5822)", () => { + it("is registered on the miner MCP server", async () => { + const client = await connectedClient(tempStores()); + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toContain("loopover_miner_get_manage_status"); + }); + + it("returns the per-PR rows and the run-portfolio view for a populated managed-PR set", async () => { + const sources = tempStores(); + seed(sources); + const payload = JSON.parse(toolText(await callManageStatus(sources))); + expect(payload.rows).toEqual([ + { + repoFullName: "acme/widgets", + prNumber: 12, + branch: "feat/widget-cursor", + ciState: "passing", + gateVerdict: "approve", + outcome: "merged", + lastPolledAt: "2026-07-04T12:00:00.000Z", + queueStatus: "queued", + priority: 3, + }, + ]); + // The run-state-only repo has no PRs but must still appear (#4279), proving the fold is not PR-scoped. + expect(payload.runPortfolio.map((entry: { repoFullName: string }) => entry.repoFullName)).toEqual([ + "acme/discovering-only", + "acme/widgets", + ]); + expect(payload.runPortfolio).toEqual([ + { repoFullName: "acme/discovering-only", runState: "discovering", runStateUpdatedAt: expect.any(String), prCount: 0, prs: [] }, + { repoFullName: "acme/widgets", runState: "preparing", runStateUpdatedAt: expect.any(String), prCount: 1, prs: payload.rows }, + ]); + }); + + it("returns empty rows and an empty run portfolio when no managed PRs are recorded yet", async () => { + const payload = JSON.parse(toolText(await callManageStatus(tempStores()))); + expect(payload).toEqual({ rows: [], runPortfolio: [] }); + }); + + it("is structurally identical to collectManageStatus/collectRunPortfolio — the wrapper adds no drift (invariant)", async () => { + const sources = tempStores(); + seed(sources); + const payload = JSON.parse(toolText(await callManageStatus(sources))); + expect(payload).toEqual({ + rows: collectManageStatus(sources), + runPortfolio: collectRunPortfolio(sources), + }); + }); + + it("reads without mutating: no enqueue, no appendEvent, no setRunState (invariant)", async () => { + const sources = tempStores(); + seed(sources); + const enqueue = vi.spyOn(sources.portfolioQueue, "enqueue"); + const appendEvent = vi.spyOn(sources.eventLedger, "appendEvent"); + const setRunState = vi.spyOn(sources.runStateStore, "setRunState"); + const listQueue = vi.spyOn(sources.portfolioQueue, "listQueue"); + await callManageStatus(sources); + expect(listQueue).toHaveBeenCalled(); + expect(enqueue).not.toHaveBeenCalled(); + expect(appendEvent).not.toHaveBeenCalled(); + expect(setRunState).not.toHaveBeenCalled(); + }); + + it("leaves injected stores open — it closes only the stores it opened itself (invariant)", async () => { + const sources = tempStores(); + seed(sources); + const closes = [ + vi.spyOn(sources.portfolioQueue, "close"), + vi.spyOn(sources.eventLedger, "close"), + vi.spyOn(sources.runStateStore, "close"), + ]; + await callManageStatus(sources); + for (const close of closes) expect(close).not.toHaveBeenCalled(); + // Still usable afterward — a closed sqlite handle would throw here. + expect(() => sources.portfolioQueue.listQueue(null)).not.toThrow(); + }); +}); diff --git a/test/unit/miner-mcp-scaffold.test.ts b/test/unit/miner-mcp-scaffold.test.ts index b3584dc218..a107dc6beb 100644 --- a/test/unit/miner-mcp-scaffold.test.ts +++ b/test/unit/miner-mcp-scaffold.test.ts @@ -94,6 +94,7 @@ describe("loopover-miner MCP server (#5153 scaffold)", () => { "loopover_miner_get_audit_feed", "loopover_miner_get_calibration_report", "loopover_miner_get_governor_decisions", + "loopover_miner_get_manage_status", "loopover_miner_get_plan", "loopover_miner_get_portfolio_dashboard", "loopover_miner_get_run_state",