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
21 changes: 21 additions & 0 deletions migrations/0045_agent_pending_actions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Agent-layer approval queue (#779, Wave 2 Phase 1). When an action's autonomy level is `auto_with_approval`,
-- the maintainer write-actions layer (#778) STAGES it here instead of executing. The maintainer accepts (→
-- execute) or rejects (→ cancel) it in one tap, and that decision feeds the trust loop. At most one row per
-- (repo, pull, action_class): re-evaluation never duplicates a staged action, and a decided row is sticky.
CREATE TABLE IF NOT EXISTS agent_pending_actions (
id TEXT PRIMARY KEY,
repo_full_name TEXT NOT NULL,
pull_number INTEGER NOT NULL,
installation_id INTEGER NOT NULL,
action_class TEXT NOT NULL,
autonomy_level TEXT NOT NULL,
params_json TEXT NOT NULL DEFAULT '{}',
reason TEXT,
status TEXT NOT NULL DEFAULT 'pending',
decided_by TEXT,
decided_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS agent_pending_actions_target_unique ON agent_pending_actions (repo_full_name, pull_number, action_class);
CREATE INDEX IF NOT EXISTS agent_pending_actions_repo_status_idx ON agent_pending_actions (repo_full_name, status, created_at);
31 changes: 31 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
getRepository,
getRepoQueueTrendSnapshot,
getRepositorySettings,
getPendingAgentAction,
listPendingAgentActions,
recordAuditEvent,
getContributorEvidence,
getProductUsageRollupStatus,
Expand Down Expand Up @@ -132,6 +134,7 @@ import {
startAgentRun,
} from "../services/agent-orchestrator";
import { buildRemediationPlan } from "../services/remediation-plan";
import { decidePendingAgentAction } from "../services/agent-approval-queue";
import { explainScoreBreakdown } from "../services/score-breakdown";
import { buildMcpClientTelemetry } from "../services/client-telemetry";
import {
Expand Down Expand Up @@ -2016,6 +2019,34 @@ export function createApp() {
return c.json(updated);
});

// #779 approval queue: the auto_with_approval actions the agent staged on this repo, awaiting a maintainer
// decision. Maintainer-scoped + per-repo.
app.get("/v1/repos/:owner/:repo/agent/pending-actions", 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;
const pending = await listPendingAgentActions(c.env, { repoFullName: fullName, status: "pending" });
return c.json({ repoFullName: fullName, pendingActions: pending });
});

// #779 one-tap decision: accept → execute the staged action live; reject → cancel. Both feed the trust loop.
app.post("/v1/repos/:owner/:repo/agent/pending-actions/:id/:decision", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const decision = c.req.param("decision");
if (decision !== "accept" && decision !== "reject") return c.json({ error: "invalid_decision", detail: "decision must be 'accept' or 'reject'" }, 400);
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;
const pending = await getPendingAgentAction(c.env, c.req.param("id"));
// Scope the action to THIS repo so a maintainer cannot decide another repo's queue via a guessed id.
if (!pending || pending.repoFullName !== fullName) return c.json({ error: "pending_action_not_found" }, 404);
const decidedBy = gate.identity?.kind === "session" ? gate.identity.actor : "maintainer";
const result = await decidePendingAgentAction(c.env, { id: pending.id, decision, decidedBy });
if (result.status === "already_decided") return c.json({ error: "already_decided", action: result.action }, 409);
return c.json(result);
});

