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
188 changes: 188 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down Expand Up @@ -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": [
Expand Down
22 changes: 20 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 <id> | reject <id> | pause | resume | set-level <action> <level> | precision | onboarding-pack | audit-feed.`,
`Unknown maintain subcommand: ${subcommand}. Use status | queue | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state.`,
);
}

Expand Down
13 changes: 13 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
30 changes: 5 additions & 25 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -4026,31 +4027,10 @@ export class LoopoverMcp {
private async getAutomationState(input: { owner: string; repo: string }): Promise<ToolPayload> {
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
Expand Down
31 changes: 31 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
15 changes: 15 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
RepoSyncStateSchema,
RepoSettingsPreviewSchema,
RepositorySchema,
AutomationStateSchema,
RepositorySettingsSchema,
RoleContextSchema,
RewardRiskActionSchema,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading