feat: add approval checkpoints backend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 19:48:58 +01:00
co-authored by Copilot
parent e7dfb66038
commit 2aa8d73b2d
28 changed files with 1471 additions and 23 deletions
+212
View File
@@ -0,0 +1,212 @@
export type ApprovalCheckpointKind = 'tool-call' | 'final-response';
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
export type ApprovalDecision = Exclude<ApprovalStatus, 'pending'>;
export interface ApprovalCheckpointRule {
kind: ApprovalCheckpointKind;
agentIds?: string[];
}
export interface ApprovalPolicy {
rules: ApprovalCheckpointRule[];
}
export interface PendingApprovalMessageRecord {
id: string;
authorName: string;
content: string;
}
export interface PendingApprovalRecord {
id: string;
kind: ApprovalCheckpointKind;
status: ApprovalStatus;
requestedAt: string;
resolvedAt?: string;
agentId?: string;
agentName?: string;
toolName?: string;
permissionKind?: string;
title: string;
detail?: string;
messages?: PendingApprovalMessageRecord[];
}
const approvalCheckpointKinds: ApprovalCheckpointKind[] = ['tool-call', 'final-response'];
const approvalCheckpointKindSet = new Set<ApprovalCheckpointKind>(approvalCheckpointKinds);
const approvalStatusSet = new Set<ApprovalStatus>(['pending', 'approved', 'rejected']);
export function isApprovalCheckpointKind(value: string | undefined): value is ApprovalCheckpointKind {
return value !== undefined && approvalCheckpointKindSet.has(value as ApprovalCheckpointKind);
}
export function isApprovalStatus(value: string | undefined): value is ApprovalStatus {
return value !== undefined && approvalStatusSet.has(value as ApprovalStatus);
}
export function normalizeApprovalPolicy(policy?: Partial<ApprovalPolicy>): ApprovalPolicy | undefined {
const rules = Array.isArray(policy?.rules) ? policy.rules : [];
const selectedAgents = new Map<ApprovalCheckpointKind, Set<string>>();
const appliesToAllAgents = new Set<ApprovalCheckpointKind>();
for (const rule of rules) {
if (!isApprovalCheckpointKind(rule?.kind)) {
continue;
}
const normalizedAgentIds = normalizeStringArray(rule.agentIds);
if (normalizedAgentIds.length === 0) {
appliesToAllAgents.add(rule.kind);
selectedAgents.delete(rule.kind);
continue;
}
if (appliesToAllAgents.has(rule.kind)) {
continue;
}
const existing = selectedAgents.get(rule.kind) ?? new Set<string>();
for (const agentId of normalizedAgentIds) {
existing.add(agentId);
}
selectedAgents.set(rule.kind, existing);
}
const normalizedRules = approvalCheckpointKinds.flatMap((kind): ApprovalCheckpointRule[] => {
if (appliesToAllAgents.has(kind)) {
return [{ kind }];
}
const agentIds = [...(selectedAgents.get(kind) ?? [])];
if (agentIds.length === 0) {
return [];
}
return [{ kind, agentIds }];
});
return normalizedRules.length > 0 ? { rules: normalizedRules } : undefined;
}
export function validateApprovalPolicy(
policy: ApprovalPolicy | undefined,
knownAgentIds: readonly string[],
): string[] {
if (!policy) {
return [];
}
const knownAgents = new Set(normalizeStringArray(knownAgentIds));
const issues: string[] = [];
for (const rule of policy.rules) {
for (const agentId of rule.agentIds ?? []) {
if (!knownAgents.has(agentId)) {
issues.push(`Approval checkpoint "${rule.kind}" references unknown agent "${agentId}".`);
}
}
}
return issues;
}
export function approvalPolicyRequiresCheckpoint(
policy: ApprovalPolicy | undefined,
kind: ApprovalCheckpointKind,
agentId?: string,
): boolean {
const rule = policy?.rules.find((candidate) => candidate.kind === kind);
if (!rule) {
return false;
}
if (!rule.agentIds || rule.agentIds.length === 0) {
return true;
}
const normalizedAgentId = normalizeOptionalString(agentId);
if (!normalizedAgentId) {
return false;
}
return rule.agentIds.includes(normalizedAgentId);
}
export function normalizePendingApproval(
approval?: Partial<PendingApprovalRecord>,
): PendingApprovalRecord | undefined {
const id = normalizeOptionalString(approval?.id);
const kind = isApprovalCheckpointKind(approval?.kind) ? approval.kind : undefined;
const status = isApprovalStatus(approval?.status) ? approval.status : undefined;
const requestedAt = normalizeOptionalString(approval?.requestedAt);
const title = normalizeOptionalString(approval?.title);
if (!id || !kind || !status || !requestedAt || !title) {
return undefined;
}
return {
id,
kind,
status,
requestedAt,
resolvedAt: status === 'pending' ? undefined : normalizeOptionalString(approval?.resolvedAt),
agentId: normalizeOptionalString(approval?.agentId),
agentName: normalizeOptionalString(approval?.agentName),
toolName: normalizeOptionalString(approval?.toolName),
permissionKind: normalizeOptionalString(approval?.permissionKind),
title,
detail: normalizeOptionalString(approval?.detail),
messages: normalizePendingApprovalMessages(approval?.messages),
};
}
export function resolvePendingApproval(
approval: PendingApprovalRecord,
decision: ApprovalDecision,
resolvedAt: string,
detail?: string,
): PendingApprovalRecord {
return {
...approval,
status: decision,
resolvedAt,
detail: normalizeOptionalString(detail) ?? approval.detail,
};
}
function normalizePendingApprovalMessages(
messages?: ReadonlyArray<Partial<PendingApprovalMessageRecord>>,
): PendingApprovalMessageRecord[] | undefined {
if (!messages || messages.length === 0) {
return undefined;
}
const normalized = messages.flatMap((message) => {
const id = normalizeOptionalString(message.id);
const authorName = normalizeOptionalString(message.authorName);
if (!id || !authorName) {
return [];
}
return [{
id,
authorName,
content: message.content ?? '',
}];
});
return normalized.length > 0 ? normalized : undefined;
}
function normalizeOptionalString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
if (!values) {
return [];
}
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
}
+17
View File
@@ -1,4 +1,9 @@
import type { ChatMessageRecord } from '@shared/domain/session';
import {
normalizeApprovalPolicy,
type ApprovalPolicy,
validateApprovalPolicy,
} from '@shared/domain/approval';
export type OrchestrationMode =
| 'single'
@@ -36,6 +41,7 @@ export interface PatternDefinition {
availability: PatternAvailability;
unavailabilityReason?: string;
maxIterations: number;
approvalPolicy?: ApprovalPolicy;
agents: PatternAgentDefinition[];
createdAt: string;
updatedAt: string;
@@ -315,6 +321,17 @@ export function validatePatternDefinition(pattern: PatternDefinition): PatternVa
}
}
for (const message of validateApprovalPolicy(
normalizeApprovalPolicy(pattern.approvalPolicy),
pattern.agents.map((agent) => agent.id),
)) {
issues.push({
level: 'error',
field: 'approvalPolicy',
message,
});
}
return issues;
}
+83
View File
@@ -1,3 +1,8 @@
import type {
ApprovalCheckpointKind,
ApprovalDecision,
PendingApprovalRecord,
} from '@shared/domain/approval';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import { createId } from '@shared/utils/ids';
@@ -9,6 +14,7 @@ export type RunTimelineEventKind =
| 'thinking'
| 'handoff'
| 'tool-call'
| 'approval'
| 'message'
| 'run-completed'
| 'run-failed';
@@ -34,6 +40,12 @@ export interface RunTimelineEventRecord {
targetAgentId?: string;
targetAgentName?: string;
toolName?: string;
approvalId?: string;
approvalKind?: ApprovalCheckpointKind;
approvalTitle?: string;
approvalDetail?: string;
permissionKind?: string;
decision?: ApprovalDecision;
messageId?: string;
content?: string;
error?: string;
@@ -84,6 +96,17 @@ export interface UpsertRunMessageEventInput {
error?: string;
}
function approvalStatusToRunStatus(status: PendingApprovalRecord['status']): RunTimelineEventStatus {
switch (status) {
case 'approved':
return 'completed';
case 'rejected':
return 'error';
default:
return 'running';
}
}
function normalizeOptionalString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
@@ -129,6 +152,12 @@ function normalizeRunTimelineEvent(
targetAgentId: normalizeOptionalString(event.targetAgentId),
targetAgentName: normalizeOptionalString(event.targetAgentName),
toolName: normalizeOptionalString(event.toolName),
approvalId: normalizeOptionalString(event.approvalId),
approvalKind: event.approvalKind,
approvalTitle: normalizeOptionalString(event.approvalTitle),
approvalDetail: normalizeOptionalString(event.approvalDetail),
permissionKind: normalizeOptionalString(event.permissionKind),
decision: event.decision,
messageId: normalizeOptionalString(event.messageId),
content: event.content,
error: normalizeOptionalString(event.error),
@@ -317,6 +346,60 @@ export function upsertSessionRunRecord(
return nextRuns;
}
export function upsertRunApprovalEvent(
run: SessionRunRecord,
approval: PendingApprovalRecord,
): SessionRunRecord {
const existingIndex = run.events.findIndex(
(event) => event.kind === 'approval' && event.approvalId === approval.id,
);
const nextStatus = approvalStatusToRunStatus(approval.status);
const nextEvent: RunTimelineEventRecord = {
id: existingIndex >= 0 ? run.events[existingIndex].id : createId('run-event'),
kind: 'approval',
occurredAt:
existingIndex >= 0 ? run.events[existingIndex].occurredAt : approval.requestedAt,
updatedAt: approval.status === 'pending' ? undefined : approval.resolvedAt,
status: nextStatus,
agentId: normalizeOptionalString(approval.agentId),
agentName: normalizeOptionalString(approval.agentName),
toolName: normalizeOptionalString(approval.toolName),
approvalId: approval.id,
approvalKind: approval.kind,
approvalTitle: approval.title,
approvalDetail: normalizeOptionalString(approval.detail),
permissionKind: normalizeOptionalString(approval.permissionKind),
decision: approval.status === 'pending' ? undefined : approval.status,
};
if (existingIndex < 0) {
return appendRunTimelineEvent(run, nextEvent);
}
const existingEvent = run.events[existingIndex];
if (
existingEvent.updatedAt === nextEvent.updatedAt
&& existingEvent.status === nextEvent.status
&& existingEvent.agentId === nextEvent.agentId
&& existingEvent.agentName === nextEvent.agentName
&& existingEvent.toolName === nextEvent.toolName
&& existingEvent.approvalKind === nextEvent.approvalKind
&& existingEvent.approvalTitle === nextEvent.approvalTitle
&& existingEvent.approvalDetail === nextEvent.approvalDetail
&& existingEvent.permissionKind === nextEvent.permissionKind
&& existingEvent.decision === nextEvent.decision
) {
return run;
}
const nextEvents = run.events.slice();
nextEvents[existingIndex] = nextEvent;
return {
...run,
events: nextEvents,
};
}
export function appendRunActivityEvent(
run: SessionRunRecord,
input: AppendRunActivityEventInput,
+2
View File
@@ -4,6 +4,7 @@ import {
normalizeSessionToolingSelection,
type SessionToolingSelection,
} from '@shared/domain/tooling';
import type { PendingApprovalRecord } from '@shared/domain/approval';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
export type ChatRole = 'system' | 'user' | 'assistant';
@@ -39,6 +40,7 @@ export interface SessionRecord {
lastError?: string;
scratchpadConfig?: ScratchpadSessionConfig;
tooling?: SessionToolingSelection;
pendingApproval?: PendingApprovalRecord;
runs: SessionRunRecord[];
}
+1
View File
@@ -181,6 +181,7 @@ export function duplicateSessionRecord(
enabledLspProfileIds: [...session.tooling.enabledLspProfileIds],
}
: undefined,
pendingApproval: undefined,
runs: [],
messages: session.messages.map((message): ChatMessageRecord => ({
...message,