// Maintainer activation demo (#701): a repo-specific "here's what Gittensory would have surfaced" preview
// over recent PRs, plus a one-click advisory ramp. Maintainer-scoped + per-repo. Deterministic (no AI run).
app.get("/v1/repos/:owner/:repo/activation-preview", async (c) => {
Expand Down
89 changes: 89 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
contributorScoringProfiles,
contributors,
digestSubscriptions,
agentPendingActions,
gateOutcomes,
githubAgentCommandAnswers,
githubAgentCommandFeedback,
Expand Down Expand Up @@ -72,6 +73,11 @@ import type {
AgentRecommendationOutcomeState,
AgentRecommendationOutcomeSummary,
AgentRecommendationOutcomeTargetType,
AgentActionClass,
AgentPendingActionParams,
AgentPendingActionRecord,
AgentPendingActionStatus,
AutonomyLevel,
GateOutcomeRecord,
AgentMode,
AgentRunRecord,
Expand Down Expand Up @@ -3277,6 +3283,89 @@ export async function listGateOutcomes(
return rows.map(toGateOutcomeRecord);
}

// #779 approval queue. Stage an auto_with_approval action; `created:false` when one is already staged for this
// (repo, pull, action_class) — re-evaluation never duplicates a staged action or re-surfaces a decided one.
export async function createPendingAgentActionIfAbsent(
env: Env,
input: { repoFullName: string; pullNumber: number; installationId: number; actionClass: AgentActionClass; autonomyLevel: AutonomyLevel; params: AgentPendingActionParams; reason?: string | null | undefined },
): Promise<{ action: AgentPendingActionRecord; created: boolean }> {
const repoFullName = boundedString(input.repoFullName, 200);
const values = {
id: crypto.randomUUID(),
repoFullName,
pullNumber: input.pullNumber,
installationId: input.installationId,
actionClass: input.actionClass,
autonomyLevel: input.autonomyLevel,
paramsJson: jsonString(input.params),
reason: input.reason ?? null,
status: "pending",
};
const inserted = await getDb(env.DB)
.insert(agentPendingActions)
.values(values)
.onConflictDoNothing({ target: [agentPendingActions.repoFullName, agentPendingActions.pullNumber, agentPendingActions.actionClass] })
.returning();
if (inserted.length > 0 && inserted[0]) return { action: toAgentPendingActionRecord(inserted[0]), created: true };
// A row already exists for this target — return it unchanged (the staged/decided action is sticky).
const [existing] = await getDb(env.DB)
.select()
.from(agentPendingActions)
.where(and(eq(agentPendingActions.repoFullName, repoFullName), eq(agentPendingActions.pullNumber, input.pullNumber), eq(agentPendingActions.actionClass, input.actionClass)))
.limit(1);
/* v8 ignore next -- onConflictDoNothing only no-ops when a conflicting row exists, so the lookup always finds it. */
if (!existing) throw new Error(`pending action conflict had no row: ${repoFullName}#${input.pullNumber} ${input.actionClass}`);
return { action: toAgentPendingActionRecord(existing), created: false };
}

export async function listPendingAgentActions(
env: Env,
options: { repoFullName?: string; status?: AgentPendingActionStatus; limit?: number } = {},
): Promise<AgentPendingActionRecord[]> {
const limit = clampInteger(options.limit ?? 200, 1, 2000);
const conditions = [];
if (options.repoFullName) conditions.push(eq(agentPendingActions.repoFullName, options.repoFullName));
if (options.status) conditions.push(eq(agentPendingActions.status, options.status));
const rows = await getDb(env.DB)
.select()
.from(agentPendingActions)
.where(conditions.length === 0 ? undefined : and(...conditions))
.orderBy(desc(agentPendingActions.createdAt), agentPendingActions.id)
.limit(limit);
return rows.map(toAgentPendingActionRecord);
}

export async function getPendingAgentAction(env: Env, id: string): Promise<AgentPendingActionRecord | null> {
const [row] = await getDb(env.DB).select().from(agentPendingActions).where(eq(agentPendingActions.id, id)).limit(1);
return row ? toAgentPendingActionRecord(row) : null;
}

/** Mark a staged action accepted/rejected. Idempotency is the caller's concern (it checks status === pending). */
export async function setPendingAgentActionStatus(env: Env, id: string, update: { status: AgentPendingActionStatus; decidedBy: string | null }): Promise<void> {
await getDb(env.DB)
.update(agentPendingActions)
.set({ status: update.status, decidedBy: update.decidedBy, decidedAt: nowIso(), updatedAt: nowIso() })
.where(eq(agentPendingActions.id, id));
}

function toAgentPendingActionRecord(row: typeof agentPendingActions.$inferSelect): AgentPendingActionRecord {
return {
id: row.id,
repoFullName: row.repoFullName,
pullNumber: row.pullNumber,
installationId: row.installationId,
actionClass: row.actionClass as AgentActionClass,
autonomyLevel: row.autonomyLevel as AutonomyLevel,
params: parseJson<AgentPendingActionParams>(row.paramsJson, {}),
reason: row.reason,
status: row.status as AgentPendingActionStatus,
decidedBy: row.decidedBy,
decidedAt: row.decidedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}

export async function getAgentRecommendationOutcomeSummary(
env: Env,
actorLogin: string,
Expand Down
27 changes: 27 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,33 @@ export const gateOutcomes = sqliteTable(
}),
);

// Agent-layer approval queue (#779). An `auto_with_approval` action the write-actions layer (#778) staged for
// a one-tap maintainer accept/reject. At most one row per (repo, pull, action_class).
export const agentPendingActions = sqliteTable(
"agent_pending_actions",
{
id: text("id").primaryKey(),
repoFullName: text("repo_full_name").notNull(),
pullNumber: integer("pull_number").notNull(),
installationId: integer("installation_id").notNull(),
actionClass: text("action_class").notNull(),
autonomyLevel: text("autonomy_level").notNull(),
// JSON of the action payload (label / reviewBody / mergeMethod / closeComment) needed to execute on accept.
paramsJson: text("params_json").notNull().default("{}"),
reason: text("reason"),
// pending → accepted | rejected. A decided row is sticky (re-evaluation never re-stages it).
status: text("status").notNull().default("pending"),
decidedBy: text("decided_by"),
decidedAt: text("decided_at"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
},
(table) => ({
target: uniqueIndex("agent_pending_actions_target_unique").on(table.repoFullName, table.pullNumber, table.actionClass),
repoStatus: index("agent_pending_actions_repo_status_idx").on(table.repoFullName, table.status, table.createdAt),
}),
);

export const installationHealth = sqliteTable("installation_health", {
installationId: integer("installation_id").primaryKey(),
accountLogin: text("account_login").notNull(),
Expand Down
52 changes: 49 additions & 3 deletions src/services/agent-action-executor.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { recordAuditEvent } from "../db/repositories";
import { createPendingAgentActionIfAbsent, insertNotificationDeliveryIfAbsent, recordAuditEvent } from "../db/repositories";
import { ensurePullRequestLabel } from "../github/labels";
import { closePullRequest, createIssueComment, createPullRequestReview, mergePullRequest } from "../github/pr-actions";
import { resolveAutonomy } from "../settings/autonomy";
import { buildAgentActionAudit, isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution";
import type { PlannedAgentAction } from "../settings/agent-actions";
import type { AgentActionClass, AutonomyPolicy } from "../types";
import type { AgentActionClass, AgentPendingActionParams, AutonomyLevel, AutonomyPolicy } from "../types";
import { errorMessage } from "../utils/json";

// The agent actor name on every audit record — the App acts on the maintainer's behalf per their configured
Expand Down Expand Up @@ -60,8 +60,10 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE
await audit("denied", "agent actions paused");
continue;
}
// 2) auto_with_approval stages the action for a maintainer instead of executing it (#779 owns the queue).
// 2) auto_with_approval stages the action in the approval queue (#779) for a one-tap maintainer decision
// instead of executing it now.
if (action.requiresApproval) {
await stageForApproval(env, ctx, action, autonomyLevel);
await audit("queued", `awaiting maintainer approval — ${action.reason}`);
continue;
}
Expand Down Expand Up @@ -107,3 +109,47 @@ async function performAction(env: Env, ctx: AgentActionExecutionContext, action:
return;
}
}

/** The execute-time payload of a planned action, persisted so the approval queue (#779) can run it on accept. */
export function actionParams(action: PlannedAgentAction): AgentPendingActionParams {
return {
...(action.label !== undefined ? { label: action.label } : {}),
...(action.reviewBody !== undefined ? { reviewBody: action.reviewBody } : {}),
...(action.mergeMethod !== undefined ? { mergeMethod: action.mergeMethod } : {}),
...(action.closeComment !== undefined ? { closeComment: action.closeComment } : {}),
};
}

/** Rebuild a PlannedAgentAction from a persisted approval-queue row so the executor can run it on accept. The
* rebuilt action is `requiresApproval: false` — the maintainer's accept IS the approval. */
export function pendingActionToPlanned(input: { actionClass: AgentActionClass; params: AgentPendingActionParams; reason?: string | null | undefined }): PlannedAgentAction {
return { actionClass: input.actionClass, requiresApproval: false, reason: input.reason ?? "maintainer-approved", ...input.params };
}

// Persist the staged action + notify the maintainer ONCE (on first staging, not on every re-evaluation).
async function stageForApproval(env: Env, ctx: AgentActionExecutionContext, action: PlannedAgentAction, autonomyLevel: AutonomyLevel): Promise<void> {
const { created } = await createPendingAgentActionIfAbsent(env, {
repoFullName: ctx.repoFullName,
pullNumber: ctx.pullNumber,
installationId: ctx.installationId,
actionClass: action.actionClass,
autonomyLevel,
params: actionParams(action),
reason: action.reason,
});
if (!created) return;
/* v8 ignore next -- a repo full name always has an owner segment; the empty fallback is purely defensive. */
const recipientLogin = ctx.repoFullName.split("/")[0] ?? "";
await insertNotificationDeliveryIfAbsent(env, {
dedupKey: `agent.pending_action:${ctx.repoFullName}#${ctx.pullNumber}:${action.actionClass}`,
channel: "badge",
recipientLogin,
eventType: "agent.pending_action",
repoFullName: ctx.repoFullName,
pullNumber: ctx.pullNumber,
title: `Gittensory staged a ${action.actionClass.replace(/_/g, " ")} for your approval`,
body: `${action.reason}. Accept to execute it, or reject to cancel.`,
deeplink: `https://github.com/${ctx.repoFullName}/pull/${ctx.pullNumber}`,
actorLogin: AGENT_ACTOR,
});
}
65 changes: 65 additions & 0 deletions src/services/agent-approval-queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { getInstallation, getPullRequest, getRepositorySettings, getPendingAgentAction, recordAuditEvent, setPendingAgentActionStatus } from "../db/repositories";
import { executeAgentMaintenanceActions, pendingActionToPlanned } from "./agent-action-executor";
import type { AgentPendingActionRecord } from "../types";

export type ApprovalDecision = "accept" | "reject";

export type ApprovalDecisionResult = {
status: "accepted" | "rejected" | "already_decided" | "not_found";
action?: AgentPendingActionRecord;
// For an accept, the executor outcome of running the staged action (completed / denied / error / dry_run).
executionOutcome?: string;
};

/**
* Decide a staged approval-queue action (#779). Accept → run the action live (the maintainer's accept IS the
* approval, so the executor's approval gate is bypassed; the kill-switch is still honored). Reject → cancel.
* Either decision marks the row decided (idempotent: a second decision is a no-op) and records an audit event
* that feeds the trust loop.
*/
export async function decidePendingAgentAction(env: Env, input: { id: string; decision: ApprovalDecision; decidedBy: string }): Promise<ApprovalDecisionResult> {
const pending = await getPendingAgentAction(env, input.id);
if (!pending) return { status: "not_found" };
if (pending.status !== "pending") return { status: "already_decided", action: pending };
const targetKey = `${pending.repoFullName}#${pending.pullNumber}`;
const baseMetadata = { pendingId: pending.id, repoFullName: pending.repoFullName, pullNumber: pending.pullNumber, actionClass: pending.actionClass, autonomyLevel: pending.autonomyLevel };

if (input.decision === "reject") {
await setPendingAgentActionStatus(env, pending.id, { status: "rejected", decidedBy: input.decidedBy });
await recordAuditEvent(env, { eventType: "agent.pending_action.rejected", actor: input.decidedBy, targetKey, outcome: "completed", detail: `rejected ${pending.actionClass}`, metadata: baseMetadata });
return { status: "rejected", action: { ...pending, status: "rejected", decidedBy: input.decidedBy } };
}

// accept → execute the staged action live, then record the result.
const [settings, pr, installation] = await Promise.all([
getRepositorySettings(env, pending.repoFullName),
getPullRequest(env, pending.repoFullName, pending.pullNumber),
getInstallation(env, pending.installationId),
]);
const outcomes = await executeAgentMaintenanceActions(
env,
{
installationId: pending.installationId,
repoFullName: pending.repoFullName,
pullNumber: pending.pullNumber,
headSha: pr?.headSha,
autonomy: settings.autonomy,
agentPaused: settings.agentPaused,
agentDryRun: false, // an explicit accept always runs live (the kill-switch still wins inside the executor)
installationPermissions: installation ? installation.permissions : null,
},
[pendingActionToPlanned({ actionClass: pending.actionClass, params: pending.params, reason: pending.reason })],
);
/* v8 ignore next -- the executor returns one outcome per planned action, so the fallback is defensive. */
const execOutcome = outcomes[0]?.outcome ?? "no_outcome";
await setPendingAgentActionStatus(env, pending.id, { status: "accepted", decidedBy: input.decidedBy });
await recordAuditEvent(env, {
eventType: "agent.pending_action.accepted",
actor: input.decidedBy,
targetKey,
outcome: execOutcome === "completed" ? "completed" : "error",
detail: `accepted ${pending.actionClass} → ${execOutcome}`,
metadata: { ...baseMetadata, executionOutcome: execOutcome },
});
return { status: "accepted", action: { ...pending, status: "accepted", decidedBy: input.decidedBy }, executionOutcome: execOutcome };
}
29 changes: 29 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,35 @@ export type AutoMaintainPolicy = {
mergeMethod: AutoMergeMethod;
};

/** The payload needed to execute a staged action when a maintainer accepts it (#779). Only the field for the
* action's class is set, mirroring PlannedAgentAction. */
export type AgentPendingActionParams = {
label?: string;
reviewBody?: string;
mergeMethod?: AutoMergeMethod;
closeComment?: string;
};

export type AgentPendingActionStatus = "pending" | "accepted" | "rejected";

/** Approval-queue row (#779): an `auto_with_approval` action the write-actions layer staged for a one-tap
* maintainer accept (→ execute) or reject (→ cancel). */
export type AgentPendingActionRecord = {
id: string;
repoFullName: string;
pullNumber: number;
installationId: number;
actionClass: AgentActionClass;
autonomyLevel: AutonomyLevel;
params: AgentPendingActionParams;
reason: string | null;
status: AgentPendingActionStatus;
decidedBy: string | null;
decidedAt: string | null;
createdAt: string;
updatedAt: string;
};

export type RepoSyncStateRecord = {
repoFullName: string;
status: "never_synced" | "running" | "success" | "partial" | "error" | "skipped" | "capped" | "rate_limited" | "stale";
Expand Down
Loading
Loading