mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
fix: queue session approvals
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+115
-193
@@ -1,43 +1,40 @@
|
|||||||
# Approval checkpoints frontend handover
|
# Approval checkpoints UX handover
|
||||||
|
|
||||||
## What is implemented in the backend
|
## What changed in the backend
|
||||||
|
|
||||||
The backend now supports approval checkpoints in two places:
|
Approval checkpoints are now **queued per session** instead of failing when a second approval request arrives before the first one is resolved.
|
||||||
|
|
||||||
1. `tool-call`
|
This fixes the previous backend limitation where multi-agent patterns such as `Sequential Trio Review` could throw:
|
||||||
2. `final-response`
|
|
||||||
|
|
||||||
`tool-call` approvals are enforced inside the .NET sidecar through the Copilot permission callback.
|
```text
|
||||||
|
Session "<id>" already has a pending approval.
|
||||||
|
```
|
||||||
|
|
||||||
`final-response` approvals are enforced in the Electron main process after the sidecar finishes generating assistant messages but before those messages are published into the session transcript.
|
The queue is backend-only in this change. The current frontend can keep working because the active approval is still exposed through the existing `session.pendingApproval` field.
|
||||||
|
|
||||||
The roadmap item **"pause before handing off outside the original working set"** is **not implemented** in this change. There is still no working-set model in the backend that can support that rule correctly.
|
## Queue semantics
|
||||||
|
|
||||||
## Important behavioral semantics
|
- A session still exposes **one active approval** through `session.pendingApproval`.
|
||||||
|
- Additional approvals waiting behind it are exposed through `session.pendingApprovalQueue`.
|
||||||
|
- `session.pendingApprovalQueue` does **not** include the active approval.
|
||||||
|
- Queue order is the order in which approvals were requested.
|
||||||
|
- `resolveSessionApproval(...)` still resolves **only the active approval**.
|
||||||
|
- When the active approval is resolved, the next queued approval automatically becomes the new `session.pendingApproval`.
|
||||||
|
- If the run fails while approvals remain queued, the backend rejects and clears the remaining queued approvals.
|
||||||
|
- If Eryx restarts while approvals are pending or queued, the backend rejects all of them and marks the session/run as errored.
|
||||||
|
|
||||||
- A session **stays** `status: 'running'` while waiting for approval.
|
## Current shared model
|
||||||
- The backend exposes the paused state through `session.pendingApproval`.
|
|
||||||
- The frontend should treat `session.pendingApproval` as "awaiting approval" even though `session.status` is still `running`.
|
|
||||||
- Resolving an approval clears `session.pendingApproval`.
|
|
||||||
- Approving a checkpoint resumes the run.
|
|
||||||
- Rejecting a `final-response` checkpoint causes the run to fail with an explicit error after the backend resumes the waiting flow.
|
|
||||||
- Rejecting a `tool-call` checkpoint sends a denial back to the Copilot runtime. The eventual run outcome depends on the runtime response, but it should be treated as a rejected risky action.
|
|
||||||
- If Eryx restarts while an approval is pending, the backend marks that session as errored and converts the pending approval into a rejected/interrupted run event.
|
|
||||||
|
|
||||||
## New shared model shapes
|
|
||||||
|
|
||||||
### Pattern approval policy
|
### Pattern approval policy
|
||||||
|
|
||||||
File: `src/shared/domain/approval.ts`
|
This is unchanged from the earlier backend work:
|
||||||
|
|
||||||
Patterns can now carry an optional `approvalPolicy`:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type ApprovalCheckpointKind = 'tool-call' | 'final-response';
|
type ApprovalCheckpointKind = 'tool-call' | 'final-response';
|
||||||
|
|
||||||
interface ApprovalCheckpointRule {
|
interface ApprovalCheckpointRule {
|
||||||
kind: ApprovalCheckpointKind;
|
kind: ApprovalCheckpointKind;
|
||||||
agentIds?: string[]; // omitted or empty = all agents in the pattern
|
agentIds?: string[]; // omitted or empty = all agents
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApprovalPolicy {
|
interface ApprovalPolicy {
|
||||||
@@ -45,19 +42,9 @@ interface ApprovalPolicy {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This is attached to `PatternDefinition` as:
|
### Session approval state
|
||||||
|
|
||||||
```ts
|
The active approval + queued approvals are now represented as:
|
||||||
approvalPolicy?: ApprovalPolicy;
|
|
||||||
```
|
|
||||||
|
|
||||||
Backend validation already rejects agent IDs that do not exist in the pattern.
|
|
||||||
|
|
||||||
### Session pending approval
|
|
||||||
|
|
||||||
File: `src/shared/domain/approval.ts`
|
|
||||||
|
|
||||||
Sessions can now expose a single active pending approval:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface PendingApprovalMessageRecord {
|
interface PendingApprovalMessageRecord {
|
||||||
@@ -82,56 +69,27 @@ interface PendingApprovalRecord {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This is attached to `SessionRecord` as:
|
Attached to `SessionRecord` as:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
pendingApproval?: PendingApprovalRecord;
|
pendingApproval?: PendingApprovalRecord;
|
||||||
```
|
pendingApprovalQueue?: PendingApprovalRecord[];
|
||||||
|
|
||||||
For `final-response`, `pendingApproval.messages` contains the unpublished assistant output that the reviewer should inspect before publication.
|
|
||||||
|
|
||||||
For `tool-call`, `pendingApproval.messages` is typically absent.
|
|
||||||
|
|
||||||
### Run timeline event
|
|
||||||
|
|
||||||
File: `src/shared/domain/runTimeline.ts`
|
|
||||||
|
|
||||||
There is a new run timeline event kind:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
kind: 'approval'
|
|
||||||
```
|
|
||||||
|
|
||||||
The event is updated in place over time rather than creating separate requested/approved/rejected entries.
|
|
||||||
|
|
||||||
Relevant fields:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
approvalId?: string;
|
|
||||||
approvalKind?: 'tool-call' | 'final-response';
|
|
||||||
approvalTitle?: string;
|
|
||||||
approvalDetail?: string;
|
|
||||||
permissionKind?: string;
|
|
||||||
decision?: 'approved' | 'rejected';
|
|
||||||
status: 'running' | 'completed' | 'error';
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Interpretation:
|
Interpretation:
|
||||||
|
|
||||||
- `status: 'running'` => approval requested and still pending
|
- `pendingApproval` = the approval the user can act on **right now**
|
||||||
- `status: 'completed'` + `decision: 'approved'` => approval granted
|
- `pendingApprovalQueue` = additional pending approvals waiting behind it
|
||||||
- `status: 'error'` + `decision: 'rejected'` => approval rejected
|
|
||||||
|
|
||||||
## New IPC surface
|
For `final-response`, `pendingApproval.messages` contains the unpublished assistant output preview.
|
||||||
|
|
||||||
Files:
|
For `tool-call`, `messages` is usually absent and the useful context is `agentName`, `toolName`, and `permissionKind`.
|
||||||
|
|
||||||
- `src/shared/contracts/ipc.ts`
|
## IPC surface
|
||||||
- `src/shared/contracts/channels.ts`
|
|
||||||
- `src/preload/index.ts`
|
|
||||||
- `src/main/ipc/registerIpcHandlers.ts`
|
|
||||||
|
|
||||||
New preload/API call:
|
No new IPC was added for queueing.
|
||||||
|
|
||||||
|
The renderer still resolves approvals through:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
resolveSessionApproval({
|
resolveSessionApproval({
|
||||||
@@ -141,166 +99,130 @@ resolveSessionApproval({
|
|||||||
}): Promise<WorkspaceState>
|
}): Promise<WorkspaceState>
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the frontend action to approve or reject the active checkpoint.
|
Important behavior:
|
||||||
|
|
||||||
## Internal sidecar protocol additions
|
- this resolves the **active** approval only
|
||||||
|
- if the user somehow attempts to resolve a queued approval directly, the backend rejects it
|
||||||
|
- after a successful resolution, the next queued approval may immediately become active in the returned workspace state
|
||||||
|
|
||||||
These are backend-only, but useful context for debugging:
|
## Timeline behavior
|
||||||
|
|
||||||
Files:
|
The run timeline still uses `approval` events.
|
||||||
|
|
||||||
- `src/shared/contracts/sidecar.ts`
|
Each approval request gets its own event keyed by `approvalId`.
|
||||||
- `sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs`
|
|
||||||
- `sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs`
|
|
||||||
- `sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs`
|
|
||||||
|
|
||||||
New sidecar event:
|
That means queued approvals now show up as multiple `approval` events in the same run when applicable. The active/queued distinction is in session state, not in the timeline event type.
|
||||||
|
|
||||||
```ts
|
## Backend files changed for queue support
|
||||||
type: 'approval-requested'
|
|
||||||
```
|
|
||||||
|
|
||||||
New sidecar command:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type: 'resolve-approval'
|
|
||||||
```
|
|
||||||
|
|
||||||
The Electron main process already handles this handshake. The frontend should not talk to the sidecar directly.
|
|
||||||
|
|
||||||
## Files that already understand approval data
|
|
||||||
|
|
||||||
Backend and shared code:
|
|
||||||
|
|
||||||
- `src/shared/domain/approval.ts`
|
- `src/shared/domain/approval.ts`
|
||||||
- `src/shared/domain/pattern.ts`
|
- added queue-state helpers and normalization
|
||||||
- `src/shared/domain/session.ts`
|
- `src/shared/domain/session.ts`
|
||||||
- `src/shared/domain/runTimeline.ts`
|
- added `pendingApprovalQueue`
|
||||||
|
- `src/shared/domain/sessionLibrary.ts`
|
||||||
|
- duplicated sessions now clear queued approvals too
|
||||||
|
- `src/main/persistence/workspaceRepository.ts`
|
||||||
|
- normalizes legacy single-approval data into active + queue state
|
||||||
- `src/main/EryxAppService.ts`
|
- `src/main/EryxAppService.ts`
|
||||||
- `src/main/sidecar/sidecarProcess.ts`
|
- enqueues approval requests instead of throwing
|
||||||
|
- advances the queue on resolution
|
||||||
|
- rejects all queued approvals on restart / failure cleanup
|
||||||
|
|
||||||
Minimal renderer compile-support only:
|
## What the current frontend already does
|
||||||
|
|
||||||
- `src/renderer/lib/runTimelineFormatting.ts`
|
The current UI should remain functionally compatible because it already reads `session.pendingApproval` and renders a single active approval banner/card.
|
||||||
- `src/renderer/components/RunTimeline.tsx`
|
|
||||||
|
|
||||||
Those renderer changes are intentionally minimal. They only keep the current app build/typecheck working with the new timeline event kind.
|
That means:
|
||||||
|
|
||||||
## Frontend work still needed
|
- users can still resolve approvals one at a time
|
||||||
|
- the next queued approval should appear automatically after resolving the current one
|
||||||
|
- no urgent UI fix is required just to keep the app working
|
||||||
|
|
||||||
### 1. Pattern editor support for approval policy
|
## Next UX steps for the frontend agent
|
||||||
|
|
||||||
Files to inspect:
|
These are the next improvements the UX agent should make on top of the new backend queue.
|
||||||
|
|
||||||
- `src/renderer/components/PatternEditor.tsx`
|
### 1. Show queue size next to the active approval
|
||||||
- `src/renderer/App.tsx`
|
|
||||||
|
|
||||||
Needed UI:
|
|
||||||
|
|
||||||
- Allow enabling `tool-call` approvals.
|
|
||||||
- Allow enabling `final-response` approvals.
|
|
||||||
- Allow scoping each checkpoint to:
|
|
||||||
- all agents
|
|
||||||
- selected agents
|
|
||||||
|
|
||||||
Recommended representation:
|
|
||||||
|
|
||||||
- one section named "Approval checkpoints"
|
|
||||||
- one row per checkpoint kind
|
|
||||||
- a toggle
|
|
||||||
- an "all agents / selected agents" selector
|
|
||||||
- multi-select agent chips when scoped
|
|
||||||
|
|
||||||
### 2. Pending approval UI in chat/session surfaces
|
|
||||||
|
|
||||||
Files to inspect:
|
Files to inspect:
|
||||||
|
|
||||||
- `src/renderer/components/ChatPane.tsx`
|
- `src/renderer/components/ChatPane.tsx`
|
||||||
- `src/renderer/components/ActivityPanel.tsx`
|
- `src/renderer/components/ActivityPanel.tsx`
|
||||||
- `src/renderer/components/Sidebar.tsx`
|
- `src/renderer/components/Sidebar.tsx`
|
||||||
- `src/renderer/lib/sessionWorkspace.ts`
|
|
||||||
|
|
||||||
Needed behavior:
|
|
||||||
|
|
||||||
- Show a visible checkpoint card/banner whenever `session.pendingApproval` exists.
|
|
||||||
- Present:
|
|
||||||
- title
|
|
||||||
- detail
|
|
||||||
- agent name
|
|
||||||
- permission kind (for tool-call)
|
|
||||||
- preview messages (for final-response)
|
|
||||||
- Add **Approve** and **Reject** actions that call `api.resolveSessionApproval(...)`.
|
|
||||||
|
|
||||||
### 3. Final-response review UI
|
|
||||||
|
|
||||||
Use `session.pendingApproval.messages` when `kind === 'final-response'`.
|
|
||||||
|
|
||||||
Recommended UX:
|
Recommended UX:
|
||||||
|
|
||||||
- Render the candidate assistant messages exactly as they would appear in chat.
|
- if `session.pendingApprovalQueue?.length > 0`, show a compact badge like:
|
||||||
- Make it clear they are pending publication, not yet committed to transcript history.
|
- `1 queued`
|
||||||
- On approval, let the backend publish them.
|
- `2 queued`
|
||||||
- On rejection, expect the run to fail and show an error state after the backend resumes the blocked flow.
|
- keep the active approval clearly distinguished from queued approvals
|
||||||
|
|
||||||
### 4. Run timeline UI
|
### 2. Clarify that the visible approval is only the active one
|
||||||
|
|
||||||
|
In the active approval card/banner, add copy such as:
|
||||||
|
|
||||||
|
- `Approval 1 of 3`
|
||||||
|
- `Next approval will appear after this one is resolved`
|
||||||
|
|
||||||
|
This will make automatic queue advancement feel intentional instead of surprising.
|
||||||
|
|
||||||
|
### 3. Add an optional queued-approvals preview
|
||||||
|
|
||||||
Files to inspect:
|
Files to inspect:
|
||||||
|
|
||||||
- `src/renderer/components/RunTimeline.tsx`
|
|
||||||
- `src/renderer/lib/runTimelineFormatting.ts`
|
|
||||||
|
|
||||||
Needed improvements:
|
|
||||||
|
|
||||||
- Add a dedicated approval event treatment instead of the current minimal fallback.
|
|
||||||
- Show distinct visuals for:
|
|
||||||
- requested
|
|
||||||
- approved
|
|
||||||
- rejected
|
|
||||||
- Include checkpoint kind and detail text.
|
|
||||||
- Include message preview affordances for final-response if desired.
|
|
||||||
|
|
||||||
### 5. Session activity / status treatment
|
|
||||||
|
|
||||||
Files to inspect:
|
|
||||||
|
|
||||||
- `src/renderer/lib/sessionActivity.ts`
|
|
||||||
- `src/renderer/components/ActivityPanel.tsx`
|
|
||||||
- `src/renderer/components/ChatPane.tsx`
|
- `src/renderer/components/ChatPane.tsx`
|
||||||
|
- `src/renderer/components/ActivityPanel.tsx`
|
||||||
Important rule:
|
|
||||||
|
|
||||||
- Do **not** rely on `session.status` alone to decide whether the session is actively streaming versus blocked on approval.
|
|
||||||
- Use `session.pendingApproval` as the signal for "paused awaiting human action."
|
|
||||||
|
|
||||||
### 6. Error / stale checkpoint handling
|
|
||||||
|
|
||||||
The frontend should handle two backend-produced edge cases cleanly:
|
|
||||||
|
|
||||||
1. `resolveSessionApproval(...)` can fail if the approval is no longer active.
|
|
||||||
2. A pending approval can disappear on reload because the backend converted it into an interrupted error after restart.
|
|
||||||
|
|
||||||
Recommended UX:
|
Recommended UX:
|
||||||
|
|
||||||
- show a toast or inline error if approval resolution fails
|
- an accordion or compact list for `session.pendingApprovalQueue`
|
||||||
- refresh from workspace state afterward
|
- each queued item can show:
|
||||||
- show the session error banner when the run has been interrupted
|
- title
|
||||||
|
- kind
|
||||||
|
- agent name
|
||||||
|
- permission kind / tool name when available
|
||||||
|
|
||||||
## Suggested frontend implementation order
|
Important:
|
||||||
|
|
||||||
1. Add read-only pending approval banner/card in `ChatPane`.
|
- queued approvals are **read-only**
|
||||||
2. Wire approve/reject buttons to `resolveSessionApproval`.
|
- only the active `pendingApproval` should show Approve / Reject buttons
|
||||||
3. Render final-response preview from `pendingApproval.messages`.
|
|
||||||
4. Add timeline rendering for `approval` events.
|
### 4. Handle queue transitions smoothly
|
||||||
5. Add pattern editor controls for configuring `approvalPolicy`.
|
|
||||||
6. Refine sidebar/activity badges for "awaiting approval" state.
|
When the active approval is resolved and the next one becomes active:
|
||||||
|
|
||||||
|
- avoid jarring layout jumps
|
||||||
|
- keep scroll position stable
|
||||||
|
- consider a subtle transition so the user understands the queue advanced
|
||||||
|
|
||||||
|
### 5. Improve error copy for queued-approval edge cases
|
||||||
|
|
||||||
|
Possible backend outcomes the UI should explain well:
|
||||||
|
|
||||||
|
- active approval was resolved, then the run failed before the queue fully drained
|
||||||
|
- app restarted and the whole queue was rejected/interrupted
|
||||||
|
- user tried to resolve an approval that is no longer active
|
||||||
|
|
||||||
|
Recommended UX:
|
||||||
|
|
||||||
|
- inline error or toast
|
||||||
|
- refresh from returned workspace state
|
||||||
|
- preserve visibility into which approval is now active
|
||||||
|
|
||||||
|
### 6. Optionally surface queue state in the timeline
|
||||||
|
|
||||||
|
The timeline already records all approval events, but the UI could add:
|
||||||
|
|
||||||
|
- an “active” vs “queued” distinction when an approval event is still pending
|
||||||
|
- badges or copy that explain multiple approval checkpoints exist in the same run
|
||||||
|
|
||||||
|
This is optional because the core queue behavior is already represented in backend state.
|
||||||
|
|
||||||
## Validation commands
|
## Validation commands
|
||||||
|
|
||||||
Backend changes already pass:
|
Backend queue changes should be validated with:
|
||||||
|
|
||||||
- `bun run typecheck`
|
- `bun run typecheck`
|
||||||
- `bun test`
|
- `bun test`
|
||||||
- `bun run sidecar:test`
|
- `bun run sidecar:test`
|
||||||
- `bun run build`
|
- `bun run build`
|
||||||
|
|
||||||
After the frontend agent finishes, rerun the same four commands.
|
|
||||||
|
|||||||
+66
-20
@@ -26,6 +26,9 @@ import {
|
|||||||
} from '@shared/domain/pattern';
|
} from '@shared/domain/pattern';
|
||||||
import {
|
import {
|
||||||
approvalPolicyRequiresCheckpoint,
|
approvalPolicyRequiresCheckpoint,
|
||||||
|
dequeuePendingApprovalState,
|
||||||
|
enqueuePendingApprovalState,
|
||||||
|
listPendingApprovals,
|
||||||
normalizeApprovalPolicy,
|
normalizeApprovalPolicy,
|
||||||
resolvePendingApproval,
|
resolvePendingApproval,
|
||||||
type ApprovalDecision,
|
type ApprovalDecision,
|
||||||
@@ -512,6 +515,15 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
const approval = session.pendingApproval;
|
const approval = session.pendingApproval;
|
||||||
if (!approval || approval.id !== approvalId) {
|
if (!approval || approval.id !== approvalId) {
|
||||||
|
const queuedApproval = session.pendingApprovalQueue?.some((candidate) => candidate.id === approvalId);
|
||||||
|
if (queuedApproval) {
|
||||||
|
throw new Error(
|
||||||
|
approval
|
||||||
|
? `Approval "${approvalId}" is queued behind "${approval.id}" for session "${sessionId}". Resolve the active approval first.`
|
||||||
|
: `Approval "${approvalId}" is queued but not active for session "${sessionId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Approval "${approvalId}" is not pending for session "${sessionId}".`);
|
throw new Error(`Approval "${approvalId}" is not pending for session "${sessionId}".`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +534,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
|
|
||||||
const resolvedAt = nowIso();
|
const resolvedAt = nowIso();
|
||||||
const resolvedApproval = resolvePendingApproval(approval, decision, resolvedAt);
|
const resolvedApproval = resolvePendingApproval(approval, decision, resolvedAt);
|
||||||
session.pendingApproval = undefined;
|
this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId));
|
||||||
session.updatedAt = resolvedAt;
|
session.updatedAt = resolvedAt;
|
||||||
|
|
||||||
const updatedRun = this.updateSessionRun(session, handle.requestId, (run) =>
|
const updatedRun = this.updateSessionRun(session, handle.requestId, (run) =>
|
||||||
@@ -539,6 +551,11 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
await Promise.resolve(handle.resolve(decision));
|
await Promise.resolve(handle.resolve(decision));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const failedAt = nowIso();
|
const failedAt = nowIso();
|
||||||
|
this.rejectPendingApprovals(
|
||||||
|
session,
|
||||||
|
failedAt,
|
||||||
|
'Queued approval was cancelled because the run failed before it could resume.',
|
||||||
|
);
|
||||||
session.status = 'error';
|
session.status = 'error';
|
||||||
session.lastError = error instanceof Error ? error.message : String(error);
|
session.lastError = error instanceof Error ? error.message : String(error);
|
||||||
session.updatedAt = failedAt;
|
session.updatedAt = failedAt;
|
||||||
@@ -931,14 +948,10 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
resolve: (decision: ApprovalDecision) => void | Promise<void>,
|
resolve: (decision: ApprovalDecision) => void | Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
if (session.pendingApproval) {
|
|
||||||
throw new Error(`Session "${sessionId}" already has a pending approval.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingApproval =
|
const pendingApproval =
|
||||||
'type' in approval ? this.createPendingApprovalFromSidecarEvent(approval) : approval;
|
'type' in approval ? this.createPendingApprovalFromSidecarEvent(approval) : approval;
|
||||||
|
|
||||||
session.pendingApproval = pendingApproval;
|
this.setSessionPendingApprovalState(session, enqueuePendingApprovalState(session, pendingApproval));
|
||||||
session.updatedAt = pendingApproval.requestedAt;
|
session.updatedAt = pendingApproval.requestedAt;
|
||||||
|
|
||||||
const updatedRun = this.updateSessionRun(session, requestId, (run) =>
|
const updatedRun = this.updateSessionRun(session, requestId, (run) =>
|
||||||
@@ -971,6 +984,41 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setSessionPendingApprovalState(
|
||||||
|
session: SessionRecord,
|
||||||
|
state: {
|
||||||
|
pendingApproval?: PendingApprovalRecord;
|
||||||
|
pendingApprovalQueue?: PendingApprovalRecord[];
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
session.pendingApproval = state.pendingApproval;
|
||||||
|
session.pendingApprovalQueue = state.pendingApprovalQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectPendingApprovals(
|
||||||
|
session: SessionRecord,
|
||||||
|
failedAt: string,
|
||||||
|
error: string,
|
||||||
|
): string[] {
|
||||||
|
const requestIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const pendingApproval of listPendingApprovals(session)) {
|
||||||
|
const requestId = this.findApprovalRequestId(session, pendingApproval.id);
|
||||||
|
const rejectedApproval = resolvePendingApproval(pendingApproval, 'rejected', failedAt, error);
|
||||||
|
|
||||||
|
if (requestId) {
|
||||||
|
requestIds.add(requestId);
|
||||||
|
this.updateSessionRun(session, requestId, (run) =>
|
||||||
|
upsertRunApprovalEvent(run, rejectedApproval));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingApprovalHandles.delete(pendingApproval.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setSessionPendingApprovalState(session, {});
|
||||||
|
return [...requestIds];
|
||||||
|
}
|
||||||
|
|
||||||
private async awaitFinalResponseApproval(
|
private async awaitFinalResponseApproval(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -1192,32 +1240,30 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
for (const session of workspace.sessions) {
|
for (const session of workspace.sessions) {
|
||||||
const pendingApproval = session.pendingApproval;
|
const pendingApprovals = listPendingApprovals(session);
|
||||||
if (!pendingApproval) {
|
if (pendingApprovals.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
changed = true;
|
changed = true;
|
||||||
const failedAt = nowIso();
|
const failedAt = nowIso();
|
||||||
const error = 'Pending approval was interrupted because Eryx restarted before a decision was recorded.';
|
const error = 'Pending approval was interrupted because Eryx restarted before a decision was recorded.';
|
||||||
const requestId = this.findApprovalRequestId(session, pendingApproval.id);
|
const requestIds = this.rejectPendingApprovals(session, failedAt, error);
|
||||||
const rejectedApproval = resolvePendingApproval(pendingApproval, 'rejected', failedAt, error);
|
|
||||||
|
|
||||||
session.pendingApproval = undefined;
|
|
||||||
session.status = 'error';
|
session.status = 'error';
|
||||||
session.lastError = error;
|
session.lastError = error;
|
||||||
session.updatedAt = failedAt;
|
session.updatedAt = failedAt;
|
||||||
|
|
||||||
if (!requestId) {
|
if (requestIds.length === 0) {
|
||||||
continue;
|
const fallbackRequestId = session.runs.find((run) => run.status === 'running')?.requestId;
|
||||||
|
if (fallbackRequestId) {
|
||||||
|
requestIds.push(fallbackRequestId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateSessionRun(session, requestId, (run) =>
|
for (const requestId of requestIds) {
|
||||||
failSessionRunRecord(
|
this.updateSessionRun(session, requestId, (run) =>
|
||||||
upsertRunApprovalEvent(run, rejectedApproval),
|
failSessionRunRecord(run, failedAt, error));
|
||||||
failedAt,
|
}
|
||||||
error,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return changed;
|
return changed;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
|||||||
import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling';
|
import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling';
|
||||||
import {
|
import {
|
||||||
normalizeApprovalPolicy,
|
normalizeApprovalPolicy,
|
||||||
normalizePendingApproval,
|
normalizePendingApprovalState,
|
||||||
} from '@shared/domain/approval';
|
} from '@shared/domain/approval';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
import { nowIso } from '@shared/utils/ids';
|
import { nowIso } from '@shared/utils/ids';
|
||||||
@@ -72,7 +72,10 @@ export class WorkspaceRepository {
|
|||||||
...session,
|
...session,
|
||||||
runs: normalizeSessionRunRecords(session.runs),
|
runs: normalizeSessionRunRecords(session.runs),
|
||||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||||
pendingApproval: normalizePendingApproval(session.pendingApproval),
|
...normalizePendingApprovalState({
|
||||||
|
pendingApproval: session.pendingApproval,
|
||||||
|
pendingApprovalQueue: session.pendingApprovalQueue,
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
settings: normalizeWorkspaceSettings(stored.settings),
|
settings: normalizeWorkspaceSettings(stored.settings),
|
||||||
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
|
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export interface PendingApprovalRecord {
|
|||||||
messages?: PendingApprovalMessageRecord[];
|
messages?: PendingApprovalMessageRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingApprovalState {
|
||||||
|
pendingApproval?: PendingApprovalRecord;
|
||||||
|
pendingApprovalQueue?: PendingApprovalRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
const approvalCheckpointKinds: ApprovalCheckpointKind[] = ['tool-call', 'final-response'];
|
const approvalCheckpointKinds: ApprovalCheckpointKind[] = ['tool-call', 'final-response'];
|
||||||
const approvalCheckpointKindSet = new Set<ApprovalCheckpointKind>(approvalCheckpointKinds);
|
const approvalCheckpointKindSet = new Set<ApprovalCheckpointKind>(approvalCheckpointKinds);
|
||||||
const approvalStatusSet = new Set<ApprovalStatus>(['pending', 'approved', 'rejected']);
|
const approvalStatusSet = new Set<ApprovalStatus>(['pending', 'approved', 'rejected']);
|
||||||
@@ -174,6 +179,58 @@ export function resolvePendingApproval(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listPendingApprovals(
|
||||||
|
state: Partial<PendingApprovalState>,
|
||||||
|
): PendingApprovalRecord[] {
|
||||||
|
const pendingApprovals: PendingApprovalRecord[] = [];
|
||||||
|
const seenApprovalIds = new Set<string>();
|
||||||
|
|
||||||
|
function appendApproval(approval?: Partial<PendingApprovalRecord>) {
|
||||||
|
const normalized = normalizePendingApproval(approval);
|
||||||
|
if (!normalized || normalized.status !== 'pending' || seenApprovalIds.has(normalized.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenApprovalIds.add(normalized.id);
|
||||||
|
pendingApprovals.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendApproval(state.pendingApproval);
|
||||||
|
for (const queuedApproval of state.pendingApprovalQueue ?? []) {
|
||||||
|
appendApproval(queuedApproval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pendingApprovals;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePendingApprovalState(
|
||||||
|
state: Partial<PendingApprovalState>,
|
||||||
|
): PendingApprovalState {
|
||||||
|
return splitPendingApprovalState(listPendingApprovals(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueuePendingApprovalState(
|
||||||
|
state: Partial<PendingApprovalState>,
|
||||||
|
approval: PendingApprovalRecord,
|
||||||
|
): PendingApprovalState {
|
||||||
|
return normalizePendingApprovalState({
|
||||||
|
pendingApproval: state.pendingApproval,
|
||||||
|
pendingApprovalQueue: [
|
||||||
|
...(state.pendingApprovalQueue ?? []),
|
||||||
|
approval,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dequeuePendingApprovalState(
|
||||||
|
state: Partial<PendingApprovalState>,
|
||||||
|
approvalId: string,
|
||||||
|
): PendingApprovalState {
|
||||||
|
return splitPendingApprovalState(
|
||||||
|
listPendingApprovals(state).filter((approval) => approval.id !== approvalId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePendingApprovalMessages(
|
function normalizePendingApprovalMessages(
|
||||||
messages?: ReadonlyArray<Partial<PendingApprovalMessageRecord>>,
|
messages?: ReadonlyArray<Partial<PendingApprovalMessageRecord>>,
|
||||||
): PendingApprovalMessageRecord[] | undefined {
|
): PendingApprovalMessageRecord[] | undefined {
|
||||||
@@ -198,6 +255,22 @@ function normalizePendingApprovalMessages(
|
|||||||
return normalized.length > 0 ? normalized : undefined;
|
return normalized.length > 0 ? normalized : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitPendingApprovalState(
|
||||||
|
approvals: readonly PendingApprovalRecord[],
|
||||||
|
): PendingApprovalState {
|
||||||
|
if (approvals.length === 0) {
|
||||||
|
return {
|
||||||
|
pendingApproval: undefined,
|
||||||
|
pendingApprovalQueue: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingApproval: approvals[0],
|
||||||
|
pendingApprovalQueue: approvals.length > 1 ? approvals.slice(1) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeOptionalString(value: string | undefined): string | undefined {
|
function normalizeOptionalString(value: string | undefined): string | undefined {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface SessionRecord {
|
|||||||
scratchpadConfig?: ScratchpadSessionConfig;
|
scratchpadConfig?: ScratchpadSessionConfig;
|
||||||
tooling?: SessionToolingSelection;
|
tooling?: SessionToolingSelection;
|
||||||
pendingApproval?: PendingApprovalRecord;
|
pendingApproval?: PendingApprovalRecord;
|
||||||
|
pendingApprovalQueue?: PendingApprovalRecord[];
|
||||||
runs: SessionRunRecord[];
|
runs: SessionRunRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export function duplicateSessionRecord(
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
pendingApproval: undefined,
|
pendingApproval: undefined,
|
||||||
|
pendingApprovalQueue: undefined,
|
||||||
runs: [],
|
runs: [],
|
||||||
messages: session.messages.map((message): ChatMessageRecord => ({
|
messages: session.messages.map((message): ChatMessageRecord => ({
|
||||||
...message,
|
...message,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
dequeuePendingApprovalState,
|
||||||
|
enqueuePendingApprovalState,
|
||||||
|
listPendingApprovals,
|
||||||
approvalPolicyRequiresCheckpoint,
|
approvalPolicyRequiresCheckpoint,
|
||||||
|
normalizePendingApprovalState,
|
||||||
normalizeApprovalPolicy,
|
normalizeApprovalPolicy,
|
||||||
normalizePendingApproval,
|
normalizePendingApproval,
|
||||||
} from '@shared/domain/approval';
|
} from '@shared/domain/approval';
|
||||||
@@ -64,4 +68,94 @@ describe('approval helpers', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes legacy active approval plus queued approvals into a stable pending state', () => {
|
||||||
|
expect(normalizePendingApprovalState({
|
||||||
|
pendingApproval: {
|
||||||
|
id: 'approval-1',
|
||||||
|
kind: 'tool-call',
|
||||||
|
status: 'pending',
|
||||||
|
requestedAt: '2026-03-24T10:00:00.000Z',
|
||||||
|
title: 'Approve tool access',
|
||||||
|
},
|
||||||
|
pendingApprovalQueue: [
|
||||||
|
{
|
||||||
|
id: 'approval-1',
|
||||||
|
kind: 'tool-call',
|
||||||
|
status: 'pending',
|
||||||
|
requestedAt: '2026-03-24T10:00:00.000Z',
|
||||||
|
title: 'Approve tool access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approval-2',
|
||||||
|
kind: 'final-response',
|
||||||
|
status: 'pending',
|
||||||
|
requestedAt: '2026-03-24T10:01:00.000Z',
|
||||||
|
title: 'Approve final response',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approval-3',
|
||||||
|
kind: 'tool-call',
|
||||||
|
status: 'approved',
|
||||||
|
requestedAt: '2026-03-24T10:02:00.000Z',
|
||||||
|
resolvedAt: '2026-03-24T10:03:00.000Z',
|
||||||
|
title: 'Already resolved',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})).toEqual({
|
||||||
|
pendingApproval: {
|
||||||
|
id: 'approval-1',
|
||||||
|
kind: 'tool-call',
|
||||||
|
status: 'pending',
|
||||||
|
requestedAt: '2026-03-24T10:00:00.000Z',
|
||||||
|
title: 'Approve tool access',
|
||||||
|
},
|
||||||
|
pendingApprovalQueue: [
|
||||||
|
{
|
||||||
|
id: 'approval-2',
|
||||||
|
kind: 'final-response',
|
||||||
|
status: 'pending',
|
||||||
|
requestedAt: '2026-03-24T10:01:00.000Z',
|
||||||
|
title: 'Approve final response',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enqueues and dequeues pending approvals while keeping the first approval active', () => {
|
||||||
|
const state = enqueuePendingApprovalState(
|
||||||
|
{
|
||||||
|
pendingApproval: {
|
||||||
|
id: 'approval-1',
|
||||||
|
kind: 'tool-call',
|
||||||
|
status: 'pending',
|
||||||
|
requestedAt: '2026-03-24T10:00:00.000Z',
|
||||||
|
title: 'Approve tool access',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approval-2',
|
||||||
|
kind: 'final-response',
|
||||||
|
status: 'pending',
|
||||||
|
requestedAt: '2026-03-24T10:01:00.000Z',
|
||||||
|
title: 'Approve final response',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(listPendingApprovals(state).map((approval) => approval.id)).toEqual([
|
||||||
|
'approval-1',
|
||||||
|
'approval-2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(dequeuePendingApprovalState(state, 'approval-1')).toEqual({
|
||||||
|
pendingApproval: {
|
||||||
|
id: 'approval-2',
|
||||||
|
kind: 'final-response',
|
||||||
|
status: 'pending',
|
||||||
|
requestedAt: '2026-03-24T10:01:00.000Z',
|
||||||
|
title: 'Approve final response',
|
||||||
|
},
|
||||||
|
pendingApprovalQueue: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,6 +129,15 @@ describe('session library helpers', () => {
|
|||||||
requestedAt: '2026-03-23T00:01:00.000Z',
|
requestedAt: '2026-03-23T00:01:00.000Z',
|
||||||
title: 'Approve tool access',
|
title: 'Approve tool access',
|
||||||
},
|
},
|
||||||
|
pendingApprovalQueue: [
|
||||||
|
{
|
||||||
|
id: 'approval-2',
|
||||||
|
kind: 'final-response',
|
||||||
|
status: 'pending',
|
||||||
|
requestedAt: '2026-03-23T00:02:00.000Z',
|
||||||
|
title: 'Approve final response',
|
||||||
|
},
|
||||||
|
],
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
id: 'msg-1',
|
id: 'msg-1',
|
||||||
@@ -157,6 +166,7 @@ describe('session library helpers', () => {
|
|||||||
});
|
});
|
||||||
expect(session.messages[0]?.pending).toBe(false);
|
expect(session.messages[0]?.pending).toBe(false);
|
||||||
expect(session.pendingApproval).toBeUndefined();
|
expect(session.pendingApproval).toBeUndefined();
|
||||||
|
expect(session.pendingApprovalQueue).toBeUndefined();
|
||||||
expect(session.runs).toEqual([]);
|
expect(session.runs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user