diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index a03ced60c7..f0c4f1f070 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -141,7 +141,9 @@ It exposes these read-only tools: - `gittensory_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. - `gittensory_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 `gittensory-miner ledger list` (`--repo`, `--since`, `--type`). Never returns `payload_json` or other raw ledger columns. -Further AMS-state-reading tools (status/doctor diagnostics, run-state, governor ledgers) land as follow-up PRs on top of this server. +- `gittensory_miner_get_run_state` (#5160) — read-only per-repo run-state (`idle` / `discovering` / `planning` / `preparing`) via `getRunState` / `listRunStates`. Pass `repoFullName` for one repo (a null state means none recorded yet), or omit it to list all. The read-only analog of ORB's `gittensory_get_automation_state`; adds no state-set mutation. + +Further AMS-state-reading tools (status/doctor diagnostics, governor ledger, plan store) land as follow-up PRs on top of this server. ## Version check diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts index 9bb05a2f04..6a13d66004 100644 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts @@ -22,11 +22,20 @@ export interface MinerMcpServerOptions { nowMs?: number; /** Override the event-ledger opener (defaults to initEventLedger); injection seam for tests. */ initEventLedger?: () => EventLedger; + /** + * Override the run-state store opener (defaults to the real on-disk store); injection seam for tests. Typed to + * the minimal read surface the run-state tool uses (never setRunState). + */ + initRunStateStore?: () => { + getRunState(repoFullName: string): unknown; + listRunStates(): unknown[]; + close(): void; + }; } /** * Build the miner MCP server with its tools registered (gittensory_miner_ping, - * gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_get_audit_feed). - * `options` supplies test injection seams; production callers pass nothing. + * gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_get_audit_feed, + * gittensory_miner_get_run_state). `options` supplies test injection seams; production callers pass nothing. */ export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer; diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.js b/packages/gittensory-miner/bin/gittensory-miner-mcp.js index 8fce54d1c9..c5a1eeb456 100755 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.js +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.js @@ -12,6 +12,7 @@ import { import { initEventLedger } from "../lib/event-ledger.js"; import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js"; import { initPortfolioQueueStore } from "../lib/portfolio-queue.js"; +import { initRunStateStore } from "../lib/run-state.js"; // MCP stdio server for @jsonbored/gittensory-miner (scaffold #5153). Mirrors the packages/gittensory-mcp // harness (MCP SDK server + stdio transport). Tools: @@ -22,7 +23,9 @@ import { initPortfolioQueueStore } from "../lib/portfolio-queue.js"; // filter passed through to listClaims); exposes no claim/release mutation. // - gittensory_miner_get_audit_feed (#5158): read-only metadata-only event-ledger audit feed via // collectEventLedgerAuditFeed() (same filters as `ledger list`; never returns payload_json). -// Remaining AMS-state-reading tools (status/doctor, run-state, governor ledgers, etc.) land as follow-ups. +// - gittensory_miner_get_run_state (#5160): read-only per-repo run-state via run-state.js's getRunState/ +// listRunStates (read-only analog of ORB's gittensory_get_automation_state; no state-set mutation). +// Remaining AMS-state-reading tools (status/doctor, governor ledger, plan store, etc.) land as follow-ups. // Read the version from this package's own package.json (always shipped) rather than a hand-synced // literal, so a release bump never has a second place to forget -- same approach as the mcp harness. @@ -40,9 +43,9 @@ export const MINER_PING_STATUS = { status: "ok", tool: "gittensory_miner_ping" } /** * Build the miner MCP server with its tools registered. `options.initPortfolioQueue`, `options.openClaimLedger`, - * `options.initEventLedger`, and `options.nowMs` are injection seams for tests (default to the real stores and the - * wall clock); the ping tool needs none. Each store-backed tool opens its store only when invoked and closes any - * store it opened. + * `options.initEventLedger`, `options.initRunStateStore`, and `options.nowMs` are injection seams for tests + * (default to the real stores and the wall clock); the ping tool needs none. Each store-backed tool opens its + * store only when invoked and closes any store it opened. */ export function createMinerMcpServer(options = {}) { const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version }); @@ -135,6 +138,31 @@ export function createMinerMcpServer(options = {}) { } }, ); + server.registerTool( + "gittensory_miner_get_run_state", + { + description: + "Read-only per-repo miner run-state (idle/discovering/planning/preparing). Pass repoFullName for a single " + + "repo (a null state means none has been recorded for it yet), or omit it to list every repo's state. The " + + "read-only analog of ORB's gittensory_get_automation_state; adds no state-set or mutation capability.", + inputSchema: { + repoFullName: z.string().min(1).optional(), + }, + }, + async ({ repoFullName }) => { + const ownsStore = options.initRunStateStore === undefined; + const store = (options.initRunStateStore ?? initRunStateStore)(); + try { + const result = + repoFullName === undefined + ? { states: store.listRunStates() } + : { repoFullName, state: store.getRunState(repoFullName) }; + return { content: [{ type: "text", text: JSON.stringify(result) }] }; + } finally { + if (ownsStore) store.close(); + } + }, + ); return server; } diff --git a/test/unit/miner-mcp-scaffold.test.ts b/test/unit/miner-mcp-scaffold.test.ts index 87b26ac86e..73d969dd56 100644 --- a/test/unit/miner-mcp-scaffold.test.ts +++ b/test/unit/miner-mcp-scaffold.test.ts @@ -93,6 +93,7 @@ describe("gittensory-miner MCP server (#5153 scaffold)", () => { expect(tools.map((tool) => tool.name).sort()).toEqual([ "gittensory_miner_get_audit_feed", "gittensory_miner_get_portfolio_dashboard", + "gittensory_miner_get_run_state", "gittensory_miner_list_claims", "gittensory_miner_ping", ]); @@ -200,3 +201,64 @@ describe("gittensory_miner_list_claims (#5156)", () => { } }); }); + +const RUN_STATE_ROWS = [ + { repoFullName: "acme/api", state: "discovering" }, + { repoFullName: "acme/web", state: "idle" }, +]; + +// Fake run-state store that records calls and throws from the mutator, so a test can assert the read tool +// reaches only getRunState/listRunStates and never triggers a state transition. +function fakeRunStateStore(rows: Array<{ repoFullName: string; state: string }>) { + const calls: string[] = []; + return { + calls, + getRunState(repoFullName: string): string | null { + calls.push("getRunState"); + return rows.find((row) => row.repoFullName === repoFullName)?.state ?? null; + }, + listRunStates(): Array<{ repoFullName: string; state: string }> { + calls.push("listRunStates"); + return rows; + }, + setRunState(): never { + calls.push("setRunState"); + throw new Error("setRunState must not be reachable via the read tool"); + }, + close(): void { + calls.push("close"); + }, + }; +} + +describe("gittensory_miner_get_run_state (#5160)", () => { + function runStateClient(store: ReturnType): Promise { + return connectedClient({ initRunStateStore: () => store }); + } + async function callRunState(client: Client, args: Record = {}): Promise { + const result = (await client.callTool({ name: "gittensory_miner_get_run_state", arguments: args })) as Content; + return JSON.parse(toolText(result)); + } + + it("returns a single repo's state when repoFullName is given", async () => { + const out = await callRunState(await runStateClient(fakeRunStateStore(RUN_STATE_ROWS)), { repoFullName: "acme/api" }); + expect(out).toEqual({ repoFullName: "acme/api", state: "discovering" }); + }); + + it("returns a null state for an unknown / no-state-yet repo without throwing", async () => { + const out = await callRunState(await runStateClient(fakeRunStateStore(RUN_STATE_ROWS)), { repoFullName: "acme/nope" }); + expect(out).toEqual({ repoFullName: "acme/nope", state: null }); + }); + + it("lists every repo's state when repoFullName is omitted", async () => { + const out = await callRunState(await runStateClient(fakeRunStateStore(RUN_STATE_ROWS))); + expect(out).toEqual({ states: RUN_STATE_ROWS }); + }); + + it("only reads — never triggers a state transition (invariant: no setRunState)", async () => { + const store = fakeRunStateStore(RUN_STATE_ROWS); + await callRunState(await runStateClient(store), { repoFullName: "acme/api" }); + expect(store.calls).toEqual(["getRunState"]); + expect(store.calls).not.toContain("setRunState"); + }); +});