Skip to content

Commit b5db38f

Browse files
feat(miner): expose per-repo run-state as a read-only MCP tool (#5363)
Add gittensory_miner_get_run_state to the gittensory-miner MCP server (scaffold #5153): pass repoFullName for one repo's state (idle/discovering/planning/preparing; null = none recorded yet) or omit it to list all repos, reading via run-state.js's existing getRunState/listRunStates. The read-only analog of ORB's gittensory_get_automation_state -- adds no state-set/mutation. Opens the store only when invoked and closes any it opened; the opener is injectable for tests. Closes #5160 Co-authored-by: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com>
1 parent dc94169 commit b5db38f

4 files changed

Lines changed: 108 additions & 7 deletions

File tree

packages/gittensory-miner/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,9 @@ It exposes these read-only tools:
141141
- `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.
142142
- `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.
143143

144-
Further AMS-state-reading tools (status/doctor diagnostics, run-state, governor ledgers) land as follow-up PRs on top of this server.
144+
- `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.
145+
146+
Further AMS-state-reading tools (status/doctor diagnostics, governor ledger, plan store) land as follow-up PRs on top of this server.
145147

146148
## Version check
147149

packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,20 @@ export interface MinerMcpServerOptions {
2222
nowMs?: number;
2323
/** Override the event-ledger opener (defaults to initEventLedger); injection seam for tests. */
2424
initEventLedger?: () => EventLedger;
25+
/**
26+
* Override the run-state store opener (defaults to the real on-disk store); injection seam for tests. Typed to
27+
* the minimal read surface the run-state tool uses (never setRunState).
28+
*/
29+
initRunStateStore?: () => {
30+
getRunState(repoFullName: string): unknown;
31+
listRunStates(): unknown[];
32+
close(): void;
33+
};
2534
}
2635

2736
/**
2837
* Build the miner MCP server with its tools registered (gittensory_miner_ping,
29-
* gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_get_audit_feed).
30-
* `options` supplies test injection seams; production callers pass nothing.
38+
* gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_get_audit_feed,
39+
* gittensory_miner_get_run_state). `options` supplies test injection seams; production callers pass nothing.
3140
*/
3241
export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer;

packages/gittensory-miner/bin/gittensory-miner-mcp.js

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { initEventLedger } from "../lib/event-ledger.js";
1313
import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js";
1414
import { initPortfolioQueueStore } from "../lib/portfolio-queue.js";
15+
import { initRunStateStore } from "../lib/run-state.js";
1516

1617
// MCP stdio server for @jsonbored/gittensory-miner (scaffold #5153). Mirrors the packages/gittensory-mcp
1718
// harness (MCP SDK server + stdio transport). Tools:
@@ -22,7 +23,9 @@ import { initPortfolioQueueStore } from "../lib/portfolio-queue.js";
2223
// filter passed through to listClaims); exposes no claim/release mutation.
2324
// - gittensory_miner_get_audit_feed (#5158): read-only metadata-only event-ledger audit feed via
2425
// collectEventLedgerAuditFeed() (same filters as `ledger list`; never returns payload_json).
25-
// Remaining AMS-state-reading tools (status/doctor, run-state, governor ledgers, etc.) land as follow-ups.
26+
// - gittensory_miner_get_run_state (#5160): read-only per-repo run-state via run-state.js's getRunState/
27+
// listRunStates (read-only analog of ORB's gittensory_get_automation_state; no state-set mutation).
28+
// Remaining AMS-state-reading tools (status/doctor, governor ledger, plan store, etc.) land as follow-ups.
2629

2730
// Read the version from this package's own package.json (always shipped) rather than a hand-synced
2831
// 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" }
4043

4144
/**
4245
* Build the miner MCP server with its tools registered. `options.initPortfolioQueue`, `options.openClaimLedger`,
43-
* `options.initEventLedger`, and `options.nowMs` are injection seams for tests (default to the real stores and the
44-
* wall clock); the ping tool needs none. Each store-backed tool opens its store only when invoked and closes any
45-
* store it opened.
46+
* `options.initEventLedger`, `options.initRunStateStore`, and `options.nowMs` are injection seams for tests
47+
* (default to the real stores and the wall clock); the ping tool needs none. Each store-backed tool opens its
48+
* store only when invoked and closes any store it opened.
4649
*/
4750
export function createMinerMcpServer(options = {}) {
4851
const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version });
@@ -135,6 +138,31 @@ export function createMinerMcpServer(options = {}) {
135138
}
136139
},
137140
);
141+
server.registerTool(
142+
"gittensory_miner_get_run_state",
143+
{
144+
description:
145+
"Read-only per-repo miner run-state (idle/discovering/planning/preparing). Pass repoFullName for a single " +
146+
"repo (a null state means none has been recorded for it yet), or omit it to list every repo's state. The " +
147+
"read-only analog of ORB's gittensory_get_automation_state; adds no state-set or mutation capability.",
148+
inputSchema: {
149+
repoFullName: z.string().min(1).optional(),
150+
},
151+
},
152+
async ({ repoFullName }) => {
153+
const ownsStore = options.initRunStateStore === undefined;
154+
const store = (options.initRunStateStore ?? initRunStateStore)();
155+
try {
156+
const result =
157+
repoFullName === undefined
158+
? { states: store.listRunStates() }
159+
: { repoFullName, state: store.getRunState(repoFullName) };
160+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
161+
} finally {
162+
if (ownsStore) store.close();
163+
}
164+
},
165+
);
138166
return server;
139167
}
140168

test/unit/miner-mcp-scaffold.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe("gittensory-miner MCP server (#5153 scaffold)", () => {
9393
expect(tools.map((tool) => tool.name).sort()).toEqual([
9494
"gittensory_miner_get_audit_feed",
9595
"gittensory_miner_get_portfolio_dashboard",
96+
"gittensory_miner_get_run_state",
9697
"gittensory_miner_list_claims",
9798
"gittensory_miner_ping",
9899
]);
@@ -200,3 +201,64 @@ describe("gittensory_miner_list_claims (#5156)", () => {
200201
}
201202
});
202203
});
204+
205+
const RUN_STATE_ROWS = [
206+
{ repoFullName: "acme/api", state: "discovering" },
207+
{ repoFullName: "acme/web", state: "idle" },
208+
];
209+
210+
// Fake run-state store that records calls and throws from the mutator, so a test can assert the read tool
211+
// reaches only getRunState/listRunStates and never triggers a state transition.
212+
function fakeRunStateStore(rows: Array<{ repoFullName: string; state: string }>) {
213+
const calls: string[] = [];
214+
return {
215+
calls,
216+
getRunState(repoFullName: string): string | null {
217+
calls.push("getRunState");
218+
return rows.find((row) => row.repoFullName === repoFullName)?.state ?? null;
219+
},
220+
listRunStates(): Array<{ repoFullName: string; state: string }> {
221+
calls.push("listRunStates");
222+
return rows;
223+
},
224+
setRunState(): never {
225+
calls.push("setRunState");
226+
throw new Error("setRunState must not be reachable via the read tool");
227+
},
228+
close(): void {
229+
calls.push("close");
230+
},
231+
};
232+
}
233+
234+
describe("gittensory_miner_get_run_state (#5160)", () => {
235+
function runStateClient(store: ReturnType<typeof fakeRunStateStore>): Promise<Client> {
236+
return connectedClient({ initRunStateStore: () => store });
237+
}
238+
async function callRunState(client: Client, args: Record<string, unknown> = {}): Promise<unknown> {
239+
const result = (await client.callTool({ name: "gittensory_miner_get_run_state", arguments: args })) as Content;
240+
return JSON.parse(toolText(result));
241+
}
242+
243+
it("returns a single repo's state when repoFullName is given", async () => {
244+
const out = await callRunState(await runStateClient(fakeRunStateStore(RUN_STATE_ROWS)), { repoFullName: "acme/api" });
245+
expect(out).toEqual({ repoFullName: "acme/api", state: "discovering" });
246+
});
247+
248+
it("returns a null state for an unknown / no-state-yet repo without throwing", async () => {
249+
const out = await callRunState(await runStateClient(fakeRunStateStore(RUN_STATE_ROWS)), { repoFullName: "acme/nope" });
250+
expect(out).toEqual({ repoFullName: "acme/nope", state: null });
251+
});
252+
253+
it("lists every repo's state when repoFullName is omitted", async () => {
254+
const out = await callRunState(await runStateClient(fakeRunStateStore(RUN_STATE_ROWS)));
255+
expect(out).toEqual({ states: RUN_STATE_ROWS });
256+
});
257+
258+
it("only reads — never triggers a state transition (invariant: no setRunState)", async () => {
259+
const store = fakeRunStateStore(RUN_STATE_ROWS);
260+
await callRunState(await runStateClient(store), { repoFullName: "acme/api" });
261+
expect(store.calls).toEqual(["getRunState"]);
262+
expect(store.calls).not.toContain("setRunState");
263+
});
264+
});

0 commit comments

Comments
 (0)