mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 22:21:33 +00:00
Gateway: harden manual compaction checkpoints
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { deleteSessionsAndRefresh, subscribeSessions, type SessionsState } from "./sessions.ts";
|
||||
import {
|
||||
deleteSessionsAndRefresh,
|
||||
loadSessions,
|
||||
subscribeSessions,
|
||||
type SessionsState,
|
||||
} from "./sessions.ts";
|
||||
|
||||
type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
|
||||
|
||||
@@ -125,3 +130,91 @@ describe("deleteSessionsAndRefresh", () => {
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSessions", () => {
|
||||
it("refreshes expanded checkpoint cards when the row summary changes", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: 1,
|
||||
path: "(multiple)",
|
||||
count: 1,
|
||||
defaults: {},
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:main",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
compactionCheckpointCount: 1,
|
||||
latestCompactionCheckpoint: {
|
||||
checkpointId: "checkpoint-new",
|
||||
createdAt: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "sessions.compaction.list") {
|
||||
return {
|
||||
ok: true,
|
||||
key: "agent:main:main",
|
||||
checkpoints: [
|
||||
{
|
||||
checkpointId: "checkpoint-new",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "session-1",
|
||||
createdAt: 20,
|
||||
reason: "manual",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const state = createState(request, {
|
||||
sessionsExpandedCheckpointKey: "agent:main:main",
|
||||
sessionsResult: {
|
||||
ts: 0,
|
||||
path: "(multiple)",
|
||||
count: 1,
|
||||
defaults: {},
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:main",
|
||||
kind: "direct",
|
||||
updatedAt: 0,
|
||||
compactionCheckpointCount: 3,
|
||||
latestCompactionCheckpoint: {
|
||||
checkpointId: "checkpoint-old",
|
||||
createdAt: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
sessionsCheckpointItemsByKey: {
|
||||
"agent:main:main": [
|
||||
{
|
||||
checkpointId: "checkpoint-old",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "session-old",
|
||||
createdAt: 10,
|
||||
reason: "manual",
|
||||
},
|
||||
] as never,
|
||||
},
|
||||
});
|
||||
|
||||
await loadSessions(state);
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "sessions.compaction.list", {
|
||||
key: "agent:main:main",
|
||||
});
|
||||
expect(
|
||||
state.sessionsCheckpointItemsByKey["agent:main:main"]?.map((item) => item.checkpointId),
|
||||
).toEqual(["checkpoint-new"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,67 @@ export type SessionsState = {
|
||||
sessionsCheckpointErrorByKey: Record<string, string>;
|
||||
};
|
||||
|
||||
function checkpointSignature(
|
||||
row:
|
||||
| {
|
||||
key: string;
|
||||
compactionCheckpointCount?: number;
|
||||
latestCompactionCheckpoint?: { checkpointId?: string; createdAt?: number } | null;
|
||||
}
|
||||
| undefined,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
key: row?.key ?? "",
|
||||
count: row?.compactionCheckpointCount ?? 0,
|
||||
latestCheckpointId: row?.latestCompactionCheckpoint?.checkpointId ?? "",
|
||||
latestCreatedAt: row?.latestCompactionCheckpoint?.createdAt ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateCheckpointCacheForKey(state: SessionsState, key: string) {
|
||||
if (
|
||||
!(key in state.sessionsCheckpointItemsByKey) &&
|
||||
!(key in state.sessionsCheckpointErrorByKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextItems = { ...state.sessionsCheckpointItemsByKey };
|
||||
const nextErrors = { ...state.sessionsCheckpointErrorByKey };
|
||||
delete nextItems[key];
|
||||
delete nextErrors[key];
|
||||
state.sessionsCheckpointItemsByKey = nextItems;
|
||||
state.sessionsCheckpointErrorByKey = nextErrors;
|
||||
}
|
||||
|
||||
async function fetchSessionCompactionCheckpoints(state: SessionsState, key: string) {
|
||||
state.sessionsCheckpointLoadingKey = key;
|
||||
state.sessionsCheckpointErrorByKey = {
|
||||
...state.sessionsCheckpointErrorByKey,
|
||||
[key]: "",
|
||||
};
|
||||
try {
|
||||
const result = await state.client?.request<SessionsCompactionListResult>(
|
||||
"sessions.compaction.list",
|
||||
{ key },
|
||||
);
|
||||
if (result) {
|
||||
state.sessionsCheckpointItemsByKey = {
|
||||
...state.sessionsCheckpointItemsByKey,
|
||||
[key]: result.checkpoints ?? [],
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
state.sessionsCheckpointErrorByKey = {
|
||||
...state.sessionsCheckpointErrorByKey,
|
||||
[key]: String(err),
|
||||
};
|
||||
} finally {
|
||||
if (state.sessionsCheckpointLoadingKey === key) {
|
||||
state.sessionsCheckpointLoadingKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeSessions(state: SessionsState) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
@@ -58,6 +119,9 @@ export async function loadSessions(
|
||||
state.sessionsLoading = true;
|
||||
state.sessionsError = null;
|
||||
try {
|
||||
const previousRows = new Map(
|
||||
(state.sessionsResult?.sessions ?? []).map((row) => [row.key, row] as const),
|
||||
);
|
||||
const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal;
|
||||
const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown;
|
||||
const activeMinutes = overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0);
|
||||
@@ -75,6 +139,30 @@ export async function loadSessions(
|
||||
const res = await state.client.request<SessionsListResult | undefined>("sessions.list", params);
|
||||
if (res) {
|
||||
state.sessionsResult = res;
|
||||
const nextKeys = new Set(res.sessions.map((row) => row.key));
|
||||
for (const key of Object.keys(state.sessionsCheckpointItemsByKey)) {
|
||||
if (!nextKeys.has(key)) {
|
||||
invalidateCheckpointCacheForKey(state, key);
|
||||
}
|
||||
}
|
||||
let expandedNeedsRefetch = false;
|
||||
for (const row of res.sessions) {
|
||||
const previous = previousRows.get(row.key);
|
||||
if (checkpointSignature(previous) !== checkpointSignature(row)) {
|
||||
invalidateCheckpointCacheForKey(state, row.key);
|
||||
if (state.sessionsExpandedCheckpointKey === row.key) {
|
||||
expandedNeedsRefetch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const expandedKey = state.sessionsExpandedCheckpointKey;
|
||||
if (
|
||||
expandedKey &&
|
||||
nextKeys.has(expandedKey) &&
|
||||
(expandedNeedsRefetch || !state.sessionsCheckpointItemsByKey[expandedKey])
|
||||
) {
|
||||
await fetchSessionCompactionCheckpoints(state, expandedKey);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMissingOperatorReadScopeError(err)) {
|
||||
@@ -181,32 +269,7 @@ export async function toggleSessionCompactionCheckpoints(state: SessionsState, k
|
||||
if (state.sessionsCheckpointItemsByKey[trimmedKey]) {
|
||||
return;
|
||||
}
|
||||
state.sessionsCheckpointLoadingKey = trimmedKey;
|
||||
state.sessionsCheckpointErrorByKey = {
|
||||
...state.sessionsCheckpointErrorByKey,
|
||||
[trimmedKey]: "",
|
||||
};
|
||||
try {
|
||||
const result = await state.client?.request<SessionsCompactionListResult>(
|
||||
"sessions.compaction.list",
|
||||
{ key: trimmedKey },
|
||||
);
|
||||
if (result) {
|
||||
state.sessionsCheckpointItemsByKey = {
|
||||
...state.sessionsCheckpointItemsByKey,
|
||||
[trimmedKey]: result.checkpoints ?? [],
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
state.sessionsCheckpointErrorByKey = {
|
||||
...state.sessionsCheckpointErrorByKey,
|
||||
[trimmedKey]: String(err),
|
||||
};
|
||||
} finally {
|
||||
if (state.sessionsCheckpointLoadingKey === trimmedKey) {
|
||||
state.sessionsCheckpointLoadingKey = null;
|
||||
}
|
||||
}
|
||||
await fetchSessionCompactionCheckpoints(state, trimmedKey);
|
||||
}
|
||||
|
||||
export async function branchSessionFromCheckpoint(
|
||||
|
||||
Reference in New Issue
Block a user