Skip to content

Commit 69fbe87

Browse files
Merge pull request #374 from corbitsdev/cl-4995-move-queued-approval-reconciliation-out-of-the-tui-into-the
Move queued-approval reconciliation into the permission layer
2 parents 34c1acf + 08c530d commit 69fbe87

4 files changed

Lines changed: 607 additions & 31 deletions

File tree

src/permission/queue.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { EventEmitter } from "node:events";
3+
import {
4+
createPermissionRequestQueue,
5+
wirePermissionGrantReconciliation,
6+
} from "./queue.js";
7+
import { isRequestCoveredByGrant } from "./gate.js";
8+
import { createPathRestriction } from "./path-restriction.js";
9+
import { createWorktreeRootsProvider } from "./worktree-roots.js";
10+
import type { Approval, ApprovalOutcome, PermissionRequest } from "./types.js";
11+
12+
function request(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
13+
return {
14+
tool: "run_shell",
15+
action: "Run",
16+
subject: "bun install",
17+
scopes: [],
18+
cwd: process.cwd(),
19+
...overrides,
20+
};
21+
}
22+
23+
// Mirrors the predicate PermissionGateOptions.onGrant hands callers: coverage
24+
// judged with the gate's own path restriction and project workspace, not
25+
// ones re-derived here.
26+
function coversFor(approval: Approval, activeProviderModel?: string): (r: PermissionRequest) => boolean {
27+
const cwd = process.cwd();
28+
const rootsProvider = createWorktreeRootsProvider(cwd);
29+
const isRestricted = createPathRestriction(cwd, rootsProvider).isRestricted;
30+
const workspace = { resolvedCwd: cwd, roots: rootsProvider() };
31+
return (r) =>
32+
isRequestCoveredByGrant(r, approval, activeProviderModel, isRestricted, workspace);
33+
}
34+
35+
describe("createPermissionRequestQueue", () => {
36+
test("settle resolves the enqueued request and removes it", () => {
37+
const queue = createPermissionRequestQueue();
38+
const outcomes: ApprovalOutcome[] = [];
39+
const id = queue.enqueue(request(), (o) => outcomes.push(o));
40+
expect(queue.size()).toBe(1);
41+
42+
expect(queue.settle(id, { allow: true })).toBe(true);
43+
expect(outcomes).toEqual([{ allow: true }]);
44+
expect(queue.size()).toBe(0);
45+
});
46+
47+
test("settle is a no-op once an id has already settled", () => {
48+
const queue = createPermissionRequestQueue();
49+
const outcomes: ApprovalOutcome[] = [];
50+
const id = queue.enqueue(request(), (o) => outcomes.push(o));
51+
52+
expect(queue.settle(id, { allow: true })).toBe(true);
53+
expect(queue.settle(id, { allow: false })).toBe(false);
54+
expect(outcomes).toEqual([{ allow: true }]);
55+
});
56+
57+
test("reconcile drains every queued request a grant now covers, in order", () => {
58+
const queue = createPermissionRequestQueue();
59+
const outcomes: ApprovalOutcome[] = [];
60+
for (let i = 0; i < 3; i++) {
61+
queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o));
62+
}
63+
// An unrelated request stays queued — the grant does not cover it.
64+
queue.enqueue(request({ subject: "bun test" }), (o) => outcomes.push(o));
65+
66+
const covers = coversFor({ tool: "run_shell", pattern: "bun install" });
67+
const settledIds = queue.reconcile(covers);
68+
69+
expect(settledIds).toHaveLength(3);
70+
expect(outcomes).toEqual([{ allow: true }, { allow: true }, { allow: true }]);
71+
expect(queue.size()).toBe(1);
72+
expect(queue.list().map((e) => e.tool)).toEqual(["run_shell"]);
73+
});
74+
75+
test("reconcile leaves requests from a different cwd queued for a project grant", () => {
76+
const queue = createPermissionRequestQueue();
77+
const outcomes: ApprovalOutcome[] = [];
78+
const cwd = process.cwd();
79+
queue.enqueue(
80+
request({ subject: "bun install", cwd: `${cwd}/other-repo` }),
81+
(o) => outcomes.push(o),
82+
);
83+
84+
const covers = coversFor({ tool: "run_shell", pattern: "bun install", cwd });
85+
queue.reconcile(covers);
86+
87+
expect(outcomes).toHaveLength(0);
88+
expect(queue.size()).toBe(1);
89+
});
90+
91+
test("reconcile is safe against settling mid-snapshot: no entry is skipped or double-visited", () => {
92+
const queue = createPermissionRequestQueue();
93+
let calls = 0;
94+
for (let i = 0; i < 5; i++) {
95+
queue.enqueue(request({ subject: "bun install" }), () => {
96+
calls++;
97+
});
98+
}
99+
queue.reconcile(coversFor({ tool: "run_shell", pattern: "bun install" }));
100+
expect(calls).toBe(5);
101+
expect(queue.size()).toBe(0);
102+
});
103+
104+
test("drain denies everything still queued instead of leaving a resolve hanging", () => {
105+
const queue = createPermissionRequestQueue();
106+
const outcomes: ApprovalOutcome[] = [];
107+
queue.enqueue(request(), (o) => outcomes.push(o));
108+
queue.enqueue(request({ subject: "bun test" }), (o) => outcomes.push(o));
109+
110+
queue.drain();
111+
112+
expect(outcomes).toEqual([{ allow: false }, { allow: false }]);
113+
expect(queue.size()).toBe(0);
114+
});
115+
});
116+
117+
describe("wirePermissionGrantReconciliation", () => {
118+
test("reconciles a queue against permission.grant events on the emitter", async () => {
119+
const emitter = new EventEmitter();
120+
const queue = createPermissionRequestQueue();
121+
const dispose = wirePermissionGrantReconciliation(emitter, queue);
122+
123+
const outcomes: ApprovalOutcome[] = [];
124+
queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o));
125+
queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o));
126+
127+
const approval: Approval = { tool: "run_shell", pattern: "bun install" };
128+
emitter.emit("permission.grant", { approval, covers: coversFor(approval) });
129+
130+
expect(outcomes).toEqual([{ allow: true }, { allow: true }]);
131+
expect(queue.size()).toBe(0);
132+
133+
dispose();
134+
});
135+
136+
test("ignores a malformed grant payload instead of throwing", () => {
137+
const emitter = new EventEmitter();
138+
const queue = createPermissionRequestQueue();
139+
const dispose = wirePermissionGrantReconciliation(emitter, queue);
140+
141+
queue.enqueue(request(), () => {
142+
throw new Error("must not settle on a malformed payload");
143+
});
144+
145+
expect(() => emitter.emit("permission.grant", { nope: true })).not.toThrow();
146+
expect(queue.size()).toBe(1);
147+
148+
dispose();
149+
});
150+
151+
test("dispose stops further reconciliation", () => {
152+
const emitter = new EventEmitter();
153+
const queue = createPermissionRequestQueue();
154+
const dispose = wirePermissionGrantReconciliation(emitter, queue);
155+
dispose();
156+
157+
const outcomes: ApprovalOutcome[] = [];
158+
queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o));
159+
const approval: Approval = { tool: "run_shell", pattern: "bun install" };
160+
emitter.emit("permission.grant", { approval, covers: coversFor(approval) });
161+
162+
expect(outcomes).toHaveLength(0);
163+
});
164+
});

