mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-05 19:38:32 +02:00
feat: add approval checkpoints backend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { dialog } from 'electron';
|
||||
|
||||
import type {
|
||||
AgentActivityEvent,
|
||||
ApprovalRequestedEvent,
|
||||
RunTurnLspProfileConfig,
|
||||
RunTurnMcpServerConfig,
|
||||
RunTurnToolingConfig,
|
||||
@@ -23,6 +24,14 @@ import {
|
||||
type ReasoningEffort,
|
||||
validatePatternDefinition,
|
||||
} from '@shared/domain/pattern';
|
||||
import {
|
||||
approvalPolicyRequiresCheckpoint,
|
||||
normalizeApprovalPolicy,
|
||||
resolvePendingApproval,
|
||||
type ApprovalDecision,
|
||||
type PendingApprovalMessageRecord,
|
||||
type PendingApprovalRecord,
|
||||
} from '@shared/domain/approval';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import {
|
||||
duplicateSessionRecord,
|
||||
@@ -45,6 +54,7 @@ import {
|
||||
completeSessionRunRecord,
|
||||
createSessionRunRecord,
|
||||
failSessionRunRecord,
|
||||
upsertRunApprovalEvent,
|
||||
upsertRunMessageEvent,
|
||||
upsertSessionRunRecord,
|
||||
type SessionRunRecord,
|
||||
@@ -75,6 +85,12 @@ type AppServiceEvents = {
|
||||
'session-event': [SessionEventRecord];
|
||||
};
|
||||
|
||||
type PendingApprovalHandle = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
resolve: (decision: ApprovalDecision) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function isBuiltinPattern(patternId: string): boolean {
|
||||
return patternId.startsWith('pattern-');
|
||||
}
|
||||
@@ -84,6 +100,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly sidecar = new SidecarClient();
|
||||
private readonly secretStore = new SecretStore();
|
||||
private readonly gitService = new GitService();
|
||||
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
|
||||
private workspace?: WorkspaceState;
|
||||
private sidecarCapabilities?: SidecarCapabilities;
|
||||
private didScheduleInitialProjectGitRefresh = false;
|
||||
@@ -99,6 +116,9 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
async loadWorkspace(): Promise<WorkspaceState> {
|
||||
if (!this.workspace) {
|
||||
this.workspace = await this.workspaceRepository.load();
|
||||
if (this.failInterruptedPendingApprovals(this.workspace)) {
|
||||
await this.workspaceRepository.save(this.workspace);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.didScheduleInitialProjectGitRefresh) {
|
||||
@@ -180,6 +200,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const existingIndex = workspace.patterns.findIndex((current) => current.id === pattern.id);
|
||||
const candidate: PatternDefinition = {
|
||||
...pattern,
|
||||
approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy),
|
||||
isFavorite: pattern.isFavorite ?? workspace.patterns[existingIndex]?.isFavorite,
|
||||
createdAt: existingIndex >= 0 ? workspace.patterns[existingIndex].createdAt : nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
@@ -385,6 +406,9 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
async sendSessionMessage(sessionId: string, content: string): Promise<void> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
if (session.status === 'running') {
|
||||
throw new Error('Wait for the current response or approval checkpoint to finish before sending another message.');
|
||||
}
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const pattern = this.requirePattern(workspace, session.patternId);
|
||||
const effectivePattern = await this.buildEffectivePattern(project, pattern, session);
|
||||
@@ -447,8 +471,13 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
async (event) => {
|
||||
await this.applyAgentActivity(workspace, session.id, requestId, event);
|
||||
},
|
||||
async (event) => {
|
||||
await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision) =>
|
||||
this.sidecar.resolveApproval(event.approvalId, decision));
|
||||
},
|
||||
);
|
||||
|
||||
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
|
||||
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
|
||||
await this.persistAndBroadcast(workspace);
|
||||
} catch (error) {
|
||||
@@ -474,6 +503,66 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
async resolveSessionApproval(
|
||||
sessionId: string,
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision,
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const approval = session.pendingApproval;
|
||||
if (!approval || approval.id !== approvalId) {
|
||||
throw new Error(`Approval "${approvalId}" is not pending for session "${sessionId}".`);
|
||||
}
|
||||
|
||||
const handle = this.pendingApprovalHandles.get(approvalId);
|
||||
if (!handle || handle.sessionId !== sessionId) {
|
||||
throw new Error(`Approval "${approvalId}" is no longer active. Restart the run and try again.`);
|
||||
}
|
||||
|
||||
const resolvedAt = nowIso();
|
||||
const resolvedApproval = resolvePendingApproval(approval, decision, resolvedAt);
|
||||
session.pendingApproval = undefined;
|
||||
session.updatedAt = resolvedAt;
|
||||
|
||||
const updatedRun = this.updateSessionRun(session, handle.requestId, (run) =>
|
||||
upsertRunApprovalEvent(run, resolvedApproval));
|
||||
|
||||
const result = await this.persistAndBroadcast(workspace);
|
||||
if (updatedRun) {
|
||||
this.emitRunUpdated(sessionId, resolvedAt, updatedRun);
|
||||
}
|
||||
|
||||
this.pendingApprovalHandles.delete(approvalId);
|
||||
|
||||
try {
|
||||
await Promise.resolve(handle.resolve(decision));
|
||||
} catch (error) {
|
||||
const failedAt = nowIso();
|
||||
session.status = 'error';
|
||||
session.lastError = error instanceof Error ? error.message : String(error);
|
||||
session.updatedAt = failedAt;
|
||||
|
||||
const failedRun = this.updateSessionRun(session, handle.requestId, (run) =>
|
||||
failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.'));
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'error',
|
||||
occurredAt: failedAt,
|
||||
error: session.lastError,
|
||||
});
|
||||
if (failedRun) {
|
||||
this.emitRunUpdated(sessionId, failedAt, failedRun);
|
||||
}
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateScratchpadSessionConfig(
|
||||
sessionId: string,
|
||||
model: string,
|
||||
@@ -834,6 +923,131 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApprovalRequested(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
approval: ApprovalRequestedEvent | PendingApprovalRecord,
|
||||
resolve: (decision: ApprovalDecision) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
if (session.pendingApproval) {
|
||||
throw new Error(`Session "${sessionId}" already has a pending approval.`);
|
||||
}
|
||||
|
||||
const pendingApproval =
|
||||
'type' in approval ? this.createPendingApprovalFromSidecarEvent(approval) : approval;
|
||||
|
||||
session.pendingApproval = pendingApproval;
|
||||
session.updatedAt = pendingApproval.requestedAt;
|
||||
|
||||
const updatedRun = this.updateSessionRun(session, requestId, (run) =>
|
||||
upsertRunApprovalEvent(run, pendingApproval));
|
||||
|
||||
this.pendingApprovalHandles.set(pendingApproval.id, {
|
||||
sessionId,
|
||||
requestId,
|
||||
resolve,
|
||||
});
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
if (updatedRun) {
|
||||
this.emitRunUpdated(sessionId, pendingApproval.requestedAt, updatedRun);
|
||||
}
|
||||
}
|
||||
|
||||
private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
|
||||
return {
|
||||
id: event.approvalId,
|
||||
kind: event.approvalKind,
|
||||
status: 'pending',
|
||||
requestedAt: nowIso(),
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
toolName: event.toolName,
|
||||
permissionKind: event.permissionKind,
|
||||
title: event.title,
|
||||
detail: event.detail,
|
||||
};
|
||||
}
|
||||
|
||||
private async awaitFinalResponseApproval(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
pattern: PatternDefinition,
|
||||
messages: ChatMessageRecord[],
|
||||
): Promise<void> {
|
||||
const pendingApproval = this.buildFinalResponseApproval(pattern, messages);
|
||||
if (!pendingApproval) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolveDecision: ((decision: ApprovalDecision) => void) | undefined;
|
||||
const decisionPromise = new Promise<ApprovalDecision>((resolve) => {
|
||||
resolveDecision = resolve;
|
||||
});
|
||||
|
||||
await this.handleApprovalRequested(
|
||||
workspace,
|
||||
sessionId,
|
||||
requestId,
|
||||
pendingApproval,
|
||||
(decision) => {
|
||||
resolveDecision?.(decision);
|
||||
},
|
||||
);
|
||||
|
||||
const decision = await decisionPromise;
|
||||
if (decision === 'rejected') {
|
||||
throw new Error('Final response approval was rejected.');
|
||||
}
|
||||
}
|
||||
|
||||
private buildFinalResponseApproval(
|
||||
pattern: PatternDefinition,
|
||||
messages: ChatMessageRecord[],
|
||||
): PendingApprovalRecord | undefined {
|
||||
const assistantMessages = messages.filter((message) => message.role === 'assistant');
|
||||
if (assistantMessages.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previewMessages: PendingApprovalMessageRecord[] = assistantMessages.map((message) => ({
|
||||
id: message.id,
|
||||
authorName: message.authorName,
|
||||
content: message.content,
|
||||
}));
|
||||
|
||||
for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = assistantMessages[index];
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agent = pattern.agents.find((candidate) =>
|
||||
candidate.id === message.authorName || candidate.name === message.authorName);
|
||||
if (!approvalPolicyRequiresCheckpoint(pattern.approvalPolicy, 'final-response', agent?.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentName = agent?.name ?? message.authorName;
|
||||
return {
|
||||
id: createId('approval'),
|
||||
kind: 'final-response',
|
||||
status: 'pending',
|
||||
requestedAt: nowIso(),
|
||||
agentId: agent?.id,
|
||||
agentName,
|
||||
title: agentName ? `Approve final response from ${agentName}` : 'Approve final response',
|
||||
detail: 'Review the pending assistant response before it is added to the session transcript.',
|
||||
messages: previewMessages,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async persistAndBroadcast(workspace: WorkspaceState): Promise<WorkspaceState> {
|
||||
await this.workspaceRepository.save(workspace);
|
||||
this.emit('workspace-updated', workspace);
|
||||
@@ -974,6 +1188,51 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
this.emit('session-event', event);
|
||||
}
|
||||
|
||||
private failInterruptedPendingApprovals(workspace: WorkspaceState): boolean {
|
||||
let changed = false;
|
||||
|
||||
for (const session of workspace.sessions) {
|
||||
const pendingApproval = session.pendingApproval;
|
||||
if (!pendingApproval) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
const failedAt = nowIso();
|
||||
const error = 'Pending approval was interrupted because Eryx restarted before a decision was recorded.';
|
||||
const requestId = this.findApprovalRequestId(session, pendingApproval.id);
|
||||
const rejectedApproval = resolvePendingApproval(pendingApproval, 'rejected', failedAt, error);
|
||||
|
||||
session.pendingApproval = undefined;
|
||||
session.status = 'error';
|
||||
session.lastError = error;
|
||||
session.updatedAt = failedAt;
|
||||
|
||||
if (!requestId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.updateSessionRun(session, requestId, (run) =>
|
||||
failSessionRunRecord(
|
||||
upsertRunApprovalEvent(run, rejectedApproval),
|
||||
failedAt,
|
||||
error,
|
||||
));
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private findApprovalRequestId(session: SessionRecord, approvalId: string): string | undefined {
|
||||
const matchingRun = session.runs.find((run) =>
|
||||
run.events.some((event) => event.kind === 'approval' && event.approvalId === approvalId));
|
||||
if (matchingRun) {
|
||||
return matchingRun.requestId;
|
||||
}
|
||||
|
||||
return session.runs.find((run) => run.status === 'running')?.requestId;
|
||||
}
|
||||
|
||||
private async loadSidecarCapabilities(forceRefresh = false): Promise<SidecarCapabilities> {
|
||||
if (forceRefresh || !this.sidecarCapabilities) {
|
||||
this.sidecarCapabilities = await this.sidecar.describeCapabilities();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CreateSessionInput,
|
||||
DuplicateSessionInput,
|
||||
RenameSessionInput,
|
||||
ResolveSessionApprovalInput,
|
||||
SaveLspProfileInput,
|
||||
SaveMcpServerInput,
|
||||
SavePatternInput,
|
||||
@@ -77,6 +78,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi
|
||||
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
||||
service.sendSessionMessage(input.sessionId, input.content),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) =>
|
||||
service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.updateScratchpadSessionConfig,
|
||||
(_event, input: UpdateScratchpadSessionConfigInput) =>
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { mergeScratchpadProject } from '@shared/domain/project';
|
||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||
import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling';
|
||||
import {
|
||||
normalizeApprovalPolicy,
|
||||
normalizePendingApproval,
|
||||
} from '@shared/domain/approval';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
@@ -59,12 +63,16 @@ export class WorkspaceRepository {
|
||||
|
||||
const workspace: WorkspaceState = {
|
||||
...stored,
|
||||
patterns: mergePatterns(stored.patterns ?? []),
|
||||
patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({
|
||||
...pattern,
|
||||
approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy),
|
||||
})),
|
||||
projects,
|
||||
sessions: (stored.sessions ?? []).map((session) => ({
|
||||
...session,
|
||||
runs: normalizeSessionRunRecords(session.runs),
|
||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||
pendingApproval: normalizePendingApproval(session.pendingApproval),
|
||||
})),
|
||||
settings: normalizeWorkspaceSettings(stored.settings),
|
||||
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentActivityEvent, TurnDeltaEvent } from '@shared/contracts/sidecar';
|
||||
import type { AgentActivityEvent, ApprovalRequestedEvent, TurnDeltaEvent } from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
export interface RunTurnPendingCommand {
|
||||
@@ -7,6 +7,7 @@ export interface RunTurnPendingCommand {
|
||||
reject: (error: Error) => void;
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>;
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>;
|
||||
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>;
|
||||
errored: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
|
||||
import type {
|
||||
AgentActivityEvent,
|
||||
SidecarCapabilities,
|
||||
ApprovalRequestedEvent,
|
||||
SidecarCommand,
|
||||
SidecarCapabilities,
|
||||
SidecarEvent,
|
||||
TurnDeltaEvent,
|
||||
ValidatePatternCommand,
|
||||
RunTurnCommand,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
import { createSidecarEnvironment } from '@main/sidecar/sidecarEnvironment';
|
||||
import {
|
||||
@@ -30,6 +32,11 @@ type PendingCommand =
|
||||
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
| {
|
||||
kind: 'resolve-approval';
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
| RunTurnPendingCommand;
|
||||
|
||||
export class SidecarClient {
|
||||
@@ -58,8 +65,18 @@ export class SidecarClient {
|
||||
command: RunTurnCommand,
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||
): Promise<ChatMessageRecord[]> {
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity);
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval);
|
||||
}
|
||||
|
||||
async resolveApproval(approvalId: string, decision: ApprovalDecision): Promise<void> {
|
||||
return this.dispatch<void>({
|
||||
type: 'resolve-approval',
|
||||
requestId: `approval-${Date.now()}`,
|
||||
approvalId,
|
||||
decision,
|
||||
});
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
@@ -118,6 +135,7 @@ export class SidecarClient {
|
||||
command: SidecarCommand,
|
||||
onDelta?: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||
): Promise<TResult> {
|
||||
const process = await this.ensureProcess();
|
||||
|
||||
@@ -129,6 +147,7 @@ export class SidecarClient {
|
||||
reject,
|
||||
onDelta: onDelta ?? (() => undefined),
|
||||
onActivity: onActivity ?? (() => undefined),
|
||||
onApproval: onApproval ?? (() => undefined),
|
||||
errored: false,
|
||||
});
|
||||
} else if (command.type === 'validate-pattern') {
|
||||
@@ -137,6 +156,12 @@ export class SidecarClient {
|
||||
resolve: resolve as (issues: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'resolve-approval') {
|
||||
this.pending.set(command.requestId, {
|
||||
kind: 'resolve-approval',
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else {
|
||||
this.pending.set(command.requestId, {
|
||||
kind: 'capabilities',
|
||||
@@ -193,6 +218,11 @@ export class SidecarClient {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onActivity(event));
|
||||
}
|
||||
return;
|
||||
case 'approval-requested':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onApproval(event));
|
||||
}
|
||||
return;
|
||||
case 'turn-complete':
|
||||
if (pending.kind === 'run-turn') {
|
||||
if (shouldHandleRunTurnEvent(pending)) {
|
||||
@@ -210,7 +240,10 @@ export class SidecarClient {
|
||||
this.pending.delete(event.requestId);
|
||||
return;
|
||||
case 'command-complete':
|
||||
if (pending.kind !== 'run-turn' || pending.errored) {
|
||||
if (pending.kind === 'resolve-approval') {
|
||||
pending.resolve();
|
||||
this.pending.delete(event.requestId);
|
||||
} else if (pending.kind !== 'run-turn' || pending.errored) {
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -25,6 +25,7 @@ const api: ElectronApi = {
|
||||
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
|
||||
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
|
||||
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
|
||||
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input),
|
||||
updateScratchpadSessionConfig: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.updateScratchpadSessionConfig, input),
|
||||
querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input),
|
||||
|
||||
@@ -59,6 +59,8 @@ function EventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; sta
|
||||
return <ArrowRight className={`${base} text-amber-400`} />;
|
||||
case 'tool-call':
|
||||
return <Wrench className={`${base} text-violet-400`} />;
|
||||
case 'approval':
|
||||
return <AlertTriangle className={`${base} ${status === 'running' ? 'text-amber-400 animate-pulse' : status === 'error' ? 'text-red-400' : 'text-emerald-400'}`} />;
|
||||
case 'message':
|
||||
return <MessageSquare className={`${base} ${status === 'running' ? 'text-blue-400 animate-pulse' : status === 'error' ? 'text-red-400' : 'text-emerald-400'}`} />;
|
||||
case 'run-completed':
|
||||
|
||||
@@ -78,6 +78,14 @@ export function formatEventLabel(event: RunTimelineEventRecord): string {
|
||||
return event.toolName
|
||||
? `${event.agentName ?? 'Agent'} used ${event.toolName}`
|
||||
: `${event.agentName ?? 'Agent'} tool call`;
|
||||
case 'approval':
|
||||
if (event.status === 'completed') {
|
||||
return event.approvalTitle ? `${event.approvalTitle} approved` : 'Approval granted';
|
||||
}
|
||||
if (event.status === 'error') {
|
||||
return event.approvalTitle ? `${event.approvalTitle} rejected` : 'Approval rejected';
|
||||
}
|
||||
return event.approvalTitle ?? 'Approval requested';
|
||||
case 'message':
|
||||
return event.agentName ?? 'Response';
|
||||
case 'run-completed':
|
||||
@@ -132,9 +140,10 @@ const eventKindOrder: Record<RunTimelineEventKind, number> = {
|
||||
'thinking': 1,
|
||||
'handoff': 2,
|
||||
'tool-call': 3,
|
||||
'message': 4,
|
||||
'run-completed': 5,
|
||||
'run-failed': 5,
|
||||
'approval': 4,
|
||||
'message': 5,
|
||||
'run-completed': 6,
|
||||
'run-failed': 6,
|
||||
};
|
||||
|
||||
export function isTerminalEvent(kind: RunTimelineEventKind): boolean {
|
||||
|
||||
@@ -20,6 +20,7 @@ export const ipcChannels = {
|
||||
setSessionPinned: 'sessions:set-pinned',
|
||||
setSessionArchived: 'sessions:set-archived',
|
||||
sendSessionMessage: 'sessions:send-message',
|
||||
resolveSessionApproval: 'sessions:resolve-approval',
|
||||
querySessions: 'sessions:query',
|
||||
updateScratchpadSessionConfig: 'sessions:update-scratchpad-config',
|
||||
selectProject: 'selection:project',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
|
||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
@@ -25,6 +26,12 @@ export interface SendSessionMessageInput {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ResolveSessionApprovalInput {
|
||||
sessionId: string;
|
||||
approvalId: string;
|
||||
decision: ApprovalDecision;
|
||||
}
|
||||
|
||||
export interface UpdateScratchpadSessionConfigInput {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
@@ -87,6 +94,7 @@ export interface ElectronApi {
|
||||
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
|
||||
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
|
||||
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
|
||||
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
|
||||
updateScratchpadSessionConfig(input: UpdateScratchpadSessionConfigInput): Promise<WorkspaceState>;
|
||||
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
|
||||
selectProject(projectId?: string): Promise<WorkspaceState>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
export interface SidecarModeCapability {
|
||||
@@ -76,7 +77,18 @@ export interface RunTurnCommand {
|
||||
tooling?: RunTurnToolingConfig;
|
||||
}
|
||||
|
||||
export type SidecarCommand = DescribeCapabilitiesCommand | ValidatePatternCommand | RunTurnCommand;
|
||||
export interface ResolveApprovalCommand {
|
||||
type: 'resolve-approval';
|
||||
requestId: string;
|
||||
approvalId: string;
|
||||
decision: ApprovalDecision;
|
||||
}
|
||||
|
||||
export type SidecarCommand =
|
||||
| DescribeCapabilitiesCommand
|
||||
| ValidatePatternCommand
|
||||
| RunTurnCommand
|
||||
| ResolveApprovalCommand;
|
||||
|
||||
export interface RunTurnLocalMcpServerConfig {
|
||||
id: string;
|
||||
@@ -157,6 +169,20 @@ export interface AgentActivityEvent {
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestedEvent {
|
||||
type: 'approval-requested';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
approvalId: string;
|
||||
approvalKind: ApprovalCheckpointKind;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
toolName?: string;
|
||||
permissionKind?: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface CommandErrorEvent {
|
||||
type: 'command-error';
|
||||
requestId: string;
|
||||
@@ -174,5 +200,6 @@ export type SidecarEvent =
|
||||
| TurnDeltaEvent
|
||||
| TurnCompleteEvent
|
||||
| AgentActivityEvent
|
||||
| ApprovalRequestedEvent
|
||||
| CommandErrorEvent
|
||||
| CommandCompleteEvent;
|
||||
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ export function duplicateSessionRecord(
|
||||
enabledLspProfileIds: [...session.tooling.enabledLspProfileIds],
|
||||
}
|
||||
: undefined,
|
||||
pendingApproval: undefined,
|
||||
runs: [],
|
||||
messages: session.messages.map((message): ChatMessageRecord => ({
|
||||
...message,
|
||||
|
||||
Reference in New Issue
Block a user