mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 05:06:09 +00:00
* fix(core): make indexed access explicit in auto-reply, infra, and config Part 1/3 of the src NUIA phase-3b burn-down (#104600): iteration and destructuring over index reads, boundary guards on parsed input, and named invariants. Config path walkers bind the path head once; SQLite migration key handling is hoisted without query-shape changes. * fix(core): make indexed access explicit in cli, gateway, commands, security, shared Part 2/3: argv/token selection restructured, gateway event/attachment invariants named, security parsers stay fail-closed (invariant violations throw), edit-distance matrices access checked entries. * fix(core): make indexed access explicit across remaining src surfaces Part 3/3: channels, plugins, process, cron, plugin-sdk, media, logging, tui, hooks, daemon, and small directories. Latent bug fixed: a tailnet resolver could leak undefined through a string|null contract and now fails with a descriptive local error. * fix(core): keep optional boundaries optional after per-commit review Review findings: expectDefined misused where absence is a legitimate state. CLI --profile/route-args missing next tokens take their existing miss paths; help normalization compares --help against the last positional again; first-time plugin install spreads absent cfg.plugins; denylist scan iterates manifest dependency entries instead of throwing on omitted sections; tailnet resolver returns a guaranteed string at the source instead of a caller-side undefined throw. * refactor(core): closed-key provider labels and honest optional passthroughs PROVIDER_LABELS becomes a satisfies-typed closed record (static reads provably defined; dynamic lookups go through providerUsageLabel with honest string|undefined). Status-scan overview passes its optional params through unchanged instead of asserting them. * fix(channels): make getChatChannelMeta honestly optional The original signature claimed ChatChannelMeta while leaking undefined on bundled channel id metadata drift; three of four callers already handled absence. The return type now says so, and the one assuming caller falls back to the raw channel label. * fix(core): index-safety for post-rebase main drift Covers the sqlite-sessions flip and auth-source-plan code that landed mid-phase, plus the channel-validation test consuming the now honestly optional getChatChannelMeta. * refactor(channels): split chat-meta accessors along the SDK contract getChatChannelMeta keeps its shipped plugin-SDK signature (defined for bundled ids, fail-loud on impossible misses); new findChatChannelMeta carries the drift-tolerant optional contract for core auto-enable and formatting paths. * fix(qa-channel): own channel metadata instead of a guaranteed-undefined catalog lookup qa-channel spread getChatChannelMeta over an id that is never in the bundled catalog, shipping an empty setup meta by accident; the fail-loud SDK accessor exposed it. The channel now declares its metadata once. * fix(gateway): heartbeat projection lookahead is optional at the transcript tail expectDefined wrapped messages[i + 1] whose absence on the final message is the normal case; the adjacent ternary already handled it. Restores the plain optional read with an explicit guard in the pair condition. * fix(plugin-sdk): channel plugin factory tolerates non-bundled channel ids again createChannelPluginBase spreads bundled catalog meta for ANY channel id, where absence is the normal case for external plugins; the resolver is honestly optional again while the exported bundled-id accessor keeps the fail-loud contract. * fix(core): spreads of optional config sections stay optional Fresh-setup and first-install paths (crestodian setup inference, hook installs, agent config base, target agent models) legitimately lack the section being rebuilt; spreading undefined is the shipped {} semantics. Removes the remaining gratuitous assertion wraps found by tree audit.
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import { expectDefined } from "@openclaw/normalization-core";
|
|
/**
|
|
* Shared queue overflow, debounce, and collection helpers.
|
|
*
|
|
* Queue owners use these helpers to cap pending work, summarize dropped items,
|
|
* debounce drains, and force individual collection when cross-channel ordering matters.
|
|
*/
|
|
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
|
|
|
/** Mutable summary state for a capped queue. */
|
|
type QueueSummaryState = {
|
|
dropPolicy: "summarize" | "old" | "new";
|
|
droppedCount: number;
|
|
summaryLines: string[];
|
|
};
|
|
|
|
/** Queue overflow strategy. */
|
|
type QueueDropPolicy = QueueSummaryState["dropPolicy"];
|
|
|
|
/** Generic capped queue state with shared overflow summary fields. */
|
|
type QueueState<T> = QueueSummaryState & {
|
|
items: T[];
|
|
cap: number;
|
|
};
|
|
|
|
/** Clear accumulated overflow summary state after it has been emitted. */
|
|
export function clearQueueSummaryState(state: QueueSummaryState): void {
|
|
state.droppedCount = 0;
|
|
state.summaryLines = [];
|
|
}
|
|
|
|
/** Build a summary prompt preview without mutating the source queue state. */
|
|
export function previewQueueSummaryPrompt(params: {
|
|
state: QueueSummaryState;
|
|
noun: string;
|
|
title?: string;
|
|
}): string | undefined {
|
|
return buildQueueSummaryPrompt({
|
|
state: {
|
|
dropPolicy: params.state.dropPolicy,
|
|
droppedCount: params.state.droppedCount,
|
|
summaryLines: [...params.state.summaryLines],
|
|
},
|
|
noun: params.noun,
|
|
title: params.title,
|
|
});
|
|
}
|
|
|
|
/** Apply runtime queue settings while preserving previous values for omitted fields. */
|
|
export function applyQueueRuntimeSettings<TMode extends string>(params: {
|
|
target: {
|
|
mode: TMode;
|
|
debounceMs: number;
|
|
cap: number;
|
|
dropPolicy: QueueDropPolicy;
|
|
};
|
|
settings: {
|
|
mode: TMode;
|
|
debounceMs?: number;
|
|
cap?: number;
|
|
dropPolicy?: QueueDropPolicy;
|
|
};
|
|
}): void {
|
|
params.target.mode = params.settings.mode;
|
|
params.target.debounceMs =
|
|
typeof params.settings.debounceMs === "number"
|
|
? Math.max(0, params.settings.debounceMs)
|
|
: params.target.debounceMs;
|
|
params.target.cap =
|
|
typeof params.settings.cap === "number" && params.settings.cap > 0
|
|
? Math.floor(params.settings.cap)
|
|
: params.target.cap;
|
|
params.target.dropPolicy = params.settings.dropPolicy ?? params.target.dropPolicy;
|
|
}
|
|
|
|
/** Trim queue summary text to a bounded single-line preview. */
|
|
function elideQueueText(text: string, limit = 140): string {
|
|
if (text.length <= limit) {
|
|
return text;
|
|
}
|
|
return `${truncateUtf16Safe(text, Math.max(0, limit - 1)).trimEnd()}…`;
|
|
}
|
|
|
|
/** Normalize whitespace and elide one dropped item for queue summaries. */
|
|
function buildQueueSummaryLine(text: string, limit = 160): string {
|
|
const cleaned = text.replace(/\s+/g, " ").trim();
|
|
return elideQueueText(cleaned, limit);
|
|
}
|
|
|
|
/** Run optional duplicate detection before an item enters a queue. */
|
|
export function shouldSkipQueueItem<T>(params: {
|
|
item: T;
|
|
items: T[];
|
|
dedupe?: (item: T, items: T[]) => boolean;
|
|
}): boolean {
|
|
if (!params.dedupe) {
|
|
return false;
|
|
}
|
|
return params.dedupe(params.item, params.items);
|
|
}
|
|
|
|
/** Count identities that are still pending in the queue, excluding active deliveries. */
|
|
export function countPendingQueueItems<T>(items: readonly T[], inFlight?: ReadonlySet<T>): number {
|
|
if (!inFlight || inFlight.size === 0) {
|
|
return items.length;
|
|
}
|
|
return items.reduce((count, item) => count + (inFlight.has(item) ? 0 : 1), 0);
|
|
}
|
|
|
|
type DrainQueueItemOptions<T> = {
|
|
inFlight?: Set<T>;
|
|
shouldRestoreOnError?: (item: T) => boolean;
|
|
onDiscard?: (item: T) => void;
|
|
};
|
|
|
|
/** Apply overflow policy before enqueueing another item. */
|
|
export function applyQueueDropPolicy<T>(params: {
|
|
queue: QueueState<T>;
|
|
summarize: (item: T) => string;
|
|
summaryLimit?: number;
|
|
onDrop?: (items: T[]) => void;
|
|
inFlight?: ReadonlySet<T>;
|
|
isProtected?: (item: T) => boolean;
|
|
}): boolean {
|
|
const cap = params.queue.cap;
|
|
const pendingCount = countPendingQueueItems(params.queue.items, params.inFlight);
|
|
if (cap <= 0 || pendingCount < cap) {
|
|
return true;
|
|
}
|
|
if (params.queue.dropPolicy === "new") {
|
|
return false;
|
|
}
|
|
const dropCount = pendingCount - cap + 1;
|
|
// Collect victim indices first. In-flight identities stay until delivery
|
|
// succeeds; protected priority runs (e.g. stranded-reply retries) also stay.
|
|
// Only mutate the queue when enough victims exist so a partial drop cannot
|
|
// admit overflow when the queue is full of in-flight/protected work.
|
|
const victimIndices: number[] = [];
|
|
for (const [index, item] of params.queue.items.entries()) {
|
|
if (params.inFlight?.has(item) || params.isProtected?.(item) === true) {
|
|
continue;
|
|
}
|
|
victimIndices.push(index);
|
|
if (victimIndices.length === dropCount) {
|
|
break;
|
|
}
|
|
}
|
|
if (victimIndices.length < dropCount) {
|
|
return false;
|
|
}
|
|
const dropped: T[] = [];
|
|
for (let i = victimIndices.length - 1; i >= 0; i -= 1) {
|
|
dropped.unshift(
|
|
...params.queue.items.splice(expectDefined(victimIndices[i], "victim indices entry at i"), 1),
|
|
);
|
|
}
|
|
params.onDrop?.(dropped);
|
|
if (params.queue.dropPolicy === "summarize") {
|
|
for (const item of dropped) {
|
|
params.queue.droppedCount += 1;
|
|
params.queue.summaryLines.push(buildQueueSummaryLine(params.summarize(item)));
|
|
}
|
|
// Summary memory is bounded independently from the item cap to avoid prompt blowups.
|
|
const limit = Math.max(0, params.summaryLimit ?? cap);
|
|
while (params.queue.summaryLines.length > limit) {
|
|
params.queue.summaryLines.shift();
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Wait until the queue has been quiet for its debounce window. */
|
|
export function waitForQueueDebounce(
|
|
queue: {
|
|
debounceMs: number;
|
|
lastEnqueuedAt: number;
|
|
},
|
|
abortSignal?: AbortSignal,
|
|
): Promise<void> {
|
|
if (process.env.OPENCLAW_TEST_FAST === "1") {
|
|
// Tests use this escape hatch so debounce logic does not slow deterministic queue specs.
|
|
return Promise.resolve();
|
|
}
|
|
const debounceMs = Math.max(0, queue.debounceMs);
|
|
if (debounceMs <= 0) {
|
|
return Promise.resolve();
|
|
}
|
|
if (abortSignal?.aborted) {
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise<void>((resolve) => {
|
|
let settled = false;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const finish = () => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer);
|
|
}
|
|
abortSignal?.removeEventListener("abort", finish);
|
|
resolve();
|
|
};
|
|
const check = () => {
|
|
if (abortSignal?.aborted) {
|
|
finish();
|
|
return;
|
|
}
|
|
const since = Date.now() - queue.lastEnqueuedAt;
|
|
if (since >= debounceMs) {
|
|
finish();
|
|
return;
|
|
}
|
|
timer = setTimeout(check, debounceMs - since);
|
|
};
|
|
abortSignal?.addEventListener("abort", finish, { once: true });
|
|
check();
|
|
});
|
|
}
|
|
|
|
/** Mark one queue as draining unless another drain is already active. */
|
|
export function beginQueueDrain<T extends { draining: boolean }>(
|
|
map: Map<string, T>,
|
|
key: string,
|
|
): T | undefined {
|
|
const queue = map.get(key);
|
|
if (!queue || queue.draining) {
|
|
return undefined;
|
|
}
|
|
queue.draining = true;
|
|
return queue;
|
|
}
|
|
|
|
export function removeQueuedItemsByRef<T>(items: T[], processed: readonly T[]): void {
|
|
for (const item of processed) {
|
|
const idx = items.indexOf(item);
|
|
if (idx !== -1) {
|
|
items.splice(idx, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Run and remove the next queued item, returning false when empty. */
|
|
export async function drainNextQueueItem<T>(
|
|
items: T[],
|
|
run: (item: T) => Promise<void>,
|
|
options?: DrainQueueItemOptions<T>,
|
|
): Promise<boolean> {
|
|
const next = items[0];
|
|
if (!next) {
|
|
return false;
|
|
}
|
|
// Mark the item as in-flight so applyQueueDropPolicy skips it during the
|
|
// await window when the shared items array is still mutated by enqueuers.
|
|
options?.inFlight?.add(next);
|
|
try {
|
|
await run(next);
|
|
// Keep the identity protected until its successful by-reference removal.
|
|
removeQueuedItemsByRef(items, [next]);
|
|
} catch (error) {
|
|
if (!(options?.shouldRestoreOnError?.(next) ?? true)) {
|
|
removeQueuedItemsByRef(items, [next]);
|
|
options?.onDiscard?.(next);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
options?.inFlight?.delete(next);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Drain one item when collect mode requires individual processing. */
|
|
async function drainCollectItemIfNeeded<T>(params: {
|
|
forceIndividualCollect: boolean;
|
|
isCrossChannel: boolean;
|
|
setForceIndividualCollect?: (next: boolean) => void;
|
|
items: T[];
|
|
run: (item: T) => Promise<void>;
|
|
reserveOptions?: DrainQueueItemOptions<T>;
|
|
}): Promise<"skipped" | "drained" | "empty"> {
|
|
if (!params.forceIndividualCollect && !params.isCrossChannel) {
|
|
return "skipped";
|
|
}
|
|
if (params.isCrossChannel) {
|
|
// Once cross-channel items appear, future collection stays individual to preserve ordering.
|
|
params.setForceIndividualCollect?.(true);
|
|
}
|
|
const drained = await drainNextQueueItem(params.items, params.run, params.reserveOptions);
|
|
return drained ? "drained" : "empty";
|
|
}
|
|
|
|
/** Drain one collect step using mutable queue collection state. */
|
|
export async function drainCollectQueueStep<T>(params: {
|
|
collectState: { forceIndividualCollect: boolean };
|
|
isCrossChannel: boolean;
|
|
items: T[];
|
|
run: (item: T) => Promise<void>;
|
|
reserveOptions?: DrainQueueItemOptions<T>;
|
|
}): Promise<"skipped" | "drained" | "empty"> {
|
|
return await drainCollectItemIfNeeded({
|
|
forceIndividualCollect: params.collectState.forceIndividualCollect,
|
|
isCrossChannel: params.isCrossChannel,
|
|
setForceIndividualCollect: (next) => {
|
|
params.collectState.forceIndividualCollect = next;
|
|
},
|
|
items: params.items,
|
|
run: params.run,
|
|
reserveOptions: params.reserveOptions,
|
|
});
|
|
}
|
|
|
|
/** Build and consume the queue overflow summary prompt. */
|
|
function buildQueueSummaryPrompt(params: {
|
|
state: QueueSummaryState;
|
|
noun: string;
|
|
title?: string;
|
|
}): string | undefined {
|
|
if (params.state.dropPolicy !== "summarize" || params.state.droppedCount <= 0) {
|
|
return undefined;
|
|
}
|
|
const noun = params.noun;
|
|
const title =
|
|
params.title ??
|
|
`[Queue overflow] Dropped ${params.state.droppedCount} ${noun}${params.state.droppedCount === 1 ? "" : "s"} due to cap.`;
|
|
const lines = [title];
|
|
if (params.state.summaryLines.length > 0) {
|
|
lines.push("Summary:");
|
|
for (const line of params.state.summaryLines) {
|
|
lines.push(`- ${line}`);
|
|
}
|
|
}
|
|
clearQueueSummaryState(params.state);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/** Render a collect prompt from queued items and optional overflow summary. */
|
|
export function buildCollectPrompt<T>(params: {
|
|
title: string;
|
|
items: T[];
|
|
summary?: string;
|
|
renderItem: (item: T, index: number) => string;
|
|
}): string {
|
|
const blocks: string[] = [params.title];
|
|
if (params.summary) {
|
|
blocks.push(params.summary);
|
|
}
|
|
params.items.forEach((item, idx) => {
|
|
blocks.push(params.renderItem(item, idx));
|
|
});
|
|
return blocks.join("\n\n");
|
|
}
|
|
|
|
/** Return true when queued items span keys or explicitly mark cross-channel state. */
|
|
export function hasCrossChannelItems<T>(
|
|
items: T[],
|
|
resolveKey: (item: T) => { key?: string; cross?: boolean },
|
|
): boolean {
|
|
const keys = new Set<string>();
|
|
|
|
for (const item of items) {
|
|
const resolved = resolveKey(item);
|
|
if (resolved.cross) {
|
|
return true;
|
|
}
|
|
if (!resolved.key) {
|
|
continue;
|
|
}
|
|
keys.add(resolved.key);
|
|
}
|
|
|
|
return keys.size > 1;
|
|
}
|