From 313daad5496aedcdd69e0afb526d629cabf7a6d8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 19:37:13 -0400 Subject: [PATCH] perf(gateway): warm handler families and cache identity avatars (#114754) * perf(gateway): warm handler families and cache identity avatars * chore: drop changelog edit (release-generation-owned) * fix(ci): regenerate protocol models, satisfy knip, absorb sidecar drift - commit regenerated Swift GatewayModels for the additive cron.list param - split the avatar data-url cache into assistant-avatar-cache.ts so its factory has a production consumer (knip production scan) - de-export prewarm family names/type; test now drives fake families only - postReadySidecarCount 2 -> 3: main added a post-ready sidecar in parallel --- .../OpenClawProtocol/GatewayModels.swift | 6 +- .../src/cron-validators.test.ts | 1 + packages/gateway-protocol/src/schema/cron.ts | 1 + src/agents/identity-avatar-file.ts | 19 +- src/gateway/assistant-avatar-cache.ts | 72 ++++ src/gateway/assistant-avatar.test.ts | 36 +- src/gateway/assistant-avatar.ts | 6 +- src/gateway/server-methods.ts | 10 +- src/gateway/server-methods/agent-identity.ts | 4 + .../server-methods/agent.test-harness.ts | 7 +- src/gateway/server-methods/agent.ts | 4 +- src/gateway/server-methods/cron.ts | 11 +- .../server-methods/lazy-core-handlers.test.ts | 34 ++ .../models-auth-status-usage-cache.ts | 216 +++++++++++ .../server-methods/models-auth-status.test.ts | 347 ++++++++++++++++-- .../server-methods/models-auth-status.ts | 173 +++------ .../models-auth-status.types.ts | 60 +++ src/gateway/server-methods/tasks.ts | 3 +- .../server-startup-handler-prewarm.test.ts | 46 +++ src/gateway/server-startup-handler-prewarm.ts | 81 ++++ .../server-startup-post-attach.test.ts | 16 +- src/gateway/server-startup-post-attach.ts | 6 +- src/gateway/server.cron.test.ts | 9 + src/tasks/task-executor-cancel.runtime.ts | 44 +++ src/tasks/task-executor.ts | 34 +- ui/src/lib/cron/index.test.ts | 2 + ui/src/lib/cron/index.ts | 1 + ui/src/lib/cron/scope.ts | 3 + 28 files changed, 1045 insertions(+), 207 deletions(-) create mode 100644 src/gateway/assistant-avatar-cache.ts create mode 100644 src/gateway/server-methods/lazy-core-handlers.test.ts create mode 100644 src/gateway/server-methods/models-auth-status-usage-cache.ts create mode 100644 src/gateway/server-methods/models-auth-status.types.ts create mode 100644 src/gateway/server-startup-handler-prewarm.test.ts create mode 100644 src/gateway/server-startup-handler-prewarm.ts create mode 100644 src/tasks/task-executor-cancel.runtime.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 5e4a70590517..6a1ef14bad52 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -13506,6 +13506,7 @@ public struct CronListParams: Codable, Sendable { public let sortdir: AnyCodable? public let agentid: String? public let compact: Bool? + public let includedeliverypreviews: Bool? public init( includedisabled: Bool? = nil, @@ -13518,7 +13519,8 @@ public struct CronListParams: Codable, Sendable { sortby: AnyCodable? = nil, sortdir: AnyCodable? = nil, agentid: String? = nil, - compact: Bool? = nil) + compact: Bool? = nil, + includedeliverypreviews: Bool? = nil) { self.includedisabled = includedisabled self.limit = limit @@ -13531,6 +13533,7 @@ public struct CronListParams: Codable, Sendable { self.sortdir = sortdir self.agentid = agentid self.compact = compact + self.includedeliverypreviews = includedeliverypreviews } private enum CodingKeys: String, CodingKey { @@ -13545,6 +13548,7 @@ public struct CronListParams: Codable, Sendable { case sortdir = "sortDir" case agentid = "agentId" case compact + case includedeliverypreviews = "includeDeliveryPreviews" } } diff --git a/packages/gateway-protocol/src/cron-validators.test.ts b/packages/gateway-protocol/src/cron-validators.test.ts index 9797b50b7ab6..815eba8469dd 100644 --- a/packages/gateway-protocol/src/cron-validators.test.ts +++ b/packages/gateway-protocol/src/cron-validators.test.ts @@ -440,6 +440,7 @@ describe("cron protocol validators", () => { sortDir: "asc", agentId: "ops", compact: true, + includeDeliveryPreviews: false, }), ).toBe(true); expect(validateCronListParams({ offset: -1 })).toBe(false); diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts index 2a4791e2f9e6..98a8c0bb2100 100644 --- a/packages/gateway-protocol/src/schema/cron.ts +++ b/packages/gateway-protocol/src/schema/cron.ts @@ -569,6 +569,7 @@ export const CronListParamsSchema = closedObject({ sortDir: Type.Optional(CronSortDirSchema), agentId: Type.Optional(NonEmptyString), compact: Type.Optional(Type.Boolean()), + includeDeliveryPreviews: Type.Optional(Type.Boolean()), }); /** Empty request payload for scheduler status. */ diff --git a/src/agents/identity-avatar-file.ts b/src/agents/identity-avatar-file.ts index aab4930a162f..74f926c93bf5 100644 --- a/src/agents/identity-avatar-file.ts +++ b/src/agents/identity-avatar-file.ts @@ -29,6 +29,13 @@ type LocalAgentAvatarFailureReason = export type OpenedLocalAgentAvatarFile = { path: string; fd: number; + stat: { + ctimeMs: number; + dev: number; + ino: number; + mtimeMs: number; + size: number; + }; }; type LocalAgentAvatarPath = { @@ -97,7 +104,17 @@ function openResolvedLocalAgentAvatarFile( fs.closeSync(opened.fd); return null; } - return { path: opened.path, fd: opened.fd }; + return { + path: opened.path, + fd: opened.fd, + stat: { + ctimeMs: opened.stat.ctimeMs, + dev: opened.stat.dev, + ino: opened.stat.ino, + mtimeMs: opened.stat.mtimeMs, + size: opened.stat.size, + }, + }; } catch { return null; } diff --git a/src/gateway/assistant-avatar-cache.ts b/src/gateway/assistant-avatar-cache.ts new file mode 100644 index 000000000000..3d006446e7a7 --- /dev/null +++ b/src/gateway/assistant-avatar-cache.ts @@ -0,0 +1,72 @@ +// Bounded data-URL cache for locally resolved assistant avatars. Kept apart +// from the avatar projection so cache policy (identity validation, LRU bound) +// stays independently testable with injected read/close seams. +import fs from "node:fs"; +import { + readOpenedLocalAgentAvatarDataUrl, + type OpenedLocalAgentAvatarFile, +} from "../agents/identity-avatar-file.js"; + +type AvatarDataUrlCacheEntry = { + ctimeMs: number; + dev: number; + ino: number; + mtimeMs: number; + size: number; + dataUrl: string; +}; + +const GATEWAY_AVATAR_DATA_URL_CACHE_MAX_ENTRIES = 4; + +export function createGatewayAvatarDataUrlCache(params?: { + maxEntries?: number; + read?: (opened: OpenedLocalAgentAvatarFile) => string | undefined; + close?: (fd: number) => void; +}) { + const maxEntries = params?.maxEntries ?? GATEWAY_AVATAR_DATA_URL_CACHE_MAX_ENTRIES; + const read = params?.read ?? readOpenedLocalAgentAvatarDataUrl; + const close = params?.close ?? ((fd: number) => fs.closeSync(fd)); + const entries = new Map(); + return { + read(opened: OpenedLocalAgentAvatarFile): string | undefined { + const cached = entries.get(opened.path); + if ( + cached && + cached.ctimeMs === opened.stat.ctimeMs && + cached.dev === opened.stat.dev && + cached.ino === opened.stat.ino && + cached.mtimeMs === opened.stat.mtimeMs && + cached.size === opened.stat.size + ) { + close(opened.fd); + entries.delete(opened.path); + entries.set(opened.path, cached); + return cached.dataUrl; + } + entries.delete(opened.path); + const dataUrl = read(opened); + if (!dataUrl || maxEntries <= 0) { + return dataUrl; + } + // The boundary-safe open already fstats the pinned descriptor. Reuse base64 + // only while its file identity and mtime/size match; otherwise an atomic + // same-size replacement could leave stale identity data indefinitely. + entries.set(opened.path, { + ctimeMs: opened.stat.ctimeMs, + dev: opened.stat.dev, + ino: opened.stat.ino, + mtimeMs: opened.stat.mtimeMs, + size: opened.stat.size, + dataUrl, + }); + while (entries.size > maxEntries) { + const oldestPath = entries.keys().next().value; + if (oldestPath === undefined) { + break; + } + entries.delete(oldestPath); + } + return dataUrl; + }, + }; +} diff --git a/src/gateway/assistant-avatar.test.ts b/src/gateway/assistant-avatar.test.ts index c4c068fd42b9..ed20d7144582 100644 --- a/src/gateway/assistant-avatar.test.ts +++ b/src/gateway/assistant-avatar.test.ts @@ -1,9 +1,10 @@ // Gateway assistant-avatar tests cover selected-source precedence and safe fallbacks. import fs from "node:fs"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createGatewayAvatarDataUrlCache } from "./assistant-avatar-cache.js"; import { openGatewayAssistantAvatar, resolveGatewayAssistantAvatar } from "./assistant-avatar.js"; import { resolveAssistantIdentity } from "./assistant-identity.js"; @@ -31,6 +32,39 @@ function projectAvatar(cfg: OpenClawConfig): GatewayAssistantAvatarProjection { } describe("resolveGatewayAssistantAvatar", () => { + it("reuses unchanged pinned files and rereads after mtime or size changes", () => { + const read = vi.fn( + (opened: { stat: { mtimeMs: number; size: number } }) => + `data:image/png;base64,${opened.stat.mtimeMs}:${opened.stat.size}`, + ); + const close = vi.fn(); + const cache = createGatewayAvatarDataUrlCache({ maxEntries: 2, read, close }); + const opened = ( + fd: number, + mtimeMs: number, + size: number, + identity = { ctimeMs: 5, dev: 1, ino: 2 }, + ) => ({ + path: "/workspace/avatar.png", + fd, + stat: { ...identity, mtimeMs, size }, + }); + + expect(cache.read(opened(1, 10, 20))).toBe("data:image/png;base64,10:20"); + expect(cache.read(opened(2, 10, 20))).toBe("data:image/png;base64,10:20"); + expect(read).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledWith(2); + + expect(cache.read(opened(3, 11, 20))).toBe("data:image/png;base64,11:20"); + expect(cache.read(opened(4, 11, 21))).toBe("data:image/png;base64,11:21"); + expect(read).toHaveBeenCalledTimes(3); + + expect(cache.read(opened(5, 11, 21, { ctimeMs: 6, dev: 1, ino: 3 }))).toBe( + "data:image/png;base64,11:21", + ); + expect(read).toHaveBeenCalledTimes(4); + }); + it("inlines the selected local file", () => { const { cfg, workspace } = createWorkspace(); fs.writeFileSync(path.join(workspace, "avatar.png"), REAL_PNG); diff --git a/src/gateway/assistant-avatar.ts b/src/gateway/assistant-avatar.ts index 5b2693c7cd7e..04626cb7ca26 100644 --- a/src/gateway/assistant-avatar.ts +++ b/src/gateway/assistant-avatar.ts @@ -1,7 +1,6 @@ // Gateway assistant-avatar projection binds the selected value to effective metadata. import { openLocalAgentAvatarFile, - readOpenedLocalAgentAvatarDataUrl, type OpenedLocalAgentAvatarFile, } from "../agents/identity-avatar-file.js"; import type { AgentAvatarResolution } from "../agents/identity-avatar.js"; @@ -14,6 +13,7 @@ import { isWindowsAbsolutePath, looksLikeAvatarPath, } from "../shared/avatar-policy.js"; +import { createGatewayAvatarDataUrlCache } from "./assistant-avatar-cache.js"; import { DEFAULT_ASSISTANT_IDENTITY } from "./assistant-identity.js"; import { CONTROL_UI_AVATAR_PREFIX, normalizeControlUiBasePath } from "./control-ui-shared.js"; @@ -33,6 +33,8 @@ type OpenGatewayAssistantAvatarProjection = { openedFile?: OpenedLocalAgentAvatarFile; }; +const gatewayAvatarDataUrlCache = createGatewayAvatarDataUrlCache(); + function resolveSameOriginAvatarUrl(cfg: OpenClawConfig, source: string): string | undefined { const basePath = normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath); const unbasedPrefix = `${CONTROL_UI_AVATAR_PREFIX}/`; @@ -104,7 +106,7 @@ export function resolveGatewayAssistantAvatar(params: { return { avatar: source, resolution: opened.resolution }; } - const dataUrl = readOpenedLocalAgentAvatarDataUrl(opened.openedFile); + const dataUrl = gatewayAvatarDataUrlCache.read(opened.openedFile); if (!dataUrl) { return { avatar: identity.emoji ?? DEFAULT_ASSISTANT_IDENTITY.avatar, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 108baddad1d9..0753e7ab91dd 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -53,6 +53,10 @@ const loadAgentHandlers = lazyHandlerModule( () => import("./server-methods/agent.js"), (module) => module.agentHandlers, ); +const loadAgentIdentityHandlers = lazyHandlerModule( + () => import("./server-methods/agent-identity.js"), + (module) => module.agentIdentityHandlers, +); const loadAgentsHandlers = lazyHandlerModule( () => import("./server-methods/agents.js"), (module) => module.agentsHandlers, @@ -852,9 +856,13 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { loadHandlers: loadUsageHandlers, }), ...createLazyCoreHandlers({ - methods: ["agent", "agent.identity.get", "agent.wait"], + methods: ["agent", "agent.wait"], loadHandlers: loadAgentHandlers, }), + ...createLazyCoreHandlers({ + methods: ["agent.identity.get"], + loadHandlers: loadAgentIdentityHandlers, + }), ...createLazyCoreHandlers({ methods: [ "agents.list", diff --git a/src/gateway/server-methods/agent-identity.ts b/src/gateway/server-methods/agent-identity.ts index 4a21cae5fccf..8c3c35eed229 100644 --- a/src/gateway/server-methods/agent-identity.ts +++ b/src/gateway/server-methods/agent-identity.ts @@ -65,3 +65,7 @@ export const agentIdentityGetHandler: GatewayRequestHandlers["agent.identity.get undefined, ); }; + +export const agentIdentityHandlers: GatewayRequestHandlers = { + "agent.identity.get": agentIdentityGetHandler, +}; diff --git a/src/gateway/server-methods/agent.test-harness.ts b/src/gateway/server-methods/agent.test-harness.ts index c58362ba498a..468c5a2e8562 100644 --- a/src/gateway/server-methods/agent.test-harness.ts +++ b/src/gateway/server-methods/agent.test-harness.ts @@ -18,6 +18,7 @@ import { } from "../../tasks/task-runtime.test-helpers.js"; import { captureEnv, setTestEnvValue } from "../../test-utils/env.js"; import { createChatRunState } from "../server-chat-state.js"; +import { agentIdentityHandlers } from "./agent-identity.js"; import { agentHandlers } from "./agent.js"; import { suspendHandlers } from "./suspend.js"; import type { GatewayRequestContext } from "./types.js"; @@ -322,7 +323,7 @@ export type AgentParams = AgentHandlerArgs["params"]; export type AgentCommandCall = Record; -type AgentIdentityGetHandler = NonNullable<(typeof agentHandlers)["agent.identity.get"]>; +type AgentIdentityGetHandler = NonNullable<(typeof agentIdentityHandlers)["agent.identity.get"]>; type AgentIdentityGetHandlerArgs = Parameters[0]; @@ -894,8 +895,8 @@ export async function invokeAgentIdentityGet( ) { const respond = options?.respond ?? vi.fn(); await expectDefined( - agentHandlers["agent.identity.get"], - 'agentHandlers["agent.identity.get"] test invariant', + agentIdentityHandlers["agent.identity.get"], + 'agentIdentityHandlers["agent.identity.get"] test invariant', )({ params, respond: respond as never, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 6c3d8f22b3f9..a3008251b518 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -1,11 +1,9 @@ -import { agentIdentityGetHandler } from "./agent-identity.js"; import { agentRunHandler } from "./agent-run-handler.js"; import { agentWaitHandler } from "./agent-wait.js"; -// Gateway agent methods implement agent.run, agent.wait, and agent identity RPCs. +// Gateway agent methods implement agent.run and agent.wait RPCs. import type { GatewayRequestHandlers } from "./types.js"; export const agentHandlers: GatewayRequestHandlers = { agent: agentRunHandler, - "agent.identity.get": agentIdentityGetHandler, "agent.wait": agentWaitHandler, }; diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index def56fb1af5a..d63e805afb74 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -438,6 +438,7 @@ export const cronHandlers: GatewayRequestHandlers = { sortDir?: "asc" | "desc"; agentId?: string; compact?: boolean; + includeDeliveryPreviews?: boolean; }; const callerScope = readCronCallerScope(client); const requestedAgentId = p.agentId ? normalizeAgentId(p.agentId) : undefined; @@ -468,12 +469,20 @@ export const cronHandlers: GatewayRequestHandlers = { respond(true, { ...page, jobs: page.jobs.map(compactCronListJob) }, undefined); return; } + const jobs = page.jobs.map(cronJobReadView); + if (p.includeDeliveryPreviews === false) { + // Full job rows are the default because editors need their payloads. Delivery + // previews are independently suppressible so list-only callers avoid per-job I/O + // without weakening the shipped full-response default. + respond(true, { ...page, jobs }, undefined); + return; + } const deliveryPreviews = await resolveCronDeliveryPreviews({ cfg: context.getRuntimeConfig(), defaultAgentId: context.cron.getDefaultAgentId(), jobs: page.jobs, }); - respond(true, { ...page, jobs: page.jobs.map(cronJobReadView), deliveryPreviews }, undefined); + respond(true, { ...page, jobs, deliveryPreviews }, undefined); }, "cron.status": async ({ params, respond, context }) => { if (!assertValidParams(params, validateCronStatusParams, "cron.status", respond)) { diff --git a/src/gateway/server-methods/lazy-core-handlers.test.ts b/src/gateway/server-methods/lazy-core-handlers.test.ts new file mode 100644 index 000000000000..b5043ab1b861 --- /dev/null +++ b/src/gateway/server-methods/lazy-core-handlers.test.ts @@ -0,0 +1,34 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import { describe, expect, it, vi } from "vitest"; + +const runStackLoaded = vi.hoisted(() => vi.fn()); + +vi.mock("./agent-run-handler.js", () => { + runStackLoaded(); + return { agentRunHandler: vi.fn() }; +}); + +describe("lazy core handler families", () => { + it("loads agent identity without importing the agent run stack", async () => { + const { coreGatewayHandlers } = await import("../server-methods.js"); + const respond = vi.fn(); + await expectDefined( + coreGatewayHandlers["agent.identity.get"], + "agent.identity.get lazy handler", + )({ + req: { type: "req", id: "identity-light-family", method: "agent.identity.get" }, + params: { agentId: "main" }, + respond, + context: { getRuntimeConfig: () => ({}) } as never, + client: null, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ agentId: "main" }), + undefined, + ); + expect(runStackLoaded).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-methods/models-auth-status-usage-cache.ts b/src/gateway/server-methods/models-auth-status-usage-cache.ts new file mode 100644 index 000000000000..aa0f50ea84b5 --- /dev/null +++ b/src/gateway/server-methods/models-auth-status-usage-cache.ts @@ -0,0 +1,216 @@ +// Stale-while-revalidate cache for models.authStatus provider usage enrichment. +import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import { + fingerprintAuthProfileCredential, + fingerprintAuthProfileOwnerShape, + fingerprintResolvedProviderAuth, +} from "../../agents/execution-auth-binding.js"; +import { resolveUsableCustomProviderApiKey } from "../../agents/model-auth.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { loadProviderUsageSummary } from "../../infra/provider-usage.load.js"; +import type { ProviderUsageSnapshot, UsageProviderId } from "../../infra/provider-usage.types.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { formatForLog } from "../ws-log.js"; + +const log = createSubsystemLogger("models-auth-status"); +const USAGE_CACHE_TTL_MS = 60_000; + +export type ProviderUsageStatus = Pick< + ProviderUsageSnapshot, + "windows" | "summary" | "plan" | "billing" | "accountEmail" +>; + +type ProviderUsageCacheEntry = { + agentDir: string; + configRef: object; + credentialKey: string; + providerKey: string; + refreshedAt: number; + usageByProvider: Map; +}; + +type ProviderUsageRefresh = { + agentDir: string; + configRef: object; + credentialKey: string; + providerKey: string; +}; + +const usageCacheByAgentId = new Map(); +const usageRefreshByAgentId = new Map(); +let cacheGeneration = 0; + +function sortedRecordEntries(value: Record | undefined) { + return Object.entries(value ?? {}).toSorted(([left], [right]) => left.localeCompare(right)); +} + +export function fingerprintProviderUsageCredentials(params: { + cfg: OpenClawConfig; + directApiKeys: ReadonlyMap; + store: AuthProfileStore; +}): string { + const profiles = Object.entries(params.store.profiles) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([profileId, credential]) => { + const fingerprint = + fingerprintAuthProfileCredential({ profileId, credential }) ?? + fingerprintAuthProfileOwnerShape({ profileId, credential }); + return fingerprint ?? `${profileId}:${credential.type}:${credential.provider}`; + }); + const direct = [...params.directApiKeys] + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([provider, evidence]) => { + const configured = resolveUsableCustomProviderApiKey({ + cfg: params.cfg, + provider, + env: process.env, + }); + const envValue = evidence?.envVar ? process.env[evidence.envVar]?.trim() : undefined; + const resolved = + configured ?? + (envValue ? { apiKey: envValue, source: `env: ${evidence?.envVar}` } : undefined); + const fingerprint = resolved + ? fingerprintResolvedProviderAuth({ + apiKey: resolved.apiKey, + source: resolved.source, + mode: "api-key", + }) + : undefined; + return [provider, fingerprint ?? null]; + }); + // Profile selection can switch accounts without changing the profile set. + // Include every non-secret selector that resolveAuthProfileOrder consults. + return JSON.stringify({ + profiles, + direct, + order: sortedRecordEntries(params.store.order), + lastGood: sortedRecordEntries(params.store.lastGood), + usageStats: sortedRecordEntries(params.store.usageStats), + }); +} + +export function clearModelAuthStatusUsageCache(): void { + cacheGeneration += 1; + usageCacheByAgentId.clear(); + usageRefreshByAgentId.clear(); +} + +function providerUsageCacheKey(providerIds: readonly UsageProviderId[]): string { + return providerIds.toSorted().join("\0"); +} + +function mapProviderUsage(usage: Awaited>) { + const usageByProvider = new Map(); + for (const snap of usage.providers) { + usageByProvider.set(snap.provider, { + windows: snap.windows, + ...(snap.summary ? { summary: snap.summary } : {}), + ...(snap.plan ? { plan: snap.plan } : {}), + ...(snap.billing?.length ? { billing: snap.billing } : {}), + ...(snap.accountEmail ? { accountEmail: snap.accountEmail } : {}), + }); + } + return usageByProvider; +} + +function scheduleProviderUsageRefresh(params: { + agentId: string; + agentDir: string; + configRef: object; + credentialKey: string; + providerIds: UsageProviderId[]; + providerKey: string; +}): void { + const active = usageRefreshByAgentId.get(params.agentId); + if ( + active?.agentDir === params.agentDir && + active.configRef === params.configRef && + active.credentialKey === params.credentialKey && + active.providerKey === params.providerKey + ) { + return; + } + const publishGeneration = cacheGeneration; + const refresh = { + agentDir: params.agentDir, + configRef: params.configRef, + credentialKey: params.credentialKey, + providerKey: params.providerKey, + }; + usageRefreshByAgentId.set(params.agentId, refresh); + void loadProviderUsageSummary({ + providers: params.providerIds, + agentDir: params.agentDir, + timeoutMs: 3500, + }) + .then((usage) => { + if ( + publishGeneration !== cacheGeneration || + usageRefreshByAgentId.get(params.agentId) !== refresh + ) { + return; + } + usageCacheByAgentId.set(params.agentId, { + agentDir: params.agentDir, + configRef: params.configRef, + credentialKey: params.credentialKey, + providerKey: params.providerKey, + refreshedAt: Date.now(), + usageByProvider: mapProviderUsage(usage), + }); + }) + .catch((err: unknown) => { + // Usage is auxiliary and stale data remains valid. Keep failures visible at + // debug level without delaying or failing the fresh auth-health response. + log.debug( + `usage enrichment failed (auth status still returned): providers=${params.providerIds.join(",")} error=${formatForLog(err)}`, + ); + }) + .finally(() => { + if (usageRefreshByAgentId.get(params.agentId) === refresh) { + usageRefreshByAgentId.delete(params.agentId); + } + }); +} + +export function readProviderUsageStaleWhileRevalidate(params: { + agentId: string; + agentDir: string; + configRef: object; + credentialKey: string; + forceRefresh?: boolean; + providerIds: UsageProviderId[]; + now: number; +}): Map { + if (params.providerIds.length === 0) { + usageCacheByAgentId.delete(params.agentId); + return new Map(); + } + const providerIds = params.providerIds.toSorted(); + const providerKey = providerUsageCacheKey(providerIds); + const cached = usageCacheByAgentId.get(params.agentId); + const matching = + cached?.agentDir === params.agentDir && + cached.configRef === params.configRef && + cached.credentialKey === params.credentialKey && + cached.providerKey === providerKey + ? cached + : undefined; + if ( + params.forceRefresh === true || + !matching || + params.now - matching.refreshedAt >= USAGE_CACHE_TTL_MS + ) { + // Never couple the RPC deadline to provider HTTP. A cold call returns auth + // without usage; stale calls return the last snapshot while one refresh runs. + scheduleProviderUsageRefresh({ + agentId: params.agentId, + agentDir: params.agentDir, + configRef: params.configRef, + credentialKey: params.credentialKey, + providerIds, + providerKey, + }); + } + return matching?.usageByProvider ?? new Map(); +} diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index 097e6282cfc9..8dd9c8dfd4be 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -224,6 +224,14 @@ async function firstAuthStatusProvider() { return (payload as ModelAuthStatusResult).providers[0]; } +async function readAuthStatus(params: Record = {}) { + const opts = createOptions(params); + await handler(opts); + const [ok, payload, error] = firstRespondCall(opts) ?? []; + expect(ok, JSON.stringify(error)).toBe(true); + return payload as ModelAuthStatusResult; +} + function resetAuthStatusMocks(): void { vi.clearAllMocks(); invalidateModelAuthStatusCache(); @@ -405,15 +413,15 @@ describe("models.authStatus", () => { }, ); - it("keeps cached auth snapshots isolated by agent", async () => { + it("rebuilds fresh auth snapshots for each requested agent", async () => { const cfg = { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } }; mocks.getRuntimeConfig.mockReturnValue(cfg); mocks.listAgentIds.mockReturnValue(["main", "writer"]); await handler(createOptions({ agentId: "main" })); await handler(createOptions({ agentId: "writer" })); - const cachedMain = createOptions({ agentId: "main" }); - await handler(cachedMain); + const freshMain = createOptions({ agentId: "main" }); + await handler(freshMain); expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith( 1, @@ -425,8 +433,13 @@ describe("models.authStatus", () => { "/tmp/agent-writer", expect.any(Object), ); - expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(2); - expect(firstRespondCall(cachedMain)?.[3]).toEqual({ cached: true }); + expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith( + 3, + "/tmp/agent", + expect.any(Object), + ); + expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(3); + expect(firstRespondCall(freshMain)?.[3]).toBeUndefined(); }); it("re-reads runtime config after an explicit auth refresh", async () => { @@ -768,7 +781,7 @@ describe("models.authStatus", () => { expect(result.providers[0]?.profiles[0]?.reasonCode).toBe("unresolved_ref"); }); - it("serves cached response within TTL and marks it as cached", async () => { + it("keeps auth health fresh while usage caching stays auxiliary", async () => { const opts1 = createOptions(); await handler(opts1); expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(1); @@ -776,11 +789,8 @@ describe("models.authStatus", () => { const opts2 = createOptions(); await handler(opts2); - // Auth health should NOT be re-queried on the cached call. - expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(1); - - const lastCall = opts2.respond.mock.calls.at(-1); - expect(requireRecord(lastCall?.[3]).cached).toBe(true); + expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2); + expect(opts2.respond.mock.calls.at(-1)?.[3]).toBeUndefined(); }); it("bypasses cache when params.refresh is set", async () => { @@ -818,15 +828,16 @@ describe("models.authStatus", () => { expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(1); }); - it("invalidateModelAuthStatusCache() clears the cached response", async () => { + it("invalidateModelAuthStatusCache() preserves fresh auth reads", async () => { await handler(createOptions()); invalidateModelAuthStatusCache(); await handler(createOptions()); expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2); }); - it("does not cache status captured before a concurrent logout", async () => { + it("does not publish usage captured before a concurrent logout", async () => { let releaseUsage: (() => void) | undefined; + let usageFinished = false; const usageBlocked = new Promise((resolve) => { releaseUsage = resolve; }); @@ -846,16 +857,28 @@ describe("models.authStatus", () => { }); mocks.loadProviderUsageSummary.mockImplementationOnce(async () => { await usageBlocked; - return emptyUsageSummary(); + usageFinished = true; + return { + updatedAt: 0, + providers: [ + { + provider: "openrouter", + displayName: "OpenRouter", + windows: [{ label: "day", usedPercent: 99 }], + }, + ], + }; }); - const inFlightStatus = handler(createOptions()); + const first = await readAuthStatus(); + expect(first.providers[0]?.usage).toBeUndefined(); await waitForFast(() => expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce()); await logoutHandler(createLogoutOptions({ provider: "openrouter" })); releaseUsage?.(); - await inFlightStatus; + await waitForFast(() => expect(usageFinished).toBe(true)); - await handler(createOptions()); + const afterLogout = await readAuthStatus(); + expect(afterLogout.providers[0]?.usage).toBeUndefined(); expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2); }); @@ -900,18 +923,22 @@ describe("models.authStatus", () => { ], }); - const opts = createOptions(); - await handler(opts); + const first = await readAuthStatus(); + expect(first.providers[0]?.usage).toBeUndefined(); expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith({ providers: ["anthropic"], agentDir: "/tmp/agent", timeoutMs: 3500, }); - const [, payload] = firstRespondCall(opts) ?? []; - const result = payload as ModelAuthStatusResult; - expect(result.providers[0]?.displayName).toBe("Claude"); - expect(result.providers[0]?.usage).toEqual({ + let result: ModelAuthStatusResult | undefined; + await waitForFast(async () => { + result = await readAuthStatus(); + expect(result.providers[0]?.usage).toBeDefined(); + }); + const refreshed = expectDefined(result, "refreshed auth status"); + expect(refreshed.providers[0]?.displayName).toBe("Claude"); + expect(refreshed.providers[0]?.usage).toEqual({ providerId: "anthropic", windows: [{ label: "5h", usedPercent: 22 }], plan: "Max (20x)", @@ -939,23 +966,287 @@ describe("models.authStatus", () => { ], }); - const opts = createOptions(); - await handler(opts); + const first = await readAuthStatus(); + expect(first.providers[0]?.usage).toBeUndefined(); expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith({ providers: ["deepseek"], agentDir: "/tmp/agent", timeoutMs: 3500, }); - const [, payload] = firstRespondCall(opts) ?? []; - const result = payload as ModelAuthStatusResult; - expect(result.providers[0]?.usage).toEqual({ + let result: ModelAuthStatusResult | undefined; + await waitForFast(async () => { + result = await readAuthStatus(); + expect(result.providers[0]?.usage).toBeDefined(); + }); + const refreshed = expectDefined(result, "refreshed auth status"); + expect(refreshed.providers[0]?.usage).toEqual({ providerId: "deepseek", windows: [], summary: "Balance ¥42.50", }); }); + it("serves stale usage immediately while one background refresh replaces it", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1_000); + const profile = { + profileId: "openai:default", + provider: "openai", + type: "oauth", + status: "ok", + source: "store", + label: "openai:default", + } satisfies AuthHealthSummary["profiles"][number]; + mocks.buildAuthHealthSummary.mockReturnValue({ + now: 0, + warnAfterMs: 0, + profiles: [profile], + providers: [{ provider: "openai", status: "ok", profiles: [profile] }], + }); + mocks.loadProviderUsageSummary.mockResolvedValueOnce({ + updatedAt: 1_000, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 10 }], + }, + ], + }); + + await readAuthStatus(); + await waitForFast(async () => { + const warmed = await readAuthStatus(); + expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10); + }); + + let releaseRefresh: (() => void) | undefined; + const refreshBlocked = new Promise((resolve) => { + releaseRefresh = resolve; + }); + now.mockReturnValue(61_000); + mocks.loadProviderUsageSummary.mockImplementationOnce(async () => { + await refreshBlocked; + return { + updatedAt: 61_000, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 20 }], + }, + ], + }; + }); + + const stale = await readAuthStatus(); + expect(stale.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10); + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledTimes(2); + + releaseRefresh?.(); + await waitForFast(async () => { + const refreshed = await readAuthStatus(); + expect(refreshed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(20); + }); + now.mockRestore(); + }); + + it("keeps same-account stale usage visible during an explicit refresh", async () => { + mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary()); + mocks.loadProviderUsageSummary.mockResolvedValue({ + updatedAt: 0, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 10 }], + }, + ], + }); + + await readAuthStatus(); + await waitForFast(async () => { + const warmed = await readAuthStatus(); + expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10); + }); + + let releaseRefresh: (() => void) | undefined; + const refreshBlocked = new Promise((resolve) => { + releaseRefresh = resolve; + }); + mocks.loadProviderUsageSummary.mockImplementationOnce(async () => { + await refreshBlocked; + return emptyUsageSummary(); + }); + + const refreshing = await readAuthStatus({ refresh: true }); + expect(refreshing.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10); + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledTimes(2); + releaseRefresh?.(); + }); + + it("does not reuse usage after the same agent moves to another agent directory", async () => { + mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary()); + mocks.loadProviderUsageSummary.mockResolvedValue({ + updatedAt: 0, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 10 }], + }, + ], + }); + + await readAuthStatus(); + await waitForFast(async () => { + const warmed = await readAuthStatus(); + expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10); + }); + + mocks.resolveAgentDir.mockReturnValue("/tmp/rebound-agent"); + const rebound = await readAuthStatus(); + expect(rebound.providers[0]?.usage).toBeUndefined(); + expect(mocks.loadProviderUsageSummary).toHaveBeenLastCalledWith({ + providers: ["openai"], + agentDir: "/tmp/rebound-agent", + timeoutMs: 3500, + }); + }); + + it("does not reuse usage after credentials rotate within the same provider", async () => { + mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary()); + mocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "openai:default": { + type: "oauth", + provider: "openai", + access: "first-access", + refresh: "first-refresh", + expires: 1_000_000, + }, + }, + }); + mocks.loadProviderUsageSummary.mockResolvedValue({ + updatedAt: 0, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 10 }], + }, + ], + }); + + await readAuthStatus(); + await waitForFast(async () => { + const warmed = await readAuthStatus(); + expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10); + }); + + mocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "openai:default": { + type: "oauth", + provider: "openai", + access: "second-access", + refresh: "second-refresh", + expires: 1_000_000, + }, + }, + }); + const rotated = await readAuthStatus(); + expect(rotated.providers[0]?.usage).toBeUndefined(); + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledTimes(2); + }); + + it("does not reuse usage after a direct provider key rotates", async () => { + const cfg = { + models: { providers: { deepseek: { apiKey: "first-direct-value" } } }, + }; + mocks.getRuntimeConfig.mockReturnValue(cfg); + mocks.buildAuthHealthSummary.mockReturnValue({ + now: 0, + warnAfterMs: 0, + profiles: [createApiKeyProfile("deepseek")], + providers: [createStaticApiKeyProvider("deepseek")], + }); + mocks.loadProviderUsageSummary.mockResolvedValue({ + updatedAt: 0, + providers: [ + { + provider: "deepseek", + displayName: "DeepSeek", + windows: [], + summary: "Balance 10", + }, + ], + }); + + await readAuthStatus(); + await waitForFast(async () => { + const warmed = await readAuthStatus(); + expect(warmed.providers[0]?.usage?.summary).toBe("Balance 10"); + }); + + cfg.models.providers.deepseek.apiKey = "second-direct-value"; + const rotated = await readAuthStatus(); + expect(rotated.providers[0]?.usage).toBeUndefined(); + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledTimes(2); + }); + + it("does not reuse usage after profile selection state switches accounts", async () => { + mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary()); + const profiles = { + "openai:first": { + type: "oauth" as const, + provider: "openai", + access: "first-access", + refresh: "first-refresh", + expires: 1_000_000, + }, + "openai:second": { + type: "oauth" as const, + provider: "openai", + access: "second-access", + refresh: "second-refresh", + expires: 1_000_000, + }, + }; + mocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles, + lastGood: { openai: "openai:first" }, + }); + mocks.loadProviderUsageSummary.mockResolvedValue({ + updatedAt: 0, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 10 }], + }, + ], + }); + + await readAuthStatus(); + await waitForFast(async () => { + const warmed = await readAuthStatus(); + expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10); + }); + + mocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles, + lastGood: { openai: "openai:second" }, + }); + const switched = await readAuthStatus(); + expect(switched.providers[0]?.usage).toBeUndefined(); + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledTimes(2); + }); + it("scopes external CLI auth overlays to configured providers", async () => { mocks.getRuntimeConfig.mockReturnValue({ auth: { diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index 4bd599685e6a..c1932f6cdc20 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -24,7 +24,6 @@ import { removeProviderAuthProfilesWithLock, resolvePersistedAuthProfileOwnerAgentDir, } from "../../agents/auth-profiles.js"; -import type { AuthCredentialReasonCode } from "../../agents/auth-profiles/credential-state.js"; import { listProviderEnvAuthLookupKeys, resolveProviderEnvAuthLookupMaps, @@ -46,14 +45,8 @@ import { import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import type { OpenClawConfig } from "../../config/config.js"; import { coerceSecretRef, hasConfiguredSecretInput } from "../../config/types.secrets.js"; -import { loadProviderUsageSummary } from "../../infra/provider-usage.load.js"; import { providerUsageLabel, resolveUsageProviderId } from "../../infra/provider-usage.shared.js"; -import type { - ProviderUsageBilling, - ProviderUsageSnapshot, - UsageProviderId, - UsageWindow, -} from "../../infra/provider-usage.types.js"; +import type { UsageProviderId } from "../../infra/provider-usage.types.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { refreshActiveProviderAuthRuntimeSnapshot } from "../../secrets/runtime.js"; import { asDateTimestampMs } from "../../shared/number-coercion.js"; @@ -63,93 +56,38 @@ import { resolveModelAuthAgentScope, unknownModelAuthAgentIdError, } from "./model-auth-agent-scope.js"; +import { + clearModelAuthStatusUsageCache, + fingerprintProviderUsageCredentials, + type ProviderUsageStatus, + readProviderUsageStaleWhileRevalidate, +} from "./models-auth-status-usage-cache.js"; +import type { + ModelAuthExpiry, + ModelAuthLogoutResult, + ModelAuthStatusProvider, + ModelAuthStatusResult, +} from "./models-auth-status.types.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; +export type { + ModelAuthExpiry, + ModelAuthLogoutResult, + ModelAuthStatusProfile, + ModelAuthStatusProvider, + ModelAuthStatusResult, +} from "./models-auth-status.types.js"; + const log = createSubsystemLogger("models-auth-status"); const apiKeyUsageStatusProviders = new Set(["clawrouter", "deepseek"]); -type ProviderUsageStatus = Pick< - ProviderUsageSnapshot, - "windows" | "summary" | "plan" | "billing" | "accountEmail" ->; - /** - * Models-auth status wire types. Mirrored in ui/src/ui/types.ts via an - * `import(...)` re-export — edit here and the UI picks up the change. - * - * Expiry fields are grouped into a sub-object so they're present together or - * not at all: a profile either has a time-bounded credential or it doesn't. - */ -export type ModelAuthExpiry = { - /** Absolute expiry timestamp, ms since epoch. */ - at: number; - /** Remaining time in ms (negative if already expired). */ - remainingMs: number; - /** Human-readable remaining time (e.g. "10d", "2h", "45m"). */ - label: string; -}; - -export type ModelAuthStatusProfile = { - profileId: string; - type: "oauth" | "token" | "api_key"; - status: AuthProfileHealthStatus; - reasonCode?: AuthCredentialReasonCode; - expiry?: ModelAuthExpiry; - /** True only for saved OAuth/token profiles this gateway can remove. */ - logoutSupported?: boolean; -}; - -export type ModelAuthStatusProvider = { - provider: string; - displayName: string; - status: AuthProviderHealthStatus; - expiry?: ModelAuthExpiry; - profiles: ModelAuthStatusProfile[]; - apiKey?: { - source: "config" | "env"; - envVar?: string; - }; - usage?: { - /** - * Normalized usage provider id this payload was fetched under (e.g. - * "anthropic" for a claude-cli auth row). Session rows report canonical - * model providers, so consumers must match against both ids. - */ - providerId: UsageProviderId; - windows: UsageWindow[]; - summary?: string; - plan?: string; - billing?: ProviderUsageBilling[]; - /** Account email the usage was fetched under, when known. */ - accountEmail?: string; - }; -}; - -export type ModelAuthStatusResult = { - /** Snapshot build time, ms since epoch. 0 = never loaded (UI fallback sentinel). */ - ts: number; - providers: ModelAuthStatusProvider[]; -}; - -export type ModelAuthLogoutResult = { - provider: string; - removedProfiles: string[]; - abortedRunIds: string[]; -}; - -const CACHE_TTL_MS = 60_000; -const cachedByAgentId = new Map(); -let cacheGeneration = 0; - -/** - * Invalidate the in-memory cache. Reserved for future gateway-side auth - * mutation handlers (login, logout, token rotation) so the next read returns - * fresh data. Today those mutations happen via the CLI and the 60s TTL plus - * `{refresh: true}` param cover the stale-data window. + * Invalidate auxiliary usage and prepared provider-auth state after an auth + * mutation. Auth health itself is rebuilt on every request; only outbound + * usage enrichment is cached. */ export function invalidateModelAuthStatusCache(): void { - cacheGeneration += 1; - cachedByAgentId.clear(); + clearModelAuthStatusUsageCache(); // The prepared provider-auth map (model-provider-auth.ts) was built from // the pre-mutation auth state, so it must be invalidated alongside this // cache whenever an auth-profile mutation lands (logout, login, token @@ -159,7 +97,10 @@ export function invalidateModelAuthStatusCache(): void { } async function refreshModelAuthStatusRuntimeState(): Promise { - invalidateModelAuthStatusCache(); + // Keep same-credential usage visible while the explicit refresh replaces it. + // A changed credential/config produces a different cache key below; logout + // still uses invalidateModelAuthStatusCache() and clears usage immediately. + clearCurrentProviderAuthState(); try { if (await refreshActiveProviderAuthRuntimeSnapshot()) { return; @@ -589,8 +530,8 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { ); return; } - // Fence status work that may have captured the removed profiles before - // it awaits auxiliary usage. It must not repopulate the cache afterward. + // Fence auxiliary usage work that captured the removed profiles before + // logout. Its later completion must not repopulate the cache. invalidateModelAuthStatusCache(); await refreshActiveProviderAuthRuntimeSnapshot(); void warmCurrentProviderAuthStateOffMainThread(context.getRuntimeConfig()).catch( @@ -623,7 +564,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { }, "models.authStatus": async ({ params, respond, context }) => { const now = Date.now(); - const bypassCache = Boolean(params.refresh); + const refreshRequested = Boolean(params.refresh); try { let cfg = context.getRuntimeConfig(); let scope = resolveModelAuthAgentScope(cfg, params.agentId); @@ -631,7 +572,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { respond(false, undefined, unknownModelAuthAgentIdError(scope.agentId)); return; } - if (bypassCache) { + if (refreshRequested) { await refreshModelAuthStatusRuntimeState(); cfg = context.getRuntimeConfig(); scope = resolveModelAuthAgentScope(cfg, params.agentId); @@ -640,13 +581,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { return; } } - const publishGeneration = cacheGeneration; const { agentId, agentDir } = scope; - const cached = cachedByAgentId.get(agentId); - if (!bypassCache && cached && now - cached.ts < CACHE_TTL_MS) { - respond(true, cached.result, undefined, { cached: true }); - return; - } // Use the external-profile-aware store for status reads so the dashboard // reflects CLI-discovered credentials without persisting them here. const store = ensureAuthProfileStore(agentDir, { @@ -690,32 +625,19 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { ), ]; - const usageByProvider = new Map(); - if (usageProviderIds.length > 0) { - try { - const usage = await loadProviderUsageSummary({ - providers: usageProviderIds, - agentDir, - timeoutMs: 3500, - }); - for (const snap of usage.providers) { - usageByProvider.set(snap.provider, { - windows: snap.windows, - ...(snap.summary ? { summary: snap.summary } : {}), - ...(snap.plan ? { plan: snap.plan } : {}), - ...(snap.billing?.length ? { billing: snap.billing } : {}), - ...(snap.accountEmail ? { accountEmail: snap.accountEmail } : {}), - }); - } - } catch (err) { - // Usage data is auxiliary — failing here must not block auth status, - // but log at debug so a silently-broken usage endpoint is still - // diagnosable in gateway logs. - log.debug( - `usage enrichment failed (auth status still returned): providers=${usageProviderIds.join(",")} error=${formatForLog(err)}`, - ); - } - } + const usageByProvider = readProviderUsageStaleWhileRevalidate({ + agentId, + agentDir, + configRef: cfg, + credentialKey: fingerprintProviderUsageCredentials({ + cfg, + directApiKeys: apiKeys, + store, + }), + forceRefresh: refreshRequested, + providerIds: usageProviderIds, + now, + }); const externalProfileIds = new Set(store.runtimeExternalProfileIds ?? []); const logoutProfileIds = new Set( @@ -739,9 +661,6 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { ), ); const result: ModelAuthStatusResult = { ts: now, providers }; - if (publishGeneration === cacheGeneration) { - cachedByAgentId.set(agentId, { ts: now, result }); - } respond(true, result, undefined); } catch (err) { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); diff --git a/src/gateway/server-methods/models-auth-status.types.ts b/src/gateway/server-methods/models-auth-status.types.ts new file mode 100644 index 000000000000..1d10dc7a94a3 --- /dev/null +++ b/src/gateway/server-methods/models-auth-status.types.ts @@ -0,0 +1,60 @@ +import type { + AuthProfileHealthStatus, + AuthProviderHealthStatus, +} from "../../agents/auth-health.js"; +import type { AuthCredentialReasonCode } from "../../agents/auth-profiles/credential-state.js"; +import type { + ProviderUsageBilling, + UsageProviderId, + UsageWindow, +} from "../../infra/provider-usage.types.js"; + +/** Time-bounded credential expiry projected to gateway clients. */ +export type ModelAuthExpiry = { + at: number; + remainingMs: number; + label: string; +}; + +export type ModelAuthStatusProfile = { + profileId: string; + type: "oauth" | "token" | "api_key"; + status: AuthProfileHealthStatus; + reasonCode?: AuthCredentialReasonCode; + expiry?: ModelAuthExpiry; + /** True only for saved OAuth/token profiles this gateway can remove. */ + logoutSupported?: boolean; +}; + +export type ModelAuthStatusProvider = { + provider: string; + displayName: string; + status: AuthProviderHealthStatus; + expiry?: ModelAuthExpiry; + profiles: ModelAuthStatusProfile[]; + apiKey?: { + source: "config" | "env"; + envVar?: string; + }; + usage?: { + /** Normalized provider id the usage payload was fetched under. */ + providerId: UsageProviderId; + windows: UsageWindow[]; + summary?: string; + plan?: string; + billing?: ProviderUsageBilling[]; + accountEmail?: string; + }; +}; + +export type ModelAuthStatusResult = { + /** Snapshot build time, ms since epoch. 0 = never loaded (UI fallback sentinel). */ + ts: number; + providers: ModelAuthStatusProvider[]; +}; + +export type ModelAuthLogoutResult = { + provider: string; + removedProfiles: string[]; + abortedRunIds: string[]; +}; diff --git a/src/gateway/server-methods/tasks.ts b/src/gateway/server-methods/tasks.ts index 9806c9c93a88..aff133e51bb1 100644 --- a/src/gateway/server-methods/tasks.ts +++ b/src/gateway/server-methods/tasks.ts @@ -14,7 +14,6 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { canonicalizeMainSessionAlias } from "../../config/sessions.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { getTaskById, listTaskRecordsUnsorted } from "../../tasks/runtime-internal.js"; -import { cancelDetachedTaskRunById } from "../../tasks/task-executor.js"; import type { TaskRecord, TaskStatus } from "../../tasks/task-registry.types.js"; import { mapTaskSummary, taskUpdatedAt } from "./task-summary.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -165,6 +164,8 @@ export const tasksHandlers: GatewayRequestHandlers = { } const taskId = params.taskId; const reason = normalizeOptionalString(params.reason); + const { cancelDetachedTaskRunById } = + await import("../../tasks/task-executor-cancel.runtime.js"); const result = await cancelDetachedTaskRunById({ cfg: context.getRuntimeConfig(), taskId, diff --git a/src/gateway/server-startup-handler-prewarm.test.ts b/src/gateway/server-startup-handler-prewarm.test.ts new file mode 100644 index 000000000000..3be0f52b93e2 --- /dev/null +++ b/src/gateway/server-startup-handler-prewarm.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetGatewayWorkAdmission } from "../process/gateway-work-admission.js"; +import { scheduleGatewayHandlerPrewarm } from "./server-startup-handler-prewarm.js"; + +afterEach(() => { + vi.useRealTimers(); + resetGatewayWorkAdmission(); +}); + +describe("scheduleGatewayHandlerPrewarm", () => { + it("loads every scheduled family in order only after the post-ready timer yields", async () => { + vi.useFakeTimers(); + const familyNames = ["sessions", "chat", "tasks", "cron"]; + const loaded: string[] = []; + const families = familyNames.map((name) => ({ + name, + load: vi.fn(async () => { + loaded.push(name); + }), + })); + + const sidecar = scheduleGatewayHandlerPrewarm({ + families, + log: { warn: vi.fn() }, + }); + + expect(loaded).toEqual([]); + await vi.runAllTimersAsync(); + expect(loaded).toEqual(familyNames); + expect(families.every((family) => vi.mocked(family.load).mock.calls.length === 1)).toBe(true); + sidecar.stop(); + }); + + it("stops before importing a scheduled family", async () => { + vi.useFakeTimers(); + const load = vi.fn(async () => {}); + const sidecar = scheduleGatewayHandlerPrewarm({ + families: [{ name: "sessions", load }], + log: { warn: vi.fn() }, + }); + + sidecar.stop(); + await vi.runAllTimersAsync(); + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-startup-handler-prewarm.ts b/src/gateway/server-startup-handler-prewarm.ts new file mode 100644 index 000000000000..84f50a07ac11 --- /dev/null +++ b/src/gateway/server-startup-handler-prewarm.ts @@ -0,0 +1,81 @@ +import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js"; + +type StartupTrace = { + measure: (name: string, run: () => T | Promise) => Promise; +}; + +type GatewayHandlerPrewarmFamily = { + name: string; + load: () => Promise; +}; + +type GatewayHandlerPrewarmHandle = { + stop: () => void; +}; + +// These are the families requested by the Control UI's first dashboard turn. +// Keep the list explicit so adding a cold import is a conscious startup tradeoff. +const DASHBOARD_HANDLER_FAMILIES: readonly GatewayHandlerPrewarmFamily[] = [ + { name: "sessions", load: () => import("./server-methods/sessions.js") }, + { name: "chat", load: () => import("./server-methods/chat.js") }, + { name: "tasks", load: () => import("./server-methods/tasks.js") }, + { name: "cron", load: () => import("./server-methods/cron.js") }, + { name: "models-auth-status", load: () => import("./server-methods/models-auth-status.js") }, + { name: "agent-identity", load: () => import("./server-methods/agent-identity.js") }, + { name: "board", load: () => import("./server-methods/board.js") }, + { name: "channels", load: () => import("./server-methods/channels.js") }, +]; + +export function scheduleGatewayHandlerPrewarm(params: { + startupTrace?: StartupTrace; + log: { warn: (msg: string) => void }; + families?: readonly GatewayHandlerPrewarmFamily[]; +}): GatewayHandlerPrewarmHandle { + const families = params.families ?? DASHBOARD_HANDLER_FAMILIES; + let stopped = false; + let nextIndex = 0; + let timer: ReturnType | undefined; + + const scheduleNext = () => { + if (stopped || nextIndex >= families.length) { + return; + } + timer = setTimeout(() => { + timer = undefined; + if (stopped) { + return; + } + const family = families[nextIndex++]; + if (!family) { + return; + } + const load = () => family.load(); + void runWithGatewayIndependentRootWorkAdmission(() => + params.startupTrace + ? params.startupTrace.measure(`post-ready.gateway-handler.${family.name}`, load) + : load(), + ) + .catch((err: unknown) => { + params.log.warn( + `post-ready gateway handler prewarm failed for ${family.name}: ${String(err)}`, + ); + }) + .finally(scheduleNext); + }, 0); + timer.unref?.(); + }; + + // One family per event-loop turn keeps this work behind readiness and lets + // immediate client traffic run between imports instead of recreating a startup wall. + scheduleNext(); + + return { + stop: () => { + stopped = true; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + }, + }; +} diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 439a65b392db..bb28f16e236d 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -65,6 +65,7 @@ const hoisted = vi.hoisted(() => { const refreshPreparedModelRuntimeSnapshots = vi.fn(async (_cfg?: unknown) => {}); const ensureRuntimePluginsLoaded = vi.fn(); const ensureContextWindowCacheLoaded = vi.fn(async () => {}); + const scheduleGatewayHandlerPrewarm = vi.fn(() => ({ stop: vi.fn() })); const clearCurrentProviderAuthState = vi.fn(); const warmCurrentProviderAuthStateOffMainThread = vi.fn( async (_cfg?: unknown, _options?: unknown) => {}, @@ -105,6 +106,7 @@ const hoisted = vi.hoisted(() => { refreshPreparedModelRuntimeSnapshots, ensureRuntimePluginsLoaded, ensureContextWindowCacheLoaded, + scheduleGatewayHandlerPrewarm, clearCurrentProviderAuthState, warmCurrentProviderAuthStateOffMainThread, setAuthProfileFailureHook, @@ -222,6 +224,10 @@ vi.mock("../agents/model-provider-auth.js", () => ({ warmCurrentProviderAuthStateOffMainThread: hoisted.warmCurrentProviderAuthStateOffMainThread, })); +vi.mock("./server-startup-handler-prewarm.js", () => ({ + scheduleGatewayHandlerPrewarm: hoisted.scheduleGatewayHandlerPrewarm, +})); + vi.mock("../agents/model-provider-auth-state.js", () => ({ clearCurrentProviderAuthState: hoisted.clearCurrentProviderAuthState, })); @@ -1076,7 +1082,7 @@ describe("startGatewayPostAttachRuntime", () => { await vi.advanceTimersToNextTimerAsync(); expect(postReadyRequestTurn).toHaveBeenCalledTimes(1); expect(onPostReadySidecars.mock.calls[0]?.[0]).toHaveLength(0); - expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(3); + expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(4); await vi.dynamicImportSettled(); await waitForGatewayTestState(() => { expect(hoisted.setAuthProfileFailureHook).toHaveBeenCalledTimes(1); @@ -1108,7 +1114,7 @@ describe("startGatewayPostAttachRuntime", () => { await waitForGatewayTestState(() => { expect(hoisted.setAuthProfileFailureHook).toHaveBeenCalledTimes(1); }); - expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(3); + expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(4); await vi.advanceTimersByTimeAsync(10_000); expect(hoisted.warmCurrentProviderAuthStateOffMainThread).not.toHaveBeenCalled(); @@ -1232,7 +1238,7 @@ describe("startGatewayPostAttachRuntime", () => { | { stop: () => void }[] | undefined; expect(gmailSidecars).toHaveLength(1); - expect(lifetimeSidecars).toHaveLength(3); + expect(lifetimeSidecars).toHaveLength(4); for (const sidecar of gmailSidecars ?? []) { sidecar.stop(); @@ -1294,7 +1300,7 @@ describe("startGatewayPostAttachRuntime", () => { | Array<{ stop: () => Promise | void }> | undefined; expect(gmailSidecars).toHaveLength(1); - expect(lifetimeSidecars).toHaveLength(3); + expect(lifetimeSidecars).toHaveLength(4); await waitForGatewayTestState(() => { expect(hoisted.transcriptsAutoStartService.start).toHaveBeenCalledTimes(1); @@ -1789,7 +1795,7 @@ describe("startGatewayPostAttachRuntime", () => { name: "sidecars.ready", metrics: [ ["loadedPluginCount", 2], - ["postReadySidecarCount", 2], + ["postReadySidecarCount", 3], ], }); }); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index eddb79ee6c39..a7d7f5d5bcb1 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -28,6 +28,7 @@ import type { GatewayRecoveryRuntime } from "./server-instance-runtime.types.js" import type { refreshLatestUpdateRestartSentinel } from "./server-restart-sentinel.js"; import type { GatewaySidecarStartupMode } from "./server-sidecar-startup-mode.js"; import { scheduleContextCachePrewarm } from "./server-startup-context-cache-prewarm.js"; +import { scheduleGatewayHandlerPrewarm } from "./server-startup-handler-prewarm.js"; import type { logGatewayStartup } from "./server-startup-log.js"; import { createGatewayStartupOutcomeRecorder, @@ -1230,7 +1231,10 @@ export async function startGatewayPostAttachRuntime( reportPluginServices(result.pluginServices); } const postReadySidecars = [...result.postReadySidecars]; - const gatewayLifetimeSidecars = [scheduleContextCachePrewarm(params)]; + const gatewayLifetimeSidecars = [ + scheduleContextCachePrewarm(params), + scheduleGatewayHandlerPrewarm(params), + ]; if (workerEnvironmentSidecar) { gatewayLifetimeSidecars.push(workerEnvironmentSidecar); } diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 96456d3e4e46..9b212dc1c86f 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -568,6 +568,15 @@ describe("gateway server cron", () => { detail: "webhook", }); + const noPreviewListRes = await directCronReq(cronState, "cron.list", { + includeDeliveryPreviews: false, + includeDisabled: true, + }); + expect(noPreviewListRes.ok).toBe(true); + expect( + (noPreviewListRes.payload as { deliveryPreviews?: unknown } | null)?.deliveryPreviews, + ).toBeUndefined(); + const compactListRes = await directCronReq(cronState, "cron.list", { compact: true, includeDisabled: true, diff --git a/src/tasks/task-executor-cancel.runtime.ts b/src/tasks/task-executor-cancel.runtime.ts new file mode 100644 index 000000000000..e08e5fd5881f --- /dev/null +++ b/src/tasks/task-executor-cancel.runtime.ts @@ -0,0 +1,44 @@ +// Lazy runtime boundary for task cancellation and its runtime-specific control stack. +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { getRegisteredDetachedTaskLifecycleRuntime } from "./detached-task-runtime-state.js"; +import { + assertTaskCancellationReadyById, + cancelTaskById, + getTaskById, +} from "./runtime-internal.js"; + +export async function cancelDetachedTaskRunById(params: { + cfg: OpenClawConfig; + taskId: string; + reason?: string; +}) { + const task = getTaskById(params.taskId); + const registeredRuntime = getRegisteredDetachedTaskLifecycleRuntime(); + if (!task) { + if (registeredRuntime) { + const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params); + if (cancelled.found) { + return cancelled; + } + } + return cancelTaskById(params); + } + try { + assertTaskCancellationReadyById(task.taskId); + } catch (error) { + return { + found: true, + cancelled: false, + reason: formatErrorMessage(error), + task, + }; + } + if (registeredRuntime) { + const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params); + if (cancelled.found) { + return cancelled; + } + } + return cancelTaskById(params); +} diff --git a/src/tasks/task-executor.ts b/src/tasks/task-executor.ts index af87caf8de45..777a41bf3cd3 100644 --- a/src/tasks/task-executor.ts +++ b/src/tasks/task-executor.ts @@ -1,6 +1,5 @@ // Executes task records through configured runtimes and updates registry state. import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { DetachedRunningTaskCreateParams, @@ -9,10 +8,7 @@ import type { DetachedTaskFailParams, DetachedTaskFinalizeParams, } from "./detached-task-runtime-contract.js"; -import { getRegisteredDetachedTaskLifecycleRuntime } from "./detached-task-runtime-state.js"; import { - assertTaskCancellationReadyById, - cancelTaskById, createTaskRecord, findTaskByRunId as findTaskByRunIdInRegistry, getTaskById, @@ -598,32 +594,6 @@ export async function cancelDetachedTaskRunById(params: { taskId: string; reason?: string; }) { - const task = getTaskById(params.taskId); - const registeredRuntime = getRegisteredDetachedTaskLifecycleRuntime(); - if (!task) { - if (registeredRuntime) { - const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params); - if (cancelled.found) { - return cancelled; - } - } - return cancelTaskById(params); - } - try { - assertTaskCancellationReadyById(task.taskId); - } catch (error) { - return { - found: true, - cancelled: false, - reason: formatErrorMessage(error), - task, - }; - } - if (registeredRuntime) { - const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params); - if (cancelled.found) { - return cancelled; - } - } - return cancelTaskById(params); + const runtime = await import("./task-executor-cancel.runtime.js"); + return runtime.cancelDetachedTaskRunById(params); } diff --git a/ui/src/lib/cron/index.test.ts b/ui/src/lib/cron/index.test.ts index 87d31d51d747..178576789f1d 100644 --- a/ui/src/lib/cron/index.test.ts +++ b/ui/src/lib/cron/index.test.ts @@ -1903,6 +1903,7 @@ describe("cron controller", () => { offset: 0, query: "daily", enabled: "enabled", + includeDeliveryPreviews: false, scheduleKind: "cron", lastRunStatus: "error", sortBy: "updatedAtMs", @@ -2622,6 +2623,7 @@ describe("loadCronFailingCount", () => { expect(request).toHaveBeenCalledWith("cron.list", { enabled: "enabled", + includeDeliveryPreviews: false, lastRunStatus: "error", limit: 1, offset: 0, diff --git a/ui/src/lib/cron/index.ts b/ui/src/lib/cron/index.ts index c607e767392c..fae6f5df687b 100644 --- a/ui/src/lib/cron/index.ts +++ b/ui/src/lib/cron/index.ts @@ -563,6 +563,7 @@ export async function loadCronJobsPage( const res = await state.client.request("cron.list", { ...(state.cronAgentId ? { agentId: state.cronAgentId } : {}), includeDisabled: state.cronJobsEnabledFilter === "all", + includeDeliveryPreviews: false, limit: state.cronJobsLimit, offset, query: state.cronJobsQuery.trim() || undefined, diff --git a/ui/src/lib/cron/scope.ts b/ui/src/lib/cron/scope.ts index e752395f0bdc..e940a71e4ddc 100644 --- a/ui/src/lib/cron/scope.ts +++ b/ui/src/lib/cron/scope.ts @@ -20,6 +20,7 @@ export async function loadCronFailingCount(state: CronScopeState) { const res = await state.client.request("cron.list", { ...(state.cronAgentId ? { agentId: state.cronAgentId } : {}), enabled: "enabled", + includeDeliveryPreviews: false, lastRunStatus: "error", limit: 1, offset: 0, @@ -41,12 +42,14 @@ export async function loadCronScopeStats(state: CronScopeState) { state.client.request("cron.list", { agentId: state.cronAgentId, includeDisabled: true, + includeDeliveryPreviews: false, limit: 1, offset: 0, }), state.client.request("cron.list", { agentId: state.cronAgentId, enabled: "enabled", + includeDeliveryPreviews: false, limit: 1, offset: 0, sortBy: "nextRunAtMs",