|
| 1 | +// The AMS miner's management surface (#9523). |
| 2 | +// |
| 3 | +// The miner MCP exposed 11 read-only tools while its entire MUTATING ops surface was CLI-only. These add |
| 4 | +// the mutating family, plus the two reads the catalog called for. |
| 5 | +// |
| 6 | +// Every mutating tool dispatches through the miner's existing governor-gated chat-action chokepoint |
| 7 | +// (packages/loopover-miner/lib/chat-action-registry.ts), which structurally refuses any handler not produced |
| 8 | +// by `governorGatedHandler()`. That is the same boundary the dashboard's own actions go through, so an MCP |
| 9 | +// caller cannot reach a write path the dashboard could not -- and the structural rule is enforced by the |
| 10 | +// registry's own brand, not by review discipline. |
| 11 | +// |
| 12 | +// locality "miner": these run inside the AMS bin against its local stores. availability "selfhost" for the |
| 13 | +// same reason the ORB config-admin tools are: the stores are on that host's disk. |
| 14 | +// |
| 15 | +// DELIBERATELY ABSENT, recorded here so the omissions are visible rather than forgotten: |
| 16 | +// * calibration apply-min-rank / revert-min-rank -- stays CLI-only behind its existing double gate. An |
| 17 | +// agent-reachable path to loosen calibration floors is not wanted. |
| 18 | +// * run-state `set` -- raw run-state mutation is a repair tool, not an operation. |
| 19 | +// * kill-switch trip/untrip -- one-way breaker semantics stay out of agent reach; pause/resume is the |
| 20 | +// agent-safe control and is here instead. |
| 21 | +import { z } from "zod"; |
| 22 | +import { defineTool } from "../tool-definition.js"; |
| 23 | +import { INSTANCE_CHECK_STATUSES } from "../enums.js"; |
| 24 | + |
| 25 | +const RepoFullName = z.string().min(3).max(200).describe("owner/repo."); |
| 26 | + |
| 27 | +/** Mutating tools take this so an omitted field cannot read as false and proceed. */ |
| 28 | +const DestructiveConfirm = z.literal(true).describe("Must be exactly true. Confirms an irreversible action."); |
| 29 | + |
| 30 | +export const MinerDoctorInput = z.object({}); |
| 31 | + |
| 32 | +export const MinerDoctorOutput = z.looseObject({ |
| 33 | + ok: z.boolean().describe("True when no check reported fail. Warnings do not clear it to false."), |
| 34 | + checks: z.array(z.looseObject({ name: z.string(), status: z.enum(INSTANCE_CHECK_STATUSES), detail: z.string().optional() })), |
| 35 | +}); |
| 36 | + |
| 37 | +export const minerDoctorTool = defineTool({ |
| 38 | + name: "loopover_miner_doctor", |
| 39 | + title: "Diagnose this miner", |
| 40 | + description: |
| 41 | + "Read-only diagnostic checks for this AMS miner: state directory, engine version match, store reachability, credentials, and configuration. Split out of loopover_miner_status (#9523) so status stays cheap and doctor can grow checks. Every check runs and reports its own pass/warn/fail — nothing is mutated and nothing stops at the first failure.", |
| 42 | + category: "ops", |
| 43 | + auth: "token", |
| 44 | + locality: "miner", |
| 45 | + availability: "selfhost", |
| 46 | + input: MinerDoctorInput, |
| 47 | + output: MinerDoctorOutput, |
| 48 | +}); |
| 49 | + |
| 50 | +export const MinerMetricsSnapshotInput = z.object({}); |
| 51 | + |
| 52 | +export const MinerMetricsSnapshotOutput = z.looseObject({ |
| 53 | + generatedAt: z.string(), |
| 54 | + families: z.array( |
| 55 | + z.looseObject({ |
| 56 | + name: z.string(), |
| 57 | + type: z.string(), |
| 58 | + help: z.string().optional(), |
| 59 | + samples: z.array(z.looseObject({ value: z.number(), labels: z.record(z.string(), z.string()).optional() })), |
| 60 | + }), |
| 61 | + ), |
| 62 | +}); |
| 63 | + |
| 64 | +export const minerMetricsSnapshotTool = defineTool({ |
| 65 | + name: "loopover_miner_get_metrics_snapshot", |
| 66 | + title: "Read the miner's metrics snapshot", |
| 67 | + description: |
| 68 | + "The same Prometheus metric families the `metrics` CLI exports, as structured JSON — so an agent can read them without parsing the text exposition format. Read-only.", |
| 69 | + category: "ops", |
| 70 | + auth: "token", |
| 71 | + locality: "miner", |
| 72 | + availability: "selfhost", |
| 73 | + input: MinerMetricsSnapshotInput, |
| 74 | + output: MinerMetricsSnapshotOutput, |
| 75 | +}); |
| 76 | + |
| 77 | +/** |
| 78 | + * Shared output for every governor-gated mutation: what happened, and what the chokepoint decided. A refusal |
| 79 | + * is reported as `blocked` with a `reason` rather than thrown -- the governor saying no is an ANSWER the |
| 80 | + * caller needs to see, and a thrown error would flatten it into a generic tool failure. |
| 81 | + */ |
| 82 | +export const MinerGovernorActionOutput = z.looseObject({ |
| 83 | + ok: z.boolean().optional(), |
| 84 | + action: z.string().optional(), |
| 85 | + declined: z.boolean().optional().describe("True when the caller declined an elicited confirmation."), |
| 86 | + blocked: z.boolean().optional().describe("True when the governor chokepoint refused the action."), |
| 87 | + reason: z.string().optional(), |
| 88 | + result: z.unknown().optional(), |
| 89 | + error: z.string().optional(), |
| 90 | +}); |
| 91 | + |
| 92 | +export const MinerGovernorPauseInput = z.object({ |
| 93 | + reason: z.string().max(500).optional().describe("Recorded on the pause so the audit feed says why."), |
| 94 | +}); |
| 95 | + |
| 96 | +export const minerGovernorPauseTool = defineTool({ |
| 97 | + name: "loopover_miner_governor_pause", |
| 98 | + title: "Pause the miner governor", |
| 99 | + description: |
| 100 | + "Pause this miner's governor: no new work is admitted until it resumes. Administrative control, not a content write — the same action the dashboard's pause button dispatches, through the same governor-gated chokepoint, firing the same notification side-channel. Recorded in the event ledger with source=mcp.", |
| 101 | + category: "ops", |
| 102 | + auth: "token", |
| 103 | + locality: "miner", |
| 104 | + availability: "selfhost", |
| 105 | + annotations: { readOnlyHint: false, destructiveHint: false }, |
| 106 | + input: MinerGovernorPauseInput, |
| 107 | + output: MinerGovernorActionOutput, |
| 108 | +}); |
| 109 | + |
| 110 | +export const MinerGovernorResumeInput = z.object({}); |
| 111 | + |
| 112 | +export const minerGovernorResumeTool = defineTool({ |
| 113 | + name: "loopover_miner_governor_resume", |
| 114 | + title: "Resume the miner governor", |
| 115 | + description: |
| 116 | + "Resume this miner's governor after a pause, re-admitting work. The same action the dashboard's resume button dispatches, through the same governor-gated chokepoint. Recorded in the event ledger with source=mcp.", |
| 117 | + category: "ops", |
| 118 | + auth: "token", |
| 119 | + locality: "miner", |
| 120 | + availability: "selfhost", |
| 121 | + annotations: { readOnlyHint: false, destructiveHint: false }, |
| 122 | + input: MinerGovernorResumeInput, |
| 123 | + output: MinerGovernorActionOutput, |
| 124 | +}); |
| 125 | + |
| 126 | +const QueueItemTarget = z.object({ |
| 127 | + repoFullName: RepoFullName, |
| 128 | + issueNumber: z.number().int().positive(), |
| 129 | +}); |
| 130 | + |
| 131 | +export const MinerQueueReleaseInput = QueueItemTarget; |
| 132 | + |
| 133 | +export const minerQueueReleaseTool = defineTool({ |
| 134 | + name: "loopover_miner_queue_release", |
| 135 | + title: "Release a portfolio queue item", |
| 136 | + description: |
| 137 | + "Release one claimed portfolio-queue item back to unclaimed, so another cycle can pick it up. Mirrors the dashboard's release action and dispatches through the same governor-gated chokepoint. Recorded in the event ledger with source=mcp.", |
| 138 | + category: "ops", |
| 139 | + auth: "token", |
| 140 | + locality: "miner", |
| 141 | + availability: "selfhost", |
| 142 | + annotations: { readOnlyHint: false, destructiveHint: false }, |
| 143 | + input: MinerQueueReleaseInput, |
| 144 | + output: MinerGovernorActionOutput, |
| 145 | +}); |
| 146 | + |
| 147 | +export const MinerQueueRequeueInput = QueueItemTarget; |
| 148 | + |
| 149 | +export const minerQueueRequeueTool = defineTool({ |
| 150 | + name: "loopover_miner_queue_requeue", |
| 151 | + title: "Requeue a portfolio queue item", |
| 152 | + description: |
| 153 | + "Return one portfolio-queue item to the pending pool for another attempt. Mirrors the dashboard's requeue action and dispatches through the same governor-gated chokepoint. Recorded in the event ledger with source=mcp.", |
| 154 | + category: "ops", |
| 155 | + auth: "token", |
| 156 | + locality: "miner", |
| 157 | + availability: "selfhost", |
| 158 | + annotations: { readOnlyHint: false, destructiveHint: false }, |
| 159 | + input: MinerQueueRequeueInput, |
| 160 | + output: MinerGovernorActionOutput, |
| 161 | +}); |
| 162 | + |
| 163 | +export const MinerClaimReleaseInput = QueueItemTarget; |
| 164 | + |
| 165 | +export const minerClaimReleaseTool = defineTool({ |
| 166 | + name: "loopover_miner_claim_release", |
| 167 | + title: "Release a claim", |
| 168 | + description: |
| 169 | + "Release this miner's claim on one issue, so the claim ledger no longer reserves it. Dispatches through the governor-gated chokepoint and is recorded in the event ledger with source=mcp.", |
| 170 | + category: "ops", |
| 171 | + auth: "token", |
| 172 | + locality: "miner", |
| 173 | + availability: "selfhost", |
| 174 | + annotations: { readOnlyHint: false, destructiveHint: false }, |
| 175 | + input: MinerClaimReleaseInput, |
| 176 | + output: MinerGovernorActionOutput, |
| 177 | +}); |
| 178 | + |
| 179 | +export const MinerDenyHooksDecideInput = z.object({ |
| 180 | + // The store keys proposals by (repo, proposalId), so the repo is required rather than guessed -- a |
| 181 | + // proposal id alone would force a scan across every repo's proposals to resolve one. |
| 182 | + repoFullName: RepoFullName, |
| 183 | + hookId: z.string().min(1).max(200).describe("The proposal id, from the deny-hook proposals list."), |
| 184 | + decision: z.enum(["approve", "reject"]), |
| 185 | +}); |
| 186 | + |
| 187 | +export const minerDenyHooksDecideTool = defineTool({ |
| 188 | + name: "loopover_miner_deny_hooks_decide", |
| 189 | + title: "Decide a pending deny-hook", |
| 190 | + description: |
| 191 | + "Approve or reject one synthesized deny-hook awaiting review. Approving puts it into force for future runs; rejecting discards it. Dispatches through the governor-gated chokepoint and is recorded in the event ledger with source=mcp.", |
| 192 | + category: "ops", |
| 193 | + auth: "token", |
| 194 | + locality: "miner", |
| 195 | + availability: "selfhost", |
| 196 | + annotations: { readOnlyHint: false, destructiveHint: false }, |
| 197 | + input: MinerDenyHooksDecideInput, |
| 198 | + output: MinerGovernorActionOutput, |
| 199 | +}); |
| 200 | + |
| 201 | +/** |
| 202 | + * No dry-run flag, deliberately: the miner's `migrate` CLI has none either. Applying a migration IS opening |
| 203 | + * the store, so a "preview" would have to be a second implementation of the migration walk -- and a preview |
| 204 | + * that drifts from the real thing is worse than no preview. Reported as applied/up-to-date per store. |
| 205 | + */ |
| 206 | +export const MinerRunMigrationsInput = z.object({}); |
| 207 | + |
| 208 | +/** |
| 209 | + * Every field the handler returns is DECLARED here rather than left to `looseObject`'s catchall: the miner |
| 210 | + * server registers output schemas as `.shape`, which the MCP SDK re-wraps in a plain `z.object` -- dropping |
| 211 | + * the catchall and rejecting any undeclared key with -32602. Same reason the sibling outputs are explicit. |
| 212 | + */ |
| 213 | +export const MinerRunMigrationsOutput = z.looseObject({ |
| 214 | + ok: z.boolean().optional(), |
| 215 | + action: z.string().optional(), |
| 216 | + result: z.unknown().optional(), |
| 217 | + blocked: z.boolean().optional(), |
| 218 | + reason: z.string().optional(), |
| 219 | + error: z.string().optional(), |
| 220 | +}); |
| 221 | + |
| 222 | +export const minerRunMigrationsTool = defineTool({ |
| 223 | + name: "loopover_miner_run_migrations", |
| 224 | + title: "Run miner store migrations", |
| 225 | + description: |
| 226 | + "Apply pending schema migrations to this miner's EXISTING local stores — it never creates a store that is not already there. Reports each store as migrated, up-to-date, or failed. There is no dry-run mode: applying a migration is opening the store, and the CLI has none either. Dispatches through the governor-gated chokepoint.", |
| 227 | + category: "ops", |
| 228 | + auth: "token", |
| 229 | + locality: "miner", |
| 230 | + availability: "selfhost", |
| 231 | + annotations: { readOnlyHint: false, destructiveHint: false }, |
| 232 | + input: MinerRunMigrationsInput, |
| 233 | + output: MinerRunMigrationsOutput, |
| 234 | +}); |
| 235 | + |
| 236 | +export const MinerPurgeRepoInput = z.object({ |
| 237 | + repoFullName: RepoFullName, |
| 238 | + confirm: DestructiveConfirm, |
| 239 | +}); |
| 240 | + |
| 241 | +/** Same `.shape` re-wrap constraint as above: declare everything the handler returns. */ |
| 242 | +export const MinerPurgeRepoOutput = z.looseObject({ |
| 243 | + ok: z.boolean().optional(), |
| 244 | + action: z.string().optional(), |
| 245 | + declined: z.boolean().optional(), |
| 246 | + repoFullName: z.string().optional(), |
| 247 | + // The purge summary verbatim from the CLI's own core -- store list, counts, and timestamp. |
| 248 | + result: z.unknown().optional(), |
| 249 | + blocked: z.boolean().optional(), |
| 250 | + reason: z.string().optional(), |
| 251 | + error: z.string().optional(), |
| 252 | +}); |
| 253 | + |
| 254 | +export const minerPurgeRepoTool = defineTool({ |
| 255 | + name: "loopover_miner_purge_repo", |
| 256 | + title: "Purge a repo from every miner store", |
| 257 | + description: |
| 258 | + "Right-to-be-forgotten: delete every trace of one repo from this miner's local stores, returning the same per-store report as the CLI. IRREVERSIBLE — the rows are gone, not archived. Requires confirm=true, elicits confirmation where the client supports it, and dispatches through the governor-gated chokepoint.", |
| 259 | + category: "ops", |
| 260 | + auth: "token", |
| 261 | + locality: "miner", |
| 262 | + availability: "selfhost", |
| 263 | + annotations: { readOnlyHint: false, destructiveHint: true }, |
| 264 | + input: MinerPurgeRepoInput, |
| 265 | + output: MinerPurgeRepoOutput, |
| 266 | +}); |
| 267 | + |
| 268 | +export const MINER_OPS_TOOLS = [ |
| 269 | + minerDoctorTool, |
| 270 | + minerMetricsSnapshotTool, |
| 271 | + minerGovernorPauseTool, |
| 272 | + minerGovernorResumeTool, |
| 273 | + minerQueueReleaseTool, |
| 274 | + minerQueueRequeueTool, |
| 275 | + minerClaimReleaseTool, |
| 276 | + minerDenyHooksDecideTool, |
| 277 | + minerRunMigrationsTool, |
| 278 | + minerPurgeRepoTool, |
| 279 | +] as const; |
0 commit comments