diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 6a58578b28..22eef52822 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14154,6 +14154,151 @@ "defaultProfile", "analyzers" ] + }, + "AutomationState": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "configured": { + "type": "boolean" + }, + "autonomy": { + "type": "object", + "properties": { + "review": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "request_changes": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "approve": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "merge": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "close": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "label": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "review_state_label": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "update_branch": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "assign": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + } + } + }, + "autoMaintain": { + "type": "boolean", + "nullable": true + }, + "agentPaused": { + "type": "boolean" + }, + "agentDryRun": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "paused", + "dry_run", + "live" + ] + }, + "permissionReadiness": { + "type": "string", + "enum": [ + "not_required", + "ready", + "reconsent_required" + ] + }, + "actingActionClasses": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "review", + "request_changes", + "approve", + "merge", + "close", + "label", + "review_state_label", + "update_branch", + "assign" + ] + } + }, + "pendingActionCount": { + "type": "number" + } + }, + "required": [ + "repoFullName", + "configured", + "autonomy", + "agentPaused", + "agentDryRun", + "mode", + "permissionReadiness", + "actingActionClasses", + "pendingActionCount" + ] } }, "parameters": {}, @@ -18311,6 +18456,49 @@ } ] } + }, + "/v1/repos/{owner}/{repo}/automation-state": { + "get": { + "summary": "Derived agent automation state for a repository", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Maintainer-only derived automation view (mode, permission readiness, acting action classes, pending-approval count) that the raw /settings row does not include", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationState" + } + } + } + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 7ad75fa45f..b6862e13cd 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -100,7 +100,7 @@ const CLI_COMMAND_SPEC = { profile: ["list", "create", "switch", "remove"], cache: ["status", "clear", "list"], agent: ["plan", "status", "explain", "packet"], - maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed"], + maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state"], }; const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"]; const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"]; @@ -3051,6 +3051,7 @@ function printMaintainHelp() { " audit-feed [--since ISO] Show the agent audit feed (who did what, when).", " [--limit N] Cap the events returned (1-200).", " [--pull N] Scope the feed to one pull request.", + " automation-state Show the derived agent automation state (mode, readiness, pending).", "", "Pass --json for machine-readable output.", ].join("\n") + "\n", @@ -3220,8 +3221,25 @@ async function maintainCli(args) { ); return; } + if (subcommand === "automation-state") { + // #6742: read-side counterpart to the write-side pause/resume/set-level above. Mirrors GET {repoBase}/ + // automation-state (and the loopover_get_automation_state MCP tool) — the DERIVED mode/permissionReadiness/ + // acting-classes/pending-count view the raw settings row omits. Read-only; the API enforces maintainer auth. + const payload = await apiGet(`${repoBase}/automation-state`); + const acting = payload.actingActionClasses ?? []; + emit( + payload, + [ + `Agent automation for ${repoFullName}: mode=${payload.mode}, ${acting.length} acting class(es), ${payload.pendingActionCount ?? 0} pending approval(s).`, + ` permission readiness: ${payload.permissionReadiness}`, + ` auto-maintain: ${payload.autoMaintain ?? "unset"}${payload.agentDryRun ? " (dry-run)" : ""}`, + acting.length > 0 ? ` acting classes: ${acting.join(", ")}` : " acting classes: none", + ].join("\n"), + ); + return; + } throw new Error( - `Unknown maintain subcommand: ${subcommand}. Use status | queue | approve | reject | pause | resume | set-level | precision | onboarding-pack | audit-feed.`, + `Unknown maintain subcommand: ${subcommand}. Use status | queue | approve | reject | pause | resume | set-level | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state.`, ); } diff --git a/src/api/routes.ts b/src/api/routes.ts index 1f02055a5a..d4ecc04cac 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -278,6 +278,7 @@ import { computeContributorCalibration } from "../review/predicted-gate-calibrat import { buildFocusManifestValidation } from "../services/focus-manifest-validation"; import { buildMaintainerActivationPreview } from "../services/maintainer-activation"; import { buildRepoOutcomeCalibration } from "../services/outcome-calibration"; +import { buildAutomationState } from "../services/automation-state"; import { loadGatePrecisionReport } from "../services/gate-precision"; import { computeOpsStats, isOpsEnabled, resolveOpsManifestOverride } from "../review/ops-wire"; import { deleteLiveOverride, listOverrideAudit, loadOverride, loadShadowOverride, sanitizeOverridePayload, authoritativeGateOverride, toLiveGateThresholdFields, type StorageEnv } from "../review/auto-apply"; @@ -2672,6 +2673,18 @@ export function createApp() { return c.json(await resolveRepositorySettings(c.env, fullName)); }); + // #6742 read-side automation state: the DERIVED view (mode / permissionReadiness / pendingActionCount / + // acting classes) that /settings deliberately does not return -- symmetric with the write-side PUT /settings + // and the CLI's maintain pause/resume/set-level. Maintainer-gated like /settings; shares buildAutomationState + // with the loopover_get_automation_state MCP tool so the two surfaces cannot drift. + app.get("/v1/repos/:owner/:repo/automation-state", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoMaintainer(c, fullName); + /* v8 ignore next -- unauthorized requests are rejected by the auth middleware before reaching the handler. */ + if (gate instanceof Response) return gate; + return c.json(await buildAutomationState(c.env, fullName)); + }); + // #130 maintainer settings editor: PATCH-style save of the gate / slop / label / surface / command-auth // settings. Write-access gated + audited because these repo-visible settings include agent autonomy // controls. upsertRepositorySettings defaults any absent field, so we merge the sent keys onto the diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5310504255..217c8aff08 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -77,6 +77,7 @@ import { recordProductUsageEvent, } from "../db/repositories"; import { decidePendingAgentAction } from "../services/agent-approval-queue"; +import { automationStateSummary, buildAutomationState } from "../services/automation-state"; import { nowIso } from "../utils/json"; import { buildNotificationFeed } from "../notifications/service"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; @@ -4026,31 +4027,10 @@ export class LoopoverMcp { private async getAutomationState(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoAccess(fullName); - const [repo, settings, pendingActionCount] = await Promise.all([ - getRepository(this.env, fullName), - resolveRepositorySettings(this.env, fullName), - countPendingAgentActions(this.env, { repoFullName: fullName, status: "pending" }), - ]); - const autonomy = settings.autonomy; - const actingActionClasses = AGENT_ACTION_CLASSES.filter((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); - const installation = repo?.installationId ? await getInstallation(this.env, repo.installationId) : null; - const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env) || (await isGlobalAgentFrozen(this.env)), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); - const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null }); - return { - summary: `Agent automation for ${fullName}: mode=${mode}, ${actingActionClasses.length} acting class(es), ${pendingActionCount} pending approval(s).`, - data: { - repoFullName: fullName, - configured: actingActionClasses.length > 0, - autonomy, - autoMaintain: settings.autoMaintain, - agentPaused: settings.agentPaused === true, - agentDryRun: settings.agentDryRun === true, - mode, - permissionReadiness, - actingActionClasses, - pendingActionCount, - }, - }; + // Shared with GET /v1/repos/:owner/:repo/automation-state (#6742) so the two surfaces cannot drift. + const state = await buildAutomationState(this.env, fullName); + // Spread into a plain object: ToolPayload.data is a Record, and a typed interface has no implicit index sig. + return { summary: automationStateSummary(state), data: { ...state } }; } // #6087 — pause/resume: the write-side kill-switch counterpart to loopover_get_automation_state's read-only diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 7bc3c98478..031a265ed3 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -879,6 +879,37 @@ export const RepositorySettingsSchema = z }) .openapi("RepositorySettings"); +// #6742: the derived automation view returned by GET /v1/repos/:owner/:repo/automation-state, matching +// buildAutomationState's AutomationState shape. Distinct from RepositorySettings: these are computed fields +// (mode/permissionReadiness/pendingActionCount/acting classes), not the stored settings row. +const AGENT_ACTION_CLASS_VALUES = [ + "review", + "request_changes", + "approve", + "merge", + "close", + "label", + "review_state_label", + "update_branch", + "assign", +] as const; +const AUTONOMY_LEVEL_VALUES = ["observe", "auto_with_approval", "auto"] as const; + +export const AutomationStateSchema = z + .object({ + repoFullName: z.string(), + configured: z.boolean(), + autonomy: z.record(z.enum(AGENT_ACTION_CLASS_VALUES), z.enum(AUTONOMY_LEVEL_VALUES)), + autoMaintain: z.boolean().nullable().optional(), + agentPaused: z.boolean(), + agentDryRun: z.boolean(), + mode: z.enum(["paused", "dry_run", "live"]), + permissionReadiness: z.enum(["not_required", "ready", "reconsent_required"]), + actingActionClasses: z.array(z.enum(AGENT_ACTION_CLASS_VALUES)), + pendingActionCount: z.number(), + }) + .openapi("AutomationState"); + export const RepoSettingsPreviewSchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 047cd6552e..bc30b70414 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -70,6 +70,7 @@ import { RepoSyncStateSchema, RepoSettingsPreviewSchema, RepositorySchema, + AutomationStateSchema, RepositorySettingsSchema, RoleContextSchema, RewardRiskActionSchema, @@ -129,6 +130,7 @@ export function buildOpenApiSpec() { registry.register("BountyAdvisory", BountyAdvisorySchema); registry.register("BountyLifecycleEvents", BountyLifecycleEventsSchema); registry.register("RepositorySettings", RepositorySettingsSchema); + registry.register("AutomationState", AutomationStateSchema); registry.register("InstallationRepair", InstallationRepairSchema); registry.register("RepoSettingsPreview", RepoSettingsPreviewSchema); registry.register("SkippedPrAuditExport", SkippedPrAuditExportSchema); @@ -688,6 +690,19 @@ export function buildOpenApiSpec() { 200: { description: "LoopOver repository automation settings", content: { "application/json": { schema: RepositorySettingsSchema } } }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/automation-state", + summary: "Derived agent automation state for a repository", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { + description: + "Maintainer-only derived automation view (mode, permission readiness, acting action classes, pending-approval count) that the raw /settings row does not include", + content: { "application/json": { schema: AutomationStateSchema } }, + }, + }, + }); registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/settings-preview", diff --git a/src/services/automation-state.ts b/src/services/automation-state.ts new file mode 100644 index 0000000000..a258c7bc8d --- /dev/null +++ b/src/services/automation-state.ts @@ -0,0 +1,65 @@ +// Shared derived-automation-state view (#6742). Extracted from the MCP server's `getAutomationState` so the +// REST route (GET /v1/repos/:owner/:repo/automation-state), the MCP tool (loopover_get_automation_state), and +// the CLI (`maintain automation-state`) all compute it ONE way -- the derived `mode` / `permissionReadiness` / +// `pendingActionCount` view that `GET /settings` deliberately does not return (settings returns only the +// resolved row). Keeping this in one function is what stops the three surfaces from drifting. +import { countPendingAgentActions, getInstallation, getRepository, isGlobalAgentFrozen } from "../db/repositories"; +import { resolveRepositorySettings } from "../settings/repository-settings"; +import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution"; +import { AGENT_ACTION_CLASSES, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy"; + +/** The derived automation-state view. Every field is the same one `getAutomationState` returned inline. */ +export interface AutomationState { + repoFullName: string; + configured: boolean; + autonomy: Awaited>["autonomy"]; + autoMaintain: Awaited>["autoMaintain"]; + agentPaused: boolean; + agentDryRun: boolean; + mode: ReturnType; + permissionReadiness: ReturnType; + actingActionClasses: (typeof AGENT_ACTION_CLASSES)[number][]; + pendingActionCount: number; +} + +/** + * Compute the derived automation-state view for a repo. Read-only: reads the repository row, the + * yaml-merged effective settings (resolveRepositorySettings, not the raw DB row), the pending-approval count, + * and the installation's granted permissions, then folds them into the same `mode` / acting-class / + * permission-readiness derivation the MCP tool used inline. Performs no write and no authorization itself — + * every caller gates access before calling (the route via requireRepoMaintainer, the MCP tool via its own + * requireRepoAccess), exactly as before this was extracted. + */ +export async function buildAutomationState(env: Env, repoFullName: string): Promise { + const [repo, settings, pendingActionCount] = await Promise.all([ + getRepository(env, repoFullName), + resolveRepositorySettings(env, repoFullName), + countPendingAgentActions(env, { repoFullName, status: "pending" }), + ]); + const autonomy = settings.autonomy; + const actingActionClasses = AGENT_ACTION_CLASSES.filter((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); + const installation = repo?.installationId ? await getInstallation(env, repo.installationId) : null; + const mode = resolveAgentActionMode({ + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + agentPaused: settings.agentPaused, + agentDryRun: settings.agentDryRun, + }); + const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null }); + return { + repoFullName, + configured: actingActionClasses.length > 0, + autonomy, + autoMaintain: settings.autoMaintain, + agentPaused: settings.agentPaused === true, + agentDryRun: settings.agentDryRun === true, + mode, + permissionReadiness, + actingActionClasses, + pendingActionCount, + }; +} + +/** The one-line human summary the MCP tool emits, kept here so its wording stays paired with the fields. */ +export function automationStateSummary(state: AutomationState): string { + return `Agent automation for ${state.repoFullName}: mode=${state.mode}, ${state.actingActionClasses.length} acting class(es), ${state.pendingActionCount} pending approval(s).`; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 3c9f6c8045..b64a6339d2 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -962,6 +962,24 @@ describe("api routes", () => { cohorts: { miner: { overall: expect.any(Object) }, human: { overall: expect.any(Object) } }, }); + // #6742 derived automation state: maintainer-scoped, read-only. The DERIVED view (mode / permissionReadiness + // / actingActionClasses / pendingActionCount) that GET /settings does not return. + const automationUnauthenticated = await app.request("/v1/repos/entrius/allways-ui/automation-state", {}, env); + expect(automationUnauthenticated.status).toBe(401); + const automationState = await app.request("/v1/repos/entrius/allways-ui/automation-state", { headers: apiHeaders(env) }, env); + expect(automationState.status).toBe(200); + await expect(automationState.json()).resolves.toMatchObject({ + repoFullName: "entrius/allways-ui", + configured: expect.any(Boolean), + autonomy: expect.any(Object), + agentPaused: expect.any(Boolean), + agentDryRun: expect.any(Boolean), + mode: expect.stringMatching(/^(paused|dry_run|live)$/), + permissionReadiness: expect.stringMatching(/^(not_required|ready|reconsent_required)$/), + actingActionClasses: expect.any(Array), + pendingActionCount: expect.any(Number), + }); + const maintainerNoiseUnauthenticated = await app.request("/v1/repos/entrius/allways-ui/maintainer-noise", {}, env); expect(maintainerNoiseUnauthenticated.status).toBe(401); const maintainerNoise = await app.request("/v1/repos/entrius/allways-ui/maintainer-noise", { headers: apiHeaders(env) }, env); diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts index b7ad8e5a3f..22a0e8abf9 100644 --- a/test/unit/mcp-cli-basics.test.ts +++ b/test/unit/mcp-cli-basics.test.ts @@ -221,7 +221,7 @@ describe("loopover-mcp CLI — basics", () => { expect(ps).toContain("[System.Management.Automation.CompletionResult]::new"); expect(ps).toContain("$commands = @('login', 'logout'"); expect(ps).toContain( - "'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed')", + "'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state')", ); }); diff --git a/test/unit/mcp-cli-maintain.test.ts b/test/unit/mcp-cli-maintain.test.ts index 40940ce820..5d8d8e55c0 100644 --- a/test/unit/mcp-cli-maintain.test.ts +++ b/test/unit/mcp-cli-maintain.test.ts @@ -170,6 +170,22 @@ describe("loopover-mcp CLI — maintain (#784)", () => { expect(payload.echoedQuery).toEqual({ since: null, limit: null, pull: null }); }); + it("automation-state shows the derived agent automation view (plain + json), with output parity (#6742)", async () => { + const e = await env(); + const out = await runAsync(["maintain", "automation-state", "--repo", "owner/repo"], e); + expect(out).toMatch(/Agent automation for owner\/repo: mode=live, 2 acting class\(es\), 3 pending approval\(s\)\./); + expect(out).toMatch(/permission readiness: ready/); + expect(out).toMatch(/acting classes: merge, close/); + // Parity: --json re-serializes the API payload untouched, so the derived fields reach both surfaces. + const json = JSON.parse(await runAsync(["maintain", "automation-state", "--repo", "owner/repo", "--json"], e)) as { + repoFullName: string; + mode: string; + permissionReadiness: string; + pendingActionCount: number; + }; + expect(json).toMatchObject({ repoFullName: "owner/repo", mode: "live", permissionReadiness: "ready", pendingActionCount: 3 }); + }); + it("validates inputs: --repo required, id required for approve, known subcommand + action/level", async () => { const e = await env(); await expect(runAsync(["maintain", "status"], e)).rejects.toThrow(/Pass --repo/); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index eaf093f14c..3fcf6a833d 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -509,6 +509,24 @@ export async function startFixtureServer( ); return; } + // #6742 derived automation state (read-only). Returns the derived mode/readiness/acting-classes view. + if (request.url?.startsWith("/v1/repos/owner/repo/automation-state") && request.method === "GET") { + response.end( + JSON.stringify({ + repoFullName: "owner/repo", + configured: true, + autonomy: { merge: "auto", close: "auto_with_approval" }, + autoMaintain: "auto", + agentPaused: false, + agentDryRun: false, + mode: "live", + permissionReadiness: "ready", + actingActionClasses: ["merge", "close"], + pendingActionCount: 3, + }), + ); + return; + } // #554 gate precision telemetry (read-only). Echoes ?windowDays so the CLI window pass-through is testable. if (request.url?.startsWith("/v1/repos/owner/repo/gate-precision") && request.method === "GET") { const windowDays = new URL(request.url, "http://localhost").searchParams.get("windowDays");