openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
280 lines (279 loc) • 11.3 kB
JavaScript
import { F as resolveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js";
import "./src-vebZIeLe.js";
import { t as expectDefined } from "./expect-CyE8FADM.js";
import { o as normalizeLowercaseStringOrEmpty } from "./string-coerce-CIXf7egm.js";
import { t as createDeferredCore } from "./deferred-D0La5CRk.js";
import { C as runWithRetainedGatewayRootWork, i as captureGatewayRootWorkAdmissionContinuationScope } from "./gateway-work-admission-R1IpuDim.js";
import { r as getAsyncWorkSignal, t as AsyncWorkScope } from "./async-work-scope-CMQS2uTf.js";
//#region src/gateway/exec-approval-lifecycle.ts
const EXEC_APPROVAL_RESOLVED_ENTRY_GRACE_MS = 15e3;
/** Observer retirement is not an approval verdict or a durable expiry. */
var ApprovalObserverClosedError = class extends Error {
constructor() {
super("Gateway approval observer closed");
this.name = "ApprovalObserverClosedError";
}
};
/** Owns local observations and genuine decision effects, never durable decision policy. */
var ExecApprovalLifecycle = class {
constructor() {
this.pending = /* @__PURE__ */ new Map();
this.retired = false;
this.observingClosed = false;
this.observers = /* @__PURE__ */ new Set();
this.work = new AsyncWorkScope();
}
beginClose() {
this.observingClosed = true;
for (const close of this.observers) close();
}
retire() {
if (this.retired) return;
this.retired = true;
this.beginClose();
for (const [id, entry] of this.pending) {
clearTimeout(entry.timer ?? void 0);
clearTimeout(entry.cleanupTimer ?? void 0);
entry.timer = null;
entry.cleanupTimer = null;
entry.admissionContinuation?.release();
entry.admissionContinuation = null;
if (entry.record.resolvedAtMs === void 0) for (const handoff of entry.handoffs) handoff.cancel();
if (entry.handoffRetainCount === 0) this.pending.delete(id);
}
}
drain() {
this.retire();
this.draining ??= this.work.drain().then(() => {
this.pending.clear();
});
return this.draining;
}
trackActiveWork(run) {
this.assertNotRetired();
return this.work.track(() => runWithRetainedGatewayRootWork(run));
}
canUseRetainedBinding() {
return !this.retired || getAsyncWorkSignal() === this.work.signal;
}
assertNotRetired() {
if (this.retired) throw new ApprovalObserverClosedError();
}
registerEntry(record) {
const decision = createDeferredCore();
const entry = {
record,
resolve: decision.resolve,
timer: null,
cleanupTimer: null,
handoffRetainCount: 0,
handoffReleasedAtMs: null,
retainForManagerLifetime: false,
promise: decision.promise,
handoffs: /* @__PURE__ */ new Set(),
admissionContinuation: captureGatewayRootWorkAdmissionContinuationScope()
};
this.pending.set(record.id, entry);
this.scheduleExpiryTimer(entry);
return decision.promise;
}
/** Registers the real effect before an observer can leave or a synchronous verdict can win. */
registerDecisionHandoff(recordId, run) {
this.assertNotRetired();
const entry = expectDefined(this.pending.get(recordId), "registered approval handoff");
const releaseBinding = expectDefined(this.retainForHandoff(recordId), "live approval handoff");
const completion = createDeferredCore();
const handoff = {
start: (decision) => {
if (!entry.handoffs.delete(handoff)) return;
this.work.track(() => runWithRetainedGatewayRootWork(async () => {
try {
await Promise.resolve();
await run(decision);
} finally {
releaseBinding();
}
})).then(completion.resolve, completion.reject);
},
cancel: () => {
if (entry.handoffs.delete(handoff)) {
releaseBinding();
completion.reject(new ApprovalObserverClosedError());
}
}
};
entry.handoffs.add(handoff);
if (entry.record.resolvedAtMs !== void 0) handoff.start(entry.record.decision ?? entry.record.consumedDecision ?? null);
return {
observation: this.observeEntry(entry, completion.promise),
abandon: handoff.cancel
};
}
observeEntry(entry, completion) {
const signal = getAsyncWorkSignal();
return new Promise((resolve, reject) => {
let settled = false;
const finish = (settle) => {
if (settled) return;
settled = true;
this.observers.delete(onClose);
signal?.removeEventListener("abort", onClose);
settle();
};
const onClose = () => {
if (entry.record.resolvedAtMs === void 0) finish(() => reject(new ApprovalObserverClosedError()));
};
completion.then((value) => finish(() => resolve(value)), (error) => {
const failure = error instanceof Error ? error : new Error(String(error), { cause: error });
finish(() => reject(failure));
});
this.observers.add(onClose);
signal?.addEventListener("abort", onClose, { once: true });
if (this.observingClosed || signal?.aborted) onClose();
});
}
settleLocalEntry(params) {
const pending = this.pending.get(params.recordId);
if (!pending || pending.record.resolvedAtMs !== void 0 || this.retired) return false;
clearTimeout(pending.timer ?? void 0);
pending.timer = null;
pending.record.resolvedAtMs = params.resolvedAtMs;
if (params.decision === null) delete pending.record.decision;
else {
pending.record.decision = params.decision;
pending.record.resolutionSource = params.resolutionSource ?? "operator";
}
pending.record.resolvedBy = params.resolvedBy;
pending.record.resolverKind = params.resolverKind;
pending.record.status = params.status;
pending.record.terminalReason = params.terminalReason;
pending.record.runtimeEpoch = this.runtimeEpoch;
pending.record.consumedAtMs = params.consumedAtMs ?? null;
pending.record.consumedBy = params.consumedBy ?? null;
delete pending.record.mcpToolApprovalActive;
pending.retainForManagerLifetime ||= params.retainForManagerLifetime === true;
pending.admissionContinuation?.release();
pending.admissionContinuation = null;
for (const handoff of pending.handoffs) handoff.start(params.decision);
pending.resolve(params.decision);
this.scheduleResolvedCleanup(pending);
return true;
}
scheduleResolvedCleanup(entry) {
if (this.retired || entry.cleanupTimer || entry.record.resolvedAtMs === void 0 || entry.retainForManagerLifetime || entry.handoffRetainCount > 0) return;
const cleanupTimer = setTimeout(() => {
if (entry.cleanupTimer !== cleanupTimer) return;
entry.cleanupTimer = null;
if (this.pending.get(entry.record.id) === entry && entry.handoffRetainCount === 0) this.pending.delete(entry.record.id);
}, EXEC_APPROVAL_RESOLVED_ENTRY_GRACE_MS);
cleanupTimer.unref?.();
entry.cleanupTimer = cleanupTimer;
}
resolvedGraceAnchorMs(entry, nowMs) {
if (entry.record.resolvedAtMs === void 0) return null;
return entry.handoffRetainCount > 0 ? nowMs : entry.handoffReleasedAtMs ?? entry.record.resolvedAtMs;
}
/** Final release starts a fresh grace only while the manager still owns its lifecycle. */
retainForHandoff(recordId) {
const entry = this.pending.get(recordId);
if (!entry) return null;
const nowMs = Date.now();
const graceAnchorMs = this.resolvedGraceAnchorMs(entry, nowMs);
if (!entry.retainForManagerLifetime && graceAnchorMs !== null && entry.handoffRetainCount === 0 && nowMs - graceAnchorMs >= 15e3) {
this.pending.delete(recordId);
return null;
}
clearTimeout(entry.cleanupTimer ?? void 0);
entry.cleanupTimer = null;
entry.handoffRetainCount += 1;
let released = false;
return () => {
if (released) return;
released = true;
if (this.pending.get(recordId) !== entry) return;
entry.handoffRetainCount = Math.max(0, entry.handoffRetainCount - 1);
if (entry.handoffRetainCount === 0 && entry.record.resolvedAtMs !== void 0) {
entry.handoffReleasedAtMs = Date.now();
this.scheduleResolvedCleanup(entry);
}
};
}
scheduleExpiryTimer(entry) {
if (this.retired) return;
entry.timer = setTimeout(() => {
if (this.retired || this.pending.get(entry.record.id) !== entry) return;
try {
this.expireDue(entry.record.id);
} catch (error) {
this.reportError(error, {
approvalId: entry.record.id,
operation: "expire"
});
}
}, resolveTimerTimeoutMs(entry.record.expiresAtMs - Date.now(), 1));
}
getSnapshot(recordId) {
const entry = this.pending.get(recordId);
if (!entry) return null;
const nowMs = Date.now();
const graceAnchorMs = this.resolvedGraceAnchorMs(entry, nowMs);
if (entry.record.terminalReason !== "storage-corrupt" && graceAnchorMs !== null && nowMs - graceAnchorMs >= 15e3) {
this.pending.delete(recordId);
return null;
}
if (!this.retired && entry.record.resolvedAtMs === void 0 && entry.record.expiresAtMs <= nowMs) this.expireDue(recordId);
return entry.record;
}
/** Reads a live local binding without entering durable storage or mutating expiry. */
getLiveSnapshot(recordId) {
const entry = this.pending.get(recordId);
if (!entry) return null;
const nowMs = Date.now();
if (entry.record.resolvedAtMs === void 0) return entry.record.expiresAtMs > nowMs ? entry.record : null;
const graceAnchorMs = this.resolvedGraceAnchorMs(entry, nowMs);
return graceAnchorMs !== null && nowMs - graceAnchorMs < 15e3 ? entry.record : null;
}
/** Re-enters only the pending approval's exact original root. */
runPendingContinuation(recordId, run) {
const entry = this.pending.get(recordId);
if (this.retired || !entry?.admissionContinuation || entry.record.resolvedAtMs !== void 0 || entry.record.expiresAtMs <= Date.now()) return null;
return entry.admissionContinuation.run(run);
}
listPendingRecords() {
if (this.retired) return [];
const nowMs = Date.now();
for (const entry of this.pending.values()) if (entry.record.resolvedAtMs === void 0 && entry.record.expiresAtMs <= nowMs) this.expireDue(entry.record.id);
return Array.from(this.pending.values(), (entry) => entry.record).filter((record) => record.resolvedAtMs === void 0);
}
lookupApprovalId(input, opts = {}) {
const rawExact = this.getSnapshot(input);
if (rawExact) return (opts.includeResolved || rawExact.resolvedAtMs === void 0) && (opts.filter?.(rawExact) ?? true) ? {
kind: "exact",
id: input
} : { kind: "none" };
const normalized = input.trim();
if (!normalized) return { kind: "none" };
const exact = this.getSnapshot(normalized);
if (exact) return (opts.includeResolved || exact.resolvedAtMs === void 0) && (opts.filter?.(exact) ?? true) ? {
kind: "exact",
id: normalized
} : { kind: "none" };
const lowerPrefix = normalizeLowercaseStringOrEmpty(normalized);
const candidates = new Map(Array.from(this.pending.values(), (entry) => [entry.record.id, entry.record]));
for (const record of this.listPendingRecords()) candidates.set(record.id, record);
const matches = [];
for (const [id, record] of candidates) {
if (!opts.includeResolved && record.resolvedAtMs !== void 0 || opts.filter?.(record) === false) continue;
if (normalizeLowercaseStringOrEmpty(id).startsWith(lowerPrefix)) matches.push(id);
}
return matches.length === 1 ? {
kind: "prefix",
id: expectDefined(matches[0], "approval prefix match")
} : matches.length > 1 ? {
kind: "ambiguous",
ids: matches
} : { kind: "none" };
}
};
//#endregion
export { EXEC_APPROVAL_RESOLVED_ENTRY_GRACE_MS as n, ExecApprovalLifecycle as r, ApprovalObserverClosedError as t };