src/permission/queue.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Headless queued-approval reconciliation. When a grant widens mid-run, the
3+
* permission layer decides which already-queued requests it now covers and
4+
* settles them without a prompt (see isRequestCoveredByGrant in gate.ts,
5+
* which supplies the `covers` predicate this module drains against). A
6+
* rendering surface only enqueues its pending requests and dispatches
7+
* whatever settle calls come back — it never decides coverage itself.
8+
*/
9+
10+
import type { EventEmitter } from "node:events";
11+
import type { ApprovalOutcome, PermissionRequest } from "./types.js";
12+
13+
export type QueuedApprovalSummary = {
14+
readonly id: number;
15+
readonly tool: string;
16+
readonly agentLabel?: string;
17+
};
18+
19+
export type PermissionRequestQueue = {
20+
/** Register a live request; the returned id is what settle/reconcile key on. */
21+
enqueue: (request: PermissionRequest, resolve: (outcome: ApprovalOutcome) => void) => number;
22+
/** Settle one entry (accept, deny, timeout, or abort). False once already settled. */
23+
settle: (id: number, outcome: ApprovalOutcome) => boolean;
24+
/** One line per still-queued request, for a queue-depth indicator. */
25+
list: () => readonly QueuedApprovalSummary[];
26+
/**
27+
* Auto-settle every queued request a newly-minted grant now covers, without
28+
* a prompt. Runs against a snapshot so settling mid-loop never skips or
29+
* double-visits an entry. Returns the ids settled.
30+
*/
31+
reconcile: (covers: (request: PermissionRequest) => boolean) => readonly number[];
32+
/** Deny and remove everything still queued (session teardown) so no awaited resolve is left hanging. */
33+
drain: () => void;
34+
size: () => number;
35+
};
36+
37+
export function createPermissionRequestQueue(): PermissionRequestQueue {
38+
const entries = new Map<
39+
number,
40+
{ request: PermissionRequest; resolve: (outcome: ApprovalOutcome) => void }
41+
>();
42+
let nextId = 1;
43+
44+
const settle = (id: number, outcome: ApprovalOutcome): boolean => {
45+
const entry = entries.get(id);
46+
if (entry === undefined) return false;
47+
entries.delete(id);
48+
entry.resolve(outcome);
49+
return true;
50+
};
51+
52+
return {
53+
enqueue: (request, resolve) => {
54+
const id = nextId++;
55+
entries.set(id, { request, resolve });
56+
return id;
57+
},
58+
settle,
59+
list: () =>
60+
[...entries.entries()].map(([id, entry]) => ({
61+
id,
62+
tool: entry.request.tool,
63+
...(entry.request.agentLabel !== undefined ? { agentLabel: entry.request.agentLabel } : {}),
64+
})),
65+
reconcile: (covers) => {
66+
const coveredIds = [...entries.entries()]
67+
.filter(([, entry]) => covers(entry.request))
68+
.map(([id]) => id);
69+
return coveredIds.filter((id) => settle(id, { allow: true }));
70+
},
71+
drain: () => {
72+
for (const id of [...entries.keys()]) settle(id, { allow: false });
73+
},
74+
size: () => entries.size,
75+
};
76+
}
77+
78+
export type PermissionGrantEvent = {
79+
readonly approval: { readonly tool: string; readonly pattern: string };
80+
readonly covers: (request: PermissionRequest) => boolean;
81+
};
82+
83+
// `covers` is a function, which arktype cannot express in a schema, so this
84+
// stays a plain runtime guard rather than the usual declarative boundary
85+
// validator — the shape is still checked field by field.
86+
function isPermissionGrantEvent(raw: unknown): raw is PermissionGrantEvent {
87+
if (raw === null || typeof raw !== "object") return false;
88+
const approval = (raw as Record<string, unknown>).approval;
89+
const covers = (raw as Record<string, unknown>).covers;
90+
if (approval === null || typeof approval !== "object") return false;
91+
const a = approval as Record<string, unknown>;
92+
return typeof a.tool === "string" && typeof a.pattern === "string" && typeof covers === "function";
93+
}
94+
95+
/**
96+
* Drain `queue` of any request a grant now covers whenever `permission.grant`
97+
* fires (see PermissionGateOptions.onGrant for where that event originates).
98+
* Any approval surface — TUI or headless — gets reconciliation for free by
99+
* enqueuing its pending requests into a PermissionRequestQueue and calling
100+
* this once, instead of reimplementing the walk.
101+
*/
102+
export function wirePermissionGrantReconciliation(
103+
emitter: EventEmitter,
104+
queue: PermissionRequestQueue,
105+
): () => void {
106+
const onGrant = (payload: unknown): void => {
107+
if (!isPermissionGrantEvent(payload)) return;
108+
queue.reconcile(payload.covers);
109+
};
110+
emitter.on("permission.grant", onGrant);
111+
return () => emitter.off("permission.grant", onGrant);
112+
}

0 commit comments

Comments
 (0)