feat(gateway): add register and awaitDecision methods to ExecApprovalManager
Separates registration (synchronous) from waiting (async) to allow callers to confirm registration before the decision is made. Adds grace period for resolved entries to prevent race conditions.
This commit is contained in:
parent
9688454a30
commit
fa7eeca7ce
@ -28,6 +28,7 @@ type PendingEntry = {
|
||||
resolve: (decision: ExecApprovalDecision | null) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
promise: Promise<ExecApprovalDecision | null>;
|
||||
};
|
||||
|
||||
export class ExecApprovalManager {
|
||||
@ -49,17 +50,47 @@ export class ExecApprovalManager {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an approval record and return a promise that resolves when the decision is made.
|
||||
* This separates registration (synchronous) from waiting (async), allowing callers to
|
||||
* confirm registration before the decision is made.
|
||||
*/
|
||||
register(record: ExecApprovalRecord, timeoutMs: number): Promise<ExecApprovalDecision | null> {
|
||||
let resolvePromise: (decision: ExecApprovalDecision | null) => void;
|
||||
let rejectPromise: (err: Error) => void;
|
||||
const promise = new Promise<ExecApprovalDecision | null>((resolve, reject) => {
|
||||
resolvePromise = resolve;
|
||||
rejectPromise = reject;
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
const entry = this.pending.get(record.id);
|
||||
resolvePromise(null);
|
||||
// Keep entry briefly for in-flight awaitDecision calls
|
||||
setTimeout(() => {
|
||||
if (this.pending.get(record.id) === entry) {
|
||||
this.pending.delete(record.id);
|
||||
}
|
||||
}, 5000);
|
||||
}, timeoutMs);
|
||||
// Registration happens synchronously here
|
||||
this.pending.set(record.id, {
|
||||
record,
|
||||
resolve: resolvePromise!,
|
||||
reject: rejectPromise!,
|
||||
timer,
|
||||
promise,
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use register() instead for explicit separation of registration and waiting.
|
||||
*/
|
||||
async waitForDecision(
|
||||
record: ExecApprovalRecord,
|
||||
timeoutMs: number,
|
||||
): Promise<ExecApprovalDecision | null> {
|
||||
return await new Promise<ExecApprovalDecision | null>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(record.id);
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
this.pending.set(record.id, { record, resolve, reject, timer });
|
||||
});
|
||||
return this.register(record, timeoutMs);
|
||||
}
|
||||
|
||||
resolve(recordId: string, decision: ExecApprovalDecision, resolvedBy?: string | null): boolean {
|
||||
@ -69,8 +100,15 @@ export class ExecApprovalManager {
|
||||
pending.record.resolvedAtMs = Date.now();
|
||||
pending.record.decision = decision;
|
||||
pending.record.resolvedBy = resolvedBy ?? null;
|
||||
this.pending.delete(recordId);
|
||||
// Resolve the promise first, then delete after a grace period.
|
||||
// This allows in-flight awaitDecision calls to find the resolved entry.
|
||||
pending.resolve(decision);
|
||||
setTimeout(() => {
|
||||
// Only delete if the entry hasn't been replaced
|
||||
if (this.pending.get(recordId) === pending) {
|
||||
this.pending.delete(recordId);
|
||||
}
|
||||
}, 5000);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -78,4 +116,13 @@ export class ExecApprovalManager {
|
||||
const entry = this.pending.get(recordId);
|
||||
return entry?.record ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for decision on an already-registered approval.
|
||||
* Returns the decision promise if the ID is pending, null otherwise.
|
||||
*/
|
||||
awaitDecision(recordId: string): Promise<ExecApprovalDecision | null> | null {
|
||||
const entry = this.pending.get(recordId);
|
||||
return entry?.promise ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user