UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

259 lines (258 loc) 10.1 kB
import { D as resolveExpiresAtMsFromDurationMs, F as resolveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js"; import { _ as retainGatewayRootWorkAdmissionContinuationScope } from "./gateway-work-admission-R1IpuDim.js"; import { r as getAsyncWorkSignal } from "./async-work-scope-CMQS2uTf.js"; import { randomUUID } from "node:crypto"; //#region src/gateway/question-manager.ts /** Grace period for late question.waitAnswer and question.get calls. */ const QUESTION_RESOLVED_ENTRY_GRACE_MS = 15e3; const QuestionManagerErrorCodes = { NOT_FOUND: "QUESTION_NOT_FOUND", ALREADY_TERMINAL: "QUESTION_ALREADY_TERMINAL", ID_IN_USE: "QUESTION_ID_IN_USE", INVALID_ANSWER: "QUESTION_INVALID_ANSWER", REQUESTER_INACTIVE: "QUESTION_REQUESTER_INACTIVE" }; var QuestionManagerError = class extends Error { constructor(code, message) { super(message); this.code = code; this.name = "QuestionManagerError"; } }; function unrefTimer(timer) { timer.unref?.(); } function waitResult(entry, includeResolutionId) { const { record, resolutionId } = entry; switch (record.status) { case "pending": return { status: "pending" }; case "answered": return { status: "answered", answers: record.answers ?? { answers: {} }, ...includeResolutionId && resolutionId ? { resolutionId } : {} }; case "cancelled": return { status: "cancelled" }; case "expired": return { status: "expired" }; } return record.status; } function resolvedEvent(record) { if (record.status === "pending") return null; return record.status === "answered" ? { id: record.id, status: record.status, answers: record.answers ?? { answers: {} } } : { id: record.id, status: record.status }; } /** Process-local lifecycle owner for pending questions. */ var QuestionManager = class { constructor() { this.entries = /* @__PURE__ */ new Map(); this.closed = false; } request(params) { if (this.closed) throw new Error("Question manager is closed"); if (params.isRequesterActive && !params.isRequesterActive()) throw new QuestionManagerError(QuestionManagerErrorCodes.REQUESTER_INACTIVE, "the agent run that requested this question is no longer active"); const createdAtMs = Date.now(); const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 1); const expiresAtMs = resolveExpiresAtMsFromDurationMs(timeoutMs, { nowMs: createdAtMs }); if (expiresAtMs === void 0) throw new Error("question expiry is unavailable"); const id = params.id ?? randomUUID(); if (this.entries.has(id)) throw new QuestionManagerError(QuestionManagerErrorCodes.ID_IN_USE, `question '${id}' already exists`); const record = { id, questions: params.questions, ...params.agentId ? { agentId: params.agentId } : {}, ...params.sessionKey ? { sessionKey: params.sessionKey } : {}, ...params.runId ? { runId: params.runId } : {}, createdAtMs, expiresAtMs, status: "pending" }; const entry = { record, expiryTimer: setTimeout(() => this.expire(record.id), timeoutMs), cleanupTimer: null, waiters: /* @__PURE__ */ new Set(), onResolved: params.onResolved, isRequesterActive: params.isRequesterActive, admissionContinuation: retainGatewayRootWorkAdmissionContinuationScope() }; this.entries.set(record.id, entry); entry.releaseHumanInputWait = params.registerHumanInputWait?.(() => this.get(id)?.status === "pending" && this.entries.get(id) === entry); unrefTimer(entry.expiryTimer); return record; } get(id) { const entry = this.entries.get(id); if (!entry) return null; if (entry.record.status === "pending" && entry.record.expiresAtMs <= Date.now()) this.expire(id); if (entry.record.status === "pending" && entry.isRequesterActive?.() === false) this.cancelEntry(entry, "requester-inactive"); return this.entries.get(id)?.record ?? null; } /** Called by the Gateway's existing authority-close observer. */ cancelClosedAuthorities() { for (const id of this.entries.keys()) this.get(id); } list() { const records = []; for (const id of this.entries.keys()) { const record = this.get(id); if (record?.status === "pending") records.push(record); } return records.toSorted((left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id)); } /** Re-enters only the still-pending question's original admitted root. */ runPendingContinuation(id, run) { this.get(id); const entry = this.entries.get(id); if (!entry?.admissionContinuation || entry.record.status !== "pending" || entry.record.expiresAtMs <= Date.now()) return null; return entry.admissionContinuation.run(run); } waitAnswer(id, timeoutMs, includeResolutionId = false) { const entry = this.requireEntry(id); if (entry.record.status !== "pending") return Promise.resolve(waitResult(entry, includeResolutionId)); const signal = getAsyncWorkSignal(); return new Promise((resolve) => { let timer; const waiter = () => { if (!entry.waiters.delete(waiter)) return; clearTimeout(timer); signal?.removeEventListener("abort", waiter); resolve(waitResult(entry, includeResolutionId)); }; entry.waiters.add(waiter); if (signal?.aborted) { waiter(); return; } signal?.addEventListener("abort", waiter, { once: true }); if (timeoutMs !== void 0) { timer = setTimeout(waiter, resolveTimerTimeoutMs(timeoutMs, 1)); unrefTimer(timer); } }); } resolve(id, answers, resolvedBy, options) { const entry = this.requirePendingEntry(id); const canonical = this.validateAnswers(entry.record.questions, answers); options?.commit?.(); entry.resolutionId = options?.resolutionId; entry.record = { ...entry.record, status: "answered", answers: canonical, ...resolvedBy ? { resolvedBy } : {} }; this.finish(entry); return { status: "answered", answers: canonical }; } cancel(id, resolvedBy) { const entry = this.requirePendingEntry(id); return this.cancelEntry(entry, resolvedBy); } cancelEntry(entry, resolvedBy) { entry.record = { ...entry.record, status: "cancelled", ...resolvedBy ? { resolvedBy } : {} }; this.finish(entry); return { status: "cancelled" }; } /** Retires this Gateway's owner only after received mutations have joined. */ close() { if (this.closed) return; this.closed = true; this.reset(); } /** Reusable on open owners (v2026.8.1 SDK context); never reopens a closed owner. */ reset() { for (const entry of this.entries.values()) { clearTimeout(entry.expiryTimer); const releaseHumanInputWait = entry.releaseHumanInputWait; entry.releaseHumanInputWait = void 0; releaseHumanInputWait?.(false); entry.admissionContinuation?.release(); entry.admissionContinuation = null; if (entry.cleanupTimer) clearTimeout(entry.cleanupTimer); for (const waiter of entry.waiters) waiter(); } this.entries.clear(); } requireEntry(id) { this.get(id); const entry = this.entries.get(id); if (!entry) throw this.notFound(id); return entry; } requirePendingEntry(id) { const entry = this.requireEntry(id); if (entry.record.status !== "pending") throw new QuestionManagerError(QuestionManagerErrorCodes.ALREADY_TERMINAL, `question '${id}' is already ${entry.record.status}`); return entry; } /** Validates answers against stored questions and returns them in canonical form. */ validateAnswers(questions, answers) { const submittedIds = Object.keys(answers.answers); const questionsById = new Map(questions.map((question) => [question.questionId, question])); const unknownId = submittedIds.find((id) => !questionsById.has(id)); if (unknownId) throw this.invalidAnswer(unknownId, "is not part of this request"); const canonical = { answers: {} }; for (const question of questions) { const values = Object.hasOwn(answers.answers, question.questionId) ? answers.answers[question.questionId] : void 0; if (!values || values.length === 0) throw this.invalidAnswer(question.questionId, "requires an answer"); if (values.some((value) => !value.trim())) throw this.invalidAnswer(question.questionId, "contains an empty answer"); if (!question.multiSelect && values.length > 1) throw this.invalidAnswer(question.questionId, "does not allow multiple answers"); const canonicalValues = values.map((value) => { const matched = question.options.find((option) => option.label.trim() === value.trim()); return matched ? matched.label : value.trim(); }); if (question.options.length > 0 && !question.isOther && canonicalValues.some((value) => !question.options.some((option) => option.label === value))) throw this.invalidAnswer(question.questionId, "contains an unknown option"); canonical.answers[question.questionId] = canonicalValues; } return canonical; } invalidAnswer(id, reason) { return new QuestionManagerError(QuestionManagerErrorCodes.INVALID_ANSWER, `question '${id}' ${reason}`); } notFound(id) { return new QuestionManagerError(QuestionManagerErrorCodes.NOT_FOUND, `question '${id}' was not found`); } expire(id) { const entry = this.entries.get(id); if (!entry || entry.record.status !== "pending") return; entry.record = { ...entry.record, status: "expired" }; this.finish(entry); } finish(entry) { clearTimeout(entry.expiryTimer); const releaseHumanInputWait = entry.releaseHumanInputWait; entry.releaseHumanInputWait = void 0; releaseHumanInputWait?.(entry.isRequesterActive?.() !== false); entry.isRequesterActive = void 0; entry.admissionContinuation?.release(); entry.admissionContinuation = null; for (const waiter of entry.waiters) waiter(); const event = resolvedEvent(entry.record); if (event) try { entry.onResolved?.(event); } catch {} if (this.entries.get(entry.record.id) !== entry) return; const cleanupTimer = setTimeout(() => { if (entry.cleanupTimer === cleanupTimer && this.entries.get(entry.record.id) === entry) this.entries.delete(entry.record.id); }, QUESTION_RESOLVED_ENTRY_GRACE_MS); entry.cleanupTimer = cleanupTimer; unrefTimer(cleanupTimer); } }; //#endregion export { QuestionManagerError as n, QuestionManagerErrorCodes as r, QuestionManager as t };