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
4 changes: 3 additions & 1 deletion packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 11 additions & 2 deletions packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
36 changes: 32 additions & 4 deletions packages/gittensory-miner/bin/gittensory-miner-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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 });
Expand Down Expand Up @@ -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;
}

Expand Down
62 changes: 62 additions & 0 deletions test/unit/miner-mcp-scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
Expand Down Expand Up @@ -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<typeof fakeRunStateStore>): Promise<Client> {
return connectedClient({ initRunStateStore: () => store });
}
async function callRunState(client: Client, args: Record<string, unknown> = {}): Promise<unknown> {
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");
});
});