mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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
This commit is contained in:
committed by
GitHub
parent
b12f631a4a
commit
313daad549
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -440,6 +440,7 @@ describe("cron protocol validators", () => {
|
||||
sortDir: "asc",
|
||||
agentId: "ops",
|
||||
compact: true,
|
||||
includeDeliveryPreviews: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(validateCronListParams({ offset: -1 })).toBe(false);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, AvatarDataUrlCacheEntry>();
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -65,3 +65,7 @@ export const agentIdentityGetHandler: GatewayRequestHandlers["agent.identity.get
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
|
||||
export const agentIdentityHandlers: GatewayRequestHandlers = {
|
||||
"agent.identity.get": agentIdentityGetHandler,
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
type AgentIdentityGetHandler = NonNullable<(typeof agentHandlers)["agent.identity.get"]>;
|
||||
type AgentIdentityGetHandler = NonNullable<(typeof agentIdentityHandlers)["agent.identity.get"]>;
|
||||
|
||||
type AgentIdentityGetHandlerArgs = Parameters<AgentIdentityGetHandler>[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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, ProviderUsageStatus>;
|
||||
};
|
||||
|
||||
type ProviderUsageRefresh = {
|
||||
agentDir: string;
|
||||
configRef: object;
|
||||
credentialKey: string;
|
||||
providerKey: string;
|
||||
};
|
||||
|
||||
const usageCacheByAgentId = new Map<string, ProviderUsageCacheEntry>();
|
||||
const usageRefreshByAgentId = new Map<string, ProviderUsageRefresh>();
|
||||
let cacheGeneration = 0;
|
||||
|
||||
function sortedRecordEntries<T>(value: Record<string, T> | undefined) {
|
||||
return Object.entries(value ?? {}).toSorted(([left], [right]) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function fingerprintProviderUsageCredentials(params: {
|
||||
cfg: OpenClawConfig;
|
||||
directApiKeys: ReadonlyMap<string, { source: "config" | "env"; envVar?: string } | undefined>;
|
||||
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<ReturnType<typeof loadProviderUsageSummary>>) {
|
||||
const usageByProvider = new Map<string, ProviderUsageStatus>();
|
||||
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<string, ProviderUsageStatus> {
|
||||
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();
|
||||
}
|
||||
@@ -224,6 +224,14 @@ async function firstAuthStatusProvider() {
|
||||
return (payload as ModelAuthStatusResult).providers[0];
|
||||
}
|
||||
|
||||
async function readAuthStatus(params: Record<string, unknown> = {}) {
|
||||
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<void>((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<void>((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<void>((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: {
|
||||
|
||||
@@ -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<UsageProviderId>(["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<string, { ts: number; result: ModelAuthStatusResult }>();
|
||||
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<void> {
|
||||
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<string, ProviderUsageStatus>();
|
||||
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)));
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js";
|
||||
|
||||
type StartupTrace = {
|
||||
measure: <T>(name: string, run: () => T | Promise<T>) => Promise<T>;
|
||||
};
|
||||
|
||||
type GatewayHandlerPrewarmFamily = {
|
||||
name: string;
|
||||
load: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
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<typeof setTimeout> | 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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> | 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],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -563,6 +563,7 @@ export async function loadCronJobsPage(
|
||||
const res = await state.client.request<CronJobsListResult>("cron.list", {
|
||||
...(state.cronAgentId ? { agentId: state.cronAgentId } : {}),
|
||||
includeDisabled: state.cronJobsEnabledFilter === "all",
|
||||
includeDeliveryPreviews: false,
|
||||
limit: state.cronJobsLimit,
|
||||
offset,
|
||||
query: state.cronJobsQuery.trim() || undefined,
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function loadCronFailingCount(state: CronScopeState) {
|
||||
const res = await state.client.request<CronJobsListResult>("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<CronJobsListResult>("cron.list", {
|
||||
agentId: state.cronAgentId,
|
||||
includeDisabled: true,
|
||||
includeDeliveryPreviews: false,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
}),
|
||||
state.client.request<CronJobsListResult>("cron.list", {
|
||||
agentId: state.cronAgentId,
|
||||
enabled: "enabled",
|
||||
includeDeliveryPreviews: false,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
sortBy: "nextRunAtMs",
|
||||
|
||||
Reference in New Issue
Block a user