mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 13:02:10 +03:00
fix(gateway): normalize session key casing to prevent ghost sessions (#12846)
* fix(gateway): normalize session key casing to prevent ghost sessions on Linux On case-sensitive filesystems (Linux), mixed-case session keys like agent:ops:MySession and agent:ops:mysession resolve to different store entries, creating ghost duplicates that never converge. Core changes in session-utils.ts: - resolveSessionStoreKey: lowercase all session key components - canonicalizeSpawnedByForAgent: accept cfg, resolve main-alias references via canonicalizeMainSessionAlias after lowercasing - loadSessionEntry: return legacyKey only when it differs from canonicalKey - resolveGatewaySessionStoreTarget: scan store for case-insensitive matches; add optional scanLegacyKeys param to skip disk reads for read-only callers - Export findStoreKeysIgnoreCase for use by write-path consumers - Compare global/unknown sentinels case-insensitively in all canonicalization functions sessions-resolve.ts: - Make resolveSessionKeyFromResolveParams async for inline migration - Check canonical key first (fast path), then fall back to legacy scan - Delete ALL legacy case-variant keys in a single updateSessionStore pass Fixes #12603 * fix(gateway): propagate canonical keys and clean up all case variants on write paths - agent.ts: use canonicalizeSpawnedByForAgent (with cfg) instead of raw toLowerCase; use findStoreKeysIgnoreCase to delete all legacy variants on store write; pass canonicalKey to addChatRun, registerAgentRunContext, resolveSendPolicy, and agentCommand - sessions.ts: replace single-key migration with full case-variant cleanup via findStoreKeysIgnoreCase in patch/reset/delete/compact handlers; add case-insensitive fallback in preview (store already loaded); make sessions.resolve handler async; pass scanLegacyKeys: false in preview - server-node-events.ts: use findStoreKeysIgnoreCase to clean all legacy variants on voice.transcript and agent.request write paths; pass canonicalKey to addChatRun and agentCommand * test(gateway): add session key case-normalization tests Cover the case-insensitive session key canonicalization logic: - resolveSessionStoreKey normalizes mixed-case bare and prefixed keys - resolveSessionStoreKey resolves mixed-case main aliases (MAIN, Main) - resolveGatewaySessionStoreTarget includes legacy mixed-case store keys - resolveGatewaySessionStoreTarget collects all case-variant duplicates - resolveGatewaySessionStoreTarget finds legacy main alias keys with customized mainKey configuration All 5 tests fail before the production changes, pass after. * fix: clean legacy session alias cleanup gaps (openclaw#12846) thanks @mcaxtr --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -10,9 +10,13 @@ const mocks = vi.hoisted(() => ({
|
|||||||
loadConfigReturn: {} as Record<string, unknown>,
|
loadConfigReturn: {} as Record<string, unknown>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../session-utils.js", () => ({
|
vi.mock("../session-utils.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../session-utils.js")>("../session-utils.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
loadSessionEntry: mocks.loadSessionEntry,
|
loadSessionEntry: mocks.loadSessionEntry,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("../../config/sessions.js", async () => {
|
vi.mock("../../config/sessions.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||||
@@ -23,7 +27,13 @@ vi.mock("../../config/sessions.js", async () => {
|
|||||||
updateSessionStore: mocks.updateSessionStore,
|
updateSessionStore: mocks.updateSessionStore,
|
||||||
resolveAgentIdFromSessionKey: () => "main",
|
resolveAgentIdFromSessionKey: () => "main",
|
||||||
resolveExplicitAgentSessionKey: () => undefined,
|
resolveExplicitAgentSessionKey: () => undefined,
|
||||||
resolveAgentMainSessionKey: () => "agent:main:main",
|
resolveAgentMainSessionKey: ({
|
||||||
|
cfg,
|
||||||
|
agentId,
|
||||||
|
}: {
|
||||||
|
cfg?: { session?: { mainKey?: string } };
|
||||||
|
agentId: string;
|
||||||
|
}) => `agent:${agentId}:${cfg?.session?.mainKey ?? "main"}`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,4 +223,54 @@ describe("gateway agent handler", () => {
|
|||||||
expect(capturedEntry?.cliSessionIds).toBeUndefined();
|
expect(capturedEntry?.cliSessionIds).toBeUndefined();
|
||||||
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
|
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prunes legacy main alias keys when writing a canonical session entry", async () => {
|
||||||
|
mocks.loadSessionEntry.mockReturnValue({
|
||||||
|
cfg: {
|
||||||
|
session: { mainKey: "work" },
|
||||||
|
agents: { list: [{ id: "main", default: true }] },
|
||||||
|
},
|
||||||
|
storePath: "/tmp/sessions.json",
|
||||||
|
entry: {
|
||||||
|
sessionId: "existing-session-id",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
canonicalKey: "agent:main:work",
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedStore: Record<string, unknown> | undefined;
|
||||||
|
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||||
|
const store: Record<string, unknown> = {
|
||||||
|
"agent:main:work": { sessionId: "existing-session-id", updatedAt: 10 },
|
||||||
|
"agent:main:MAIN": { sessionId: "legacy-session-id", updatedAt: 5 },
|
||||||
|
};
|
||||||
|
await updater(store);
|
||||||
|
capturedStore = store;
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.agentCommand.mockResolvedValue({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
meta: { durationMs: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const respond = vi.fn();
|
||||||
|
await agentHandlers.agent({
|
||||||
|
params: {
|
||||||
|
message: "test",
|
||||||
|
agentId: "main",
|
||||||
|
sessionKey: "main",
|
||||||
|
idempotencyKey: "test-idem-alias-prune",
|
||||||
|
},
|
||||||
|
respond,
|
||||||
|
context: makeContext(),
|
||||||
|
req: { type: "req", id: "3", method: "agent" },
|
||||||
|
client: null,
|
||||||
|
isWebchatConnect: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.updateSessionStore).toHaveBeenCalled();
|
||||||
|
expect(capturedStore).toBeDefined();
|
||||||
|
expect(capturedStore?.["agent:main:work"]).toBeDefined();
|
||||||
|
expect(capturedStore?.["agent:main:MAIN"]).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ import {
|
|||||||
validateAgentParams,
|
validateAgentParams,
|
||||||
validateAgentWaitParams,
|
validateAgentWaitParams,
|
||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
import { loadSessionEntry } from "../session-utils.js";
|
import {
|
||||||
|
canonicalizeSpawnedByForAgent,
|
||||||
|
loadSessionEntry,
|
||||||
|
pruneLegacyStoreKeys,
|
||||||
|
resolveGatewaySessionStoreTarget,
|
||||||
|
} from "../session-utils.js";
|
||||||
import { formatForLog } from "../ws-log.js";
|
import { formatForLog } from "../ws-log.js";
|
||||||
import { waitForAgentJob } from "./agent-job.js";
|
import { waitForAgentJob } from "./agent-job.js";
|
||||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||||
@@ -213,6 +218,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
let sessionEntry: SessionEntry | undefined;
|
let sessionEntry: SessionEntry | undefined;
|
||||||
let bestEffortDeliver = false;
|
let bestEffortDeliver = false;
|
||||||
let cfgForAgent: ReturnType<typeof loadConfig> | undefined;
|
let cfgForAgent: ReturnType<typeof loadConfig> | undefined;
|
||||||
|
let resolvedSessionKey = requestedSessionKey;
|
||||||
|
|
||||||
if (requestedSessionKey) {
|
if (requestedSessionKey) {
|
||||||
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey);
|
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey);
|
||||||
@@ -220,7 +226,12 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sessionId = entry?.sessionId ?? randomUUID();
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
const labelValue = request.label?.trim() || entry?.label;
|
const labelValue = request.label?.trim() || entry?.label;
|
||||||
spawnedByValue = spawnedByValue || entry?.spawnedBy;
|
const sessionAgent = resolveAgentIdFromSessionKey(canonicalKey);
|
||||||
|
spawnedByValue = canonicalizeSpawnedByForAgent(
|
||||||
|
cfg,
|
||||||
|
sessionAgent,
|
||||||
|
spawnedByValue || entry?.spawnedBy,
|
||||||
|
);
|
||||||
let inheritedGroup:
|
let inheritedGroup:
|
||||||
| { groupId?: string; groupChannel?: string; groupSpace?: string }
|
| { groupId?: string; groupChannel?: string; groupSpace?: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -268,7 +279,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
const sendPolicy = resolveSendPolicy({
|
const sendPolicy = resolveSendPolicy({
|
||||||
cfg,
|
cfg,
|
||||||
entry,
|
entry,
|
||||||
sessionKey: requestedSessionKey,
|
sessionKey: canonicalKey,
|
||||||
channel: entry?.channel,
|
channel: entry?.channel,
|
||||||
chatType: entry?.chatType,
|
chatType: entry?.chatType,
|
||||||
});
|
});
|
||||||
@@ -282,21 +293,32 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
resolvedSessionId = sessionId;
|
resolvedSessionId = sessionId;
|
||||||
const canonicalSessionKey = canonicalKey;
|
const canonicalSessionKey = canonicalKey;
|
||||||
|
resolvedSessionKey = canonicalSessionKey;
|
||||||
const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey);
|
const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey);
|
||||||
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
await updateSessionStore(storePath, (store) => {
|
await updateSessionStore(storePath, (store) => {
|
||||||
|
const target = resolveGatewaySessionStoreTarget({
|
||||||
|
cfg,
|
||||||
|
key: requestedSessionKey,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
pruneLegacyStoreKeys({
|
||||||
|
store,
|
||||||
|
canonicalKey: target.canonicalKey,
|
||||||
|
candidates: target.storeKeys,
|
||||||
|
});
|
||||||
store[canonicalSessionKey] = nextEntry;
|
store[canonicalSessionKey] = nextEntry;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (canonicalSessionKey === mainSessionKey || canonicalSessionKey === "global") {
|
if (canonicalSessionKey === mainSessionKey || canonicalSessionKey === "global") {
|
||||||
context.addChatRun(idem, {
|
context.addChatRun(idem, {
|
||||||
sessionKey: requestedSessionKey,
|
sessionKey: canonicalSessionKey,
|
||||||
clientRunId: idem,
|
clientRunId: idem,
|
||||||
});
|
});
|
||||||
bestEffortDeliver = true;
|
bestEffortDeliver = true;
|
||||||
}
|
}
|
||||||
registerAgentRunContext(idem, { sessionKey: requestedSessionKey });
|
registerAgentRunContext(idem, { sessionKey: canonicalSessionKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
const runId = idem;
|
const runId = idem;
|
||||||
@@ -378,7 +400,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
images,
|
images,
|
||||||
to: resolvedTo,
|
to: resolvedTo,
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
sessionKey: requestedSessionKey,
|
sessionKey: resolvedSessionKey,
|
||||||
thinking: request.thinking,
|
thinking: request.thinking,
|
||||||
deliver,
|
deliver,
|
||||||
deliveryTargetMode,
|
deliveryTargetMode,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
listSessionsFromStore,
|
listSessionsFromStore,
|
||||||
loadCombinedSessionStoreForGateway,
|
loadCombinedSessionStoreForGateway,
|
||||||
loadSessionEntry,
|
loadSessionEntry,
|
||||||
|
pruneLegacyStoreKeys,
|
||||||
readSessionPreviewItemsFromTranscript,
|
readSessionPreviewItemsFromTranscript,
|
||||||
resolveGatewaySessionStoreTarget,
|
resolveGatewaySessionStoreTarget,
|
||||||
resolveSessionModelRef,
|
resolveSessionModelRef,
|
||||||
@@ -42,6 +43,31 @@ import {
|
|||||||
import { applySessionsPatchToStore } from "../sessions-patch.js";
|
import { applySessionsPatchToStore } from "../sessions-patch.js";
|
||||||
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
|
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
|
||||||
|
|
||||||
|
function migrateAndPruneSessionStoreKey(params: {
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
key: string;
|
||||||
|
store: Record<string, SessionEntry>;
|
||||||
|
}) {
|
||||||
|
const target = resolveGatewaySessionStoreTarget({
|
||||||
|
cfg: params.cfg,
|
||||||
|
key: params.key,
|
||||||
|
store: params.store,
|
||||||
|
});
|
||||||
|
const primaryKey = target.canonicalKey;
|
||||||
|
if (!params.store[primaryKey]) {
|
||||||
|
const existingKey = target.storeKeys.find((candidate) => Boolean(params.store[candidate]));
|
||||||
|
if (existingKey) {
|
||||||
|
params.store[primaryKey] = params.store[existingKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pruneLegacyStoreKeys({
|
||||||
|
store: params.store,
|
||||||
|
canonicalKey: primaryKey,
|
||||||
|
candidates: target.storeKeys,
|
||||||
|
});
|
||||||
|
return { target, primaryKey, entry: params.store[primaryKey] };
|
||||||
|
}
|
||||||
|
|
||||||
export const sessionsHandlers: GatewayRequestHandlers = {
|
export const sessionsHandlers: GatewayRequestHandlers = {
|
||||||
"sessions.list": ({ params, respond }) => {
|
"sessions.list": ({ params, respond }) => {
|
||||||
if (!validateSessionsListParams(params)) {
|
if (!validateSessionsListParams(params)) {
|
||||||
@@ -104,12 +130,16 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
try {
|
try {
|
||||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
const storeTarget = resolveGatewaySessionStoreTarget({ cfg, key, scanLegacyKeys: false });
|
||||||
const store = storeCache.get(target.storePath) ?? loadSessionStore(target.storePath);
|
const store =
|
||||||
storeCache.set(target.storePath, store);
|
storeCache.get(storeTarget.storePath) ?? loadSessionStore(storeTarget.storePath);
|
||||||
const entry =
|
storeCache.set(storeTarget.storePath, store);
|
||||||
target.storeKeys.map((candidate) => store[candidate]).find(Boolean) ??
|
const target = resolveGatewaySessionStoreTarget({
|
||||||
store[target.canonicalKey];
|
cfg,
|
||||||
|
key,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
const entry = target.storeKeys.map((candidate) => store[candidate]).find(Boolean);
|
||||||
if (!entry?.sessionId) {
|
if (!entry?.sessionId) {
|
||||||
previews.push({ key, status: "missing", items: [] });
|
previews.push({ key, status: "missing", items: [] });
|
||||||
continue;
|
continue;
|
||||||
@@ -134,7 +164,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
|
|
||||||
respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined);
|
respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined);
|
||||||
},
|
},
|
||||||
"sessions.resolve": ({ params, respond }) => {
|
"sessions.resolve": async ({ params, respond }) => {
|
||||||
if (!validateSessionsResolveParams(params)) {
|
if (!validateSessionsResolveParams(params)) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
@@ -149,7 +179,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
const p = params;
|
const p = params;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
|
||||||
const resolved = resolveSessionKeyFromResolveParams({ cfg, p });
|
const resolved = await resolveSessionKeyFromResolveParams({ cfg, p });
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
respond(false, undefined, resolved.error);
|
respond(false, undefined, resolved.error);
|
||||||
return;
|
return;
|
||||||
@@ -179,12 +209,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||||
const storePath = target.storePath;
|
const storePath = target.storePath;
|
||||||
const applied = await updateSessionStore(storePath, async (store) => {
|
const applied = await updateSessionStore(storePath, async (store) => {
|
||||||
const primaryKey = target.storeKeys[0] ?? key;
|
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
|
||||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
|
||||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
|
||||||
store[primaryKey] = store[existingKey];
|
|
||||||
delete store[existingKey];
|
|
||||||
}
|
|
||||||
return await applySessionsPatchToStore({
|
return await applySessionsPatchToStore({
|
||||||
cfg,
|
cfg,
|
||||||
store,
|
store,
|
||||||
@@ -235,12 +260,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||||
const storePath = target.storePath;
|
const storePath = target.storePath;
|
||||||
const next = await updateSessionStore(storePath, (store) => {
|
const next = await updateSessionStore(storePath, (store) => {
|
||||||
const primaryKey = target.storeKeys[0] ?? key;
|
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
|
||||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
|
||||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
|
||||||
store[primaryKey] = store[existingKey];
|
|
||||||
delete store[existingKey];
|
|
||||||
}
|
|
||||||
const entry = store[primaryKey];
|
const entry = store[primaryKey];
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const nextEntry: SessionEntry = {
|
const nextEntry: SessionEntry = {
|
||||||
@@ -331,12 +351,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await updateSessionStore(storePath, (store) => {
|
await updateSessionStore(storePath, (store) => {
|
||||||
const primaryKey = target.storeKeys[0] ?? key;
|
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
|
||||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
|
||||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
|
||||||
store[primaryKey] = store[existingKey];
|
|
||||||
delete store[existingKey];
|
|
||||||
}
|
|
||||||
if (store[primaryKey]) {
|
if (store[primaryKey]) {
|
||||||
delete store[primaryKey];
|
delete store[primaryKey];
|
||||||
}
|
}
|
||||||
@@ -392,13 +407,8 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
const storePath = target.storePath;
|
const storePath = target.storePath;
|
||||||
// Lock + read in a short critical section; transcript work happens outside.
|
// Lock + read in a short critical section; transcript work happens outside.
|
||||||
const compactTarget = await updateSessionStore(storePath, (store) => {
|
const compactTarget = await updateSessionStore(storePath, (store) => {
|
||||||
const primaryKey = target.storeKeys[0] ?? key;
|
const { entry, primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
|
||||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
return { entry, primaryKey };
|
||||||
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
|
||||||
store[primaryKey] = store[existingKey];
|
|
||||||
delete store[existingKey];
|
|
||||||
}
|
|
||||||
return { entry: store[primaryKey], primaryKey };
|
|
||||||
});
|
});
|
||||||
const entry = compactTarget.entry;
|
const entry = compactTarget.entry;
|
||||||
const sessionId = entry?.sessionId;
|
const sessionId = entry?.sessionId;
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
|||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { normalizeMainKey } from "../routing/session-key.js";
|
import { normalizeMainKey } from "../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { loadSessionEntry } from "./session-utils.js";
|
import {
|
||||||
|
loadSessionEntry,
|
||||||
|
pruneLegacyStoreKeys,
|
||||||
|
resolveGatewaySessionStoreTarget,
|
||||||
|
} from "./session-utils.js";
|
||||||
import { formatForLog } from "./ws-log.js";
|
import { formatForLog } from "./ws-log.js";
|
||||||
|
|
||||||
export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt: NodeEvent) => {
|
export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt: NodeEvent) => {
|
||||||
@@ -41,6 +45,12 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
const sessionId = entry?.sessionId ?? randomUUID();
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
await updateSessionStore(storePath, (store) => {
|
await updateSessionStore(storePath, (store) => {
|
||||||
|
const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey, store });
|
||||||
|
pruneLegacyStoreKeys({
|
||||||
|
store,
|
||||||
|
canonicalKey: target.canonicalKey,
|
||||||
|
candidates: target.storeKeys,
|
||||||
|
});
|
||||||
store[canonicalKey] = {
|
store[canonicalKey] = {
|
||||||
sessionId,
|
sessionId,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -58,7 +68,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
// Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send).
|
// Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send).
|
||||||
// This maps agent bus events (keyed by sessionId) to chat events (keyed by clientRunId).
|
// This maps agent bus events (keyed by sessionId) to chat events (keyed by clientRunId).
|
||||||
ctx.addChatRun(sessionId, {
|
ctx.addChatRun(sessionId, {
|
||||||
sessionKey,
|
sessionKey: canonicalKey,
|
||||||
clientRunId: `voice-${randomUUID()}`,
|
clientRunId: `voice-${randomUUID()}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +76,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
{
|
{
|
||||||
message: text,
|
message: text,
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionKey,
|
sessionKey: canonicalKey,
|
||||||
thinking: "low",
|
thinking: "low",
|
||||||
deliver: false,
|
deliver: false,
|
||||||
messageChannel: "node",
|
messageChannel: "node",
|
||||||
@@ -113,11 +123,18 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
|
|
||||||
const sessionKeyRaw = (link?.sessionKey ?? "").trim();
|
const sessionKeyRaw = (link?.sessionKey ?? "").trim();
|
||||||
const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
|
const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
|
||||||
|
const cfg = loadConfig();
|
||||||
const { storePath, entry, canonicalKey } = loadSessionEntry(sessionKey);
|
const { storePath, entry, canonicalKey } = loadSessionEntry(sessionKey);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sessionId = entry?.sessionId ?? randomUUID();
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
await updateSessionStore(storePath, (store) => {
|
await updateSessionStore(storePath, (store) => {
|
||||||
|
const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey, store });
|
||||||
|
pruneLegacyStoreKeys({
|
||||||
|
store,
|
||||||
|
canonicalKey: target.canonicalKey,
|
||||||
|
candidates: target.storeKeys,
|
||||||
|
});
|
||||||
store[canonicalKey] = {
|
store[canonicalKey] = {
|
||||||
sessionId,
|
sessionId,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -136,7 +153,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
{
|
{
|
||||||
message,
|
message,
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionKey,
|
sessionKey: canonicalKey,
|
||||||
thinking: link?.thinking ?? undefined,
|
thinking: link?.thinking ?? undefined,
|
||||||
deliver,
|
deliver,
|
||||||
to,
|
to,
|
||||||
|
|||||||
@@ -419,6 +419,129 @@ describe("gateway server sessions", () => {
|
|||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sessions.preview resolves legacy mixed-case main alias with custom mainKey", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-preview-alias-"));
|
||||||
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
testState.sessionStorePath = storePath;
|
||||||
|
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
|
||||||
|
testState.sessionConfig = { mainKey: "work" };
|
||||||
|
const sessionId = "sess-legacy-main";
|
||||||
|
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "Legacy alias transcript" } }),
|
||||||
|
];
|
||||||
|
await fs.writeFile(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
"agent:ops:MAIN": {
|
||||||
|
sessionId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { ws } = await openClient();
|
||||||
|
const preview = await rpcReq<{
|
||||||
|
previews: Array<{
|
||||||
|
key: string;
|
||||||
|
status: string;
|
||||||
|
items: Array<{ role: string; text: string }>;
|
||||||
|
}>;
|
||||||
|
}>(ws, "sessions.preview", { keys: ["main"], limit: 3, maxChars: 120 });
|
||||||
|
|
||||||
|
expect(preview.ok).toBe(true);
|
||||||
|
const entry = preview.payload?.previews[0];
|
||||||
|
expect(entry?.key).toBe("main");
|
||||||
|
expect(entry?.status).toBe("ok");
|
||||||
|
expect(entry?.items[0]?.text).toContain("Legacy alias transcript");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sessions.resolve and mutators clean legacy main-alias ghost keys", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-cleanup-alias-"));
|
||||||
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
testState.sessionStorePath = storePath;
|
||||||
|
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
|
||||||
|
testState.sessionConfig = { mainKey: "work" };
|
||||||
|
const sessionId = "sess-alias-cleanup";
|
||||||
|
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
|
||||||
|
await fs.writeFile(
|
||||||
|
transcriptPath,
|
||||||
|
`${Array.from({ length: 8 })
|
||||||
|
.map((_, idx) => JSON.stringify({ role: "assistant", content: `line ${idx}` }))
|
||||||
|
.join("\n")}\n`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const writeRawStore = async (store: Record<string, unknown>) => {
|
||||||
|
await fs.writeFile(storePath, `${JSON.stringify(store, null, 2)}\n`, "utf-8");
|
||||||
|
};
|
||||||
|
const readStore = async () =>
|
||||||
|
JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
await writeRawStore({
|
||||||
|
"agent:ops:MAIN": { sessionId, updatedAt: Date.now() - 2_000 },
|
||||||
|
"agent:ops:Main": { sessionId, updatedAt: Date.now() - 1_000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ws } = await openClient();
|
||||||
|
|
||||||
|
const resolved = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", {
|
||||||
|
key: "main",
|
||||||
|
});
|
||||||
|
expect(resolved.ok).toBe(true);
|
||||||
|
expect(resolved.payload?.key).toBe("agent:ops:work");
|
||||||
|
let store = await readStore();
|
||||||
|
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||||
|
|
||||||
|
await writeRawStore({
|
||||||
|
...store,
|
||||||
|
"agent:ops:MAIN": { ...store["agent:ops:work"] },
|
||||||
|
});
|
||||||
|
const patched = await rpcReq<{ ok: true; key: string }>(ws, "sessions.patch", {
|
||||||
|
key: "main",
|
||||||
|
thinkingLevel: "medium",
|
||||||
|
});
|
||||||
|
expect(patched.ok).toBe(true);
|
||||||
|
expect(patched.payload?.key).toBe("agent:ops:work");
|
||||||
|
store = await readStore();
|
||||||
|
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||||
|
expect(store["agent:ops:work"]?.thinkingLevel).toBe("medium");
|
||||||
|
|
||||||
|
await writeRawStore({
|
||||||
|
...store,
|
||||||
|
"agent:ops:MAIN": { ...store["agent:ops:work"] },
|
||||||
|
});
|
||||||
|
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(ws, "sessions.compact", {
|
||||||
|
key: "main",
|
||||||
|
maxLines: 3,
|
||||||
|
});
|
||||||
|
expect(compacted.ok).toBe(true);
|
||||||
|
expect(compacted.payload?.compacted).toBe(true);
|
||||||
|
store = await readStore();
|
||||||
|
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||||
|
|
||||||
|
await writeRawStore({
|
||||||
|
...store,
|
||||||
|
"agent:ops:MAIN": { ...store["agent:ops:work"] },
|
||||||
|
});
|
||||||
|
const reset = await rpcReq<{ ok: true; key: string }>(ws, "sessions.reset", { key: "main" });
|
||||||
|
expect(reset.ok).toBe(true);
|
||||||
|
expect(reset.payload?.key).toBe("agent:ops:work");
|
||||||
|
store = await readStore();
|
||||||
|
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("sessions.delete rejects main and aborts active runs", async () => {
|
test("sessions.delete rejects main and aborts active runs", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-"));
|
||||||
const storePath = path.join(dir, "sessions.json");
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
deriveSessionTitle,
|
deriveSessionTitle,
|
||||||
listSessionsFromStore,
|
listSessionsFromStore,
|
||||||
parseGroupKey,
|
parseGroupKey,
|
||||||
|
pruneLegacyStoreKeys,
|
||||||
resolveGatewaySessionStoreTarget,
|
resolveGatewaySessionStoreTarget,
|
||||||
resolveSessionStoreKey,
|
resolveSessionStoreKey,
|
||||||
} from "./session-utils.js";
|
} from "./session-utils.js";
|
||||||
@@ -50,6 +52,9 @@ describe("gateway session utils", () => {
|
|||||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("agent:ops:work");
|
expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("agent:ops:work");
|
||||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "work" })).toBe("agent:ops:work");
|
expect(resolveSessionStoreKey({ cfg, sessionKey: "work" })).toBe("agent:ops:work");
|
||||||
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:main" })).toBe("agent:ops:work");
|
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:main" })).toBe("agent:ops:work");
|
||||||
|
// Mixed-case main alias must also resolve to the configured mainKey (idempotent)
|
||||||
|
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:MAIN" })).toBe("agent:ops:work");
|
||||||
|
expect(resolveSessionStoreKey({ cfg, sessionKey: "MAIN" })).toBe("agent:ops:work");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveSessionStoreKey canonicalizes bare keys to default agent", () => {
|
test("resolveSessionStoreKey canonicalizes bare keys to default agent", () => {
|
||||||
@@ -65,6 +70,23 @@ describe("gateway session utils", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resolveSessionStoreKey normalizes session key casing", () => {
|
||||||
|
const cfg = {
|
||||||
|
session: { mainKey: "main" },
|
||||||
|
agents: { list: [{ id: "ops", default: true }] },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
// Bare keys with different casing must resolve to the same canonical key
|
||||||
|
expect(resolveSessionStoreKey({ cfg, sessionKey: "CoP" })).toBe(
|
||||||
|
resolveSessionStoreKey({ cfg, sessionKey: "cop" }),
|
||||||
|
);
|
||||||
|
expect(resolveSessionStoreKey({ cfg, sessionKey: "MySession" })).toBe("agent:ops:mysession");
|
||||||
|
// Prefixed agent keys with mixed-case rest must also normalize
|
||||||
|
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:ops:CoP" })).toBe("agent:ops:cop");
|
||||||
|
expect(resolveSessionStoreKey({ cfg, sessionKey: "agent:alpha:MySession" })).toBe(
|
||||||
|
"agent:alpha:mysession",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("resolveSessionStoreKey honors global scope", () => {
|
test("resolveSessionStoreKey honors global scope", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
session: { scope: "global", mainKey: "work" },
|
session: { scope: "global", mainKey: "work" },
|
||||||
@@ -92,6 +114,89 @@ describe("gateway session utils", () => {
|
|||||||
expect(target.storeKeys).toEqual(expect.arrayContaining(["agent:ops:main", "main"]));
|
expect(target.storeKeys).toEqual(expect.arrayContaining(["agent:ops:main", "main"]));
|
||||||
expect(target.storePath).toBe(path.resolve(storeTemplate.replace("{agentId}", "ops")));
|
expect(target.storePath).toBe(path.resolve(storeTemplate.replace("{agentId}", "ops")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resolveGatewaySessionStoreTarget includes legacy mixed-case store key", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-case-"));
|
||||||
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
// Simulate a legacy store with a mixed-case key
|
||||||
|
fs.writeFileSync(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify({ "agent:ops:MySession": { sessionId: "s1", updatedAt: 1 } }),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const cfg = {
|
||||||
|
session: { mainKey: "main", store: storePath },
|
||||||
|
agents: { list: [{ id: "ops", default: true }] },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
// Client passes the lowercased canonical key (as returned by sessions.list)
|
||||||
|
const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:ops:mysession" });
|
||||||
|
expect(target.canonicalKey).toBe("agent:ops:mysession");
|
||||||
|
// storeKeys must include the legacy mixed-case key from the on-disk store
|
||||||
|
expect(target.storeKeys).toEqual(
|
||||||
|
expect.arrayContaining(["agent:ops:mysession", "agent:ops:MySession"]),
|
||||||
|
);
|
||||||
|
// The legacy key must resolve to the actual entry in the store
|
||||||
|
const store = JSON.parse(fs.readFileSync(storePath, "utf8"));
|
||||||
|
const found = target.storeKeys.some((k) => Boolean(store[k]));
|
||||||
|
expect(found).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveGatewaySessionStoreTarget includes all case-variant duplicate keys", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-dupes-"));
|
||||||
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
// Simulate a store with both canonical and legacy mixed-case entries
|
||||||
|
fs.writeFileSync(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify({
|
||||||
|
"agent:ops:mysession": { sessionId: "s-lower", updatedAt: 2 },
|
||||||
|
"agent:ops:MySession": { sessionId: "s-mixed", updatedAt: 1 },
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const cfg = {
|
||||||
|
session: { mainKey: "main", store: storePath },
|
||||||
|
agents: { list: [{ id: "ops", default: true }] },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:ops:mysession" });
|
||||||
|
// storeKeys must include BOTH variants so delete/reset/patch can clean up all duplicates
|
||||||
|
expect(target.storeKeys).toEqual(
|
||||||
|
expect.arrayContaining(["agent:ops:mysession", "agent:ops:MySession"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveGatewaySessionStoreTarget finds legacy main alias key when mainKey is customized", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-alias-"));
|
||||||
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
// Legacy store has entry under "agent:ops:MAIN" but mainKey is "work"
|
||||||
|
fs.writeFileSync(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify({ "agent:ops:MAIN": { sessionId: "s1", updatedAt: 1 } }),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const cfg = {
|
||||||
|
session: { mainKey: "work", store: storePath },
|
||||||
|
agents: { list: [{ id: "ops", default: true }] },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:ops:main" });
|
||||||
|
expect(target.canonicalKey).toBe("agent:ops:work");
|
||||||
|
// storeKeys must include the legacy mixed-case alias key
|
||||||
|
expect(target.storeKeys).toEqual(expect.arrayContaining(["agent:ops:MAIN"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pruneLegacyStoreKeys removes alias and case-variant ghost keys", () => {
|
||||||
|
const store: Record<string, unknown> = {
|
||||||
|
"agent:ops:work": { sessionId: "canonical", updatedAt: 3 },
|
||||||
|
"agent:ops:MAIN": { sessionId: "legacy-upper", updatedAt: 1 },
|
||||||
|
"agent:ops:Main": { sessionId: "legacy-mixed", updatedAt: 2 },
|
||||||
|
"agent:ops:main": { sessionId: "legacy-lower", updatedAt: 4 },
|
||||||
|
};
|
||||||
|
pruneLegacyStoreKeys({
|
||||||
|
store,
|
||||||
|
canonicalKey: "agent:ops:work",
|
||||||
|
candidates: ["agent:ops:work", "agent:ops:main"],
|
||||||
|
});
|
||||||
|
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deriveSessionTitle", () => {
|
describe("deriveSessionTitle", () => {
|
||||||
|
|||||||
+142
-24
@@ -19,6 +19,7 @@ import {
|
|||||||
buildGroupDisplayName,
|
buildGroupDisplayName,
|
||||||
canonicalizeMainSessionAlias,
|
canonicalizeMainSessionAlias,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveAgentMainSessionKey,
|
||||||
resolveFreshSessionTotalTokens,
|
resolveFreshSessionTotalTokens,
|
||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
@@ -189,8 +190,81 @@ export function loadSessionEntry(sessionKey: string) {
|
|||||||
const agentId = resolveSessionStoreAgentId(cfg, canonicalKey);
|
const agentId = resolveSessionStoreAgentId(cfg, canonicalKey);
|
||||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const entry = store[canonicalKey];
|
const match = findStoreMatch(store, canonicalKey, sessionKey.trim());
|
||||||
return { cfg, storePath, store, entry, canonicalKey };
|
const legacyKey = match?.key !== canonicalKey ? match?.key : undefined;
|
||||||
|
return { cfg, storePath, store, entry: match?.entry, canonicalKey, legacyKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a session entry by exact or case-insensitive key match.
|
||||||
|
* Returns both the entry and the actual store key it was found under,
|
||||||
|
* so callers can clean up legacy mixed-case keys when they differ from canonicalKey.
|
||||||
|
*/
|
||||||
|
function findStoreMatch(
|
||||||
|
store: Record<string, SessionEntry>,
|
||||||
|
...candidates: string[]
|
||||||
|
): { entry: SessionEntry; key: string } | undefined {
|
||||||
|
// Exact match first.
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate && store[candidate]) {
|
||||||
|
return { entry: store[candidate], key: candidate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Case-insensitive scan for ALL candidates.
|
||||||
|
const loweredSet = new Set(candidates.filter(Boolean).map((c) => c.toLowerCase()));
|
||||||
|
for (const key of Object.keys(store)) {
|
||||||
|
if (loweredSet.has(key.toLowerCase())) {
|
||||||
|
return { entry: store[key], key };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all on-disk store keys that match the given key case-insensitively.
|
||||||
|
* Returns every key from the store whose lowercased form equals the target's lowercased form.
|
||||||
|
*/
|
||||||
|
export function findStoreKeysIgnoreCase(
|
||||||
|
store: Record<string, unknown>,
|
||||||
|
targetKey: string,
|
||||||
|
): string[] {
|
||||||
|
const lowered = targetKey.toLowerCase();
|
||||||
|
const matches: string[] = [];
|
||||||
|
for (const key of Object.keys(store)) {
|
||||||
|
if (key.toLowerCase() === lowered) {
|
||||||
|
matches.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove legacy key variants for one canonical session key.
|
||||||
|
* Candidates can include aliases (for example, "agent:ops:main" when canonical is "agent:ops:work").
|
||||||
|
*/
|
||||||
|
export function pruneLegacyStoreKeys(params: {
|
||||||
|
store: Record<string, unknown>;
|
||||||
|
canonicalKey: string;
|
||||||
|
candidates: Iterable<string>;
|
||||||
|
}) {
|
||||||
|
const keysToDelete = new Set<string>();
|
||||||
|
for (const candidate of params.candidates) {
|
||||||
|
const trimmed = String(candidate ?? "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed !== params.canonicalKey) {
|
||||||
|
keysToDelete.add(trimmed);
|
||||||
|
}
|
||||||
|
for (const match of findStoreKeysIgnoreCase(params.store, trimmed)) {
|
||||||
|
if (match !== params.canonicalKey) {
|
||||||
|
keysToDelete.add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of keysToDelete) {
|
||||||
|
delete params.store[key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] {
|
export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] {
|
||||||
@@ -334,13 +408,14 @@ export function listAgentsForGateway(cfg: OpenClawConfig): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
|
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
|
||||||
if (key === "global" || key === "unknown") {
|
const lowered = key.toLowerCase();
|
||||||
return key;
|
if (lowered === "global" || lowered === "unknown") {
|
||||||
|
return lowered;
|
||||||
}
|
}
|
||||||
if (key.startsWith("agent:")) {
|
if (lowered.startsWith("agent:")) {
|
||||||
return key;
|
return lowered;
|
||||||
}
|
}
|
||||||
return `agent:${normalizeAgentId(agentId)}:${key}`;
|
return `agent:${normalizeAgentId(agentId)}:${lowered}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultStoreAgentId(cfg: OpenClawConfig): string {
|
function resolveDefaultStoreAgentId(cfg: OpenClawConfig): string {
|
||||||
@@ -355,30 +430,33 @@ export function resolveSessionStoreKey(params: {
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
if (raw === "global" || raw === "unknown") {
|
const rawLower = raw.toLowerCase();
|
||||||
return raw;
|
if (rawLower === "global" || rawLower === "unknown") {
|
||||||
|
return rawLower;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseAgentSessionKey(raw);
|
const parsed = parseAgentSessionKey(raw);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const agentId = normalizeAgentId(parsed.agentId);
|
const agentId = normalizeAgentId(parsed.agentId);
|
||||||
|
const lowered = raw.toLowerCase();
|
||||||
const canonical = canonicalizeMainSessionAlias({
|
const canonical = canonicalizeMainSessionAlias({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
agentId,
|
agentId,
|
||||||
sessionKey: raw,
|
sessionKey: lowered,
|
||||||
});
|
});
|
||||||
if (canonical !== raw) {
|
if (canonical !== lowered) {
|
||||||
return canonical;
|
return canonical;
|
||||||
}
|
}
|
||||||
return raw;
|
return lowered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lowered = raw.toLowerCase();
|
||||||
const rawMainKey = normalizeMainKey(params.cfg.session?.mainKey);
|
const rawMainKey = normalizeMainKey(params.cfg.session?.mainKey);
|
||||||
if (raw === "main" || raw === rawMainKey) {
|
if (lowered === "main" || lowered === rawMainKey) {
|
||||||
return resolveMainSessionKey(params.cfg);
|
return resolveMainSessionKey(params.cfg);
|
||||||
}
|
}
|
||||||
const agentId = resolveDefaultStoreAgentId(params.cfg);
|
const agentId = resolveDefaultStoreAgentId(params.cfg);
|
||||||
return canonicalizeSessionKeyForAgent(agentId, raw);
|
return canonicalizeSessionKeyForAgent(agentId, lowered);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSessionStoreAgentId(cfg: OpenClawConfig, canonicalKey: string): string {
|
function resolveSessionStoreAgentId(cfg: OpenClawConfig, canonicalKey: string): string {
|
||||||
@@ -392,21 +470,37 @@ function resolveSessionStoreAgentId(cfg: OpenClawConfig, canonicalKey: string):
|
|||||||
return resolveDefaultStoreAgentId(cfg);
|
return resolveDefaultStoreAgentId(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalizeSpawnedByForAgent(agentId: string, spawnedBy?: string): string | undefined {
|
export function canonicalizeSpawnedByForAgent(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
agentId: string,
|
||||||
|
spawnedBy?: string,
|
||||||
|
): string | undefined {
|
||||||
const raw = spawnedBy?.trim();
|
const raw = spawnedBy?.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (raw === "global" || raw === "unknown") {
|
const lower = raw.toLowerCase();
|
||||||
return raw;
|
if (lower === "global" || lower === "unknown") {
|
||||||
|
return lower;
|
||||||
}
|
}
|
||||||
if (raw.startsWith("agent:")) {
|
let result: string;
|
||||||
return raw;
|
if (raw.toLowerCase().startsWith("agent:")) {
|
||||||
|
result = raw.toLowerCase();
|
||||||
|
} else {
|
||||||
|
result = `agent:${normalizeAgentId(agentId)}:${lower}`;
|
||||||
}
|
}
|
||||||
return `agent:${normalizeAgentId(agentId)}:${raw}`;
|
// Resolve main-alias references (e.g. agent:ops:main → configured main key).
|
||||||
|
const parsed = parseAgentSessionKey(result);
|
||||||
|
const resolvedAgent = parsed?.agentId ? normalizeAgentId(parsed.agentId) : agentId;
|
||||||
|
return canonicalizeMainSessionAlias({ cfg, agentId: resolvedAgent, sessionKey: result });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveGatewaySessionStoreTarget(params: { cfg: OpenClawConfig; key: string }): {
|
export function resolveGatewaySessionStoreTarget(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
key: string;
|
||||||
|
scanLegacyKeys?: boolean;
|
||||||
|
store?: Record<string, SessionEntry>;
|
||||||
|
}): {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
storePath: string;
|
storePath: string;
|
||||||
canonicalKey: string;
|
canonicalKey: string;
|
||||||
@@ -431,6 +525,23 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: OpenClawConfig;
|
|||||||
if (key && key !== canonicalKey) {
|
if (key && key !== canonicalKey) {
|
||||||
storeKeys.add(key);
|
storeKeys.add(key);
|
||||||
}
|
}
|
||||||
|
if (params.scanLegacyKeys !== false) {
|
||||||
|
// Build a set of scan targets: all known keys plus the main alias key so we
|
||||||
|
// catch legacy entries stored under "agent:{id}:MAIN" when mainKey != "main".
|
||||||
|
const scanTargets = new Set(storeKeys);
|
||||||
|
const agentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId });
|
||||||
|
if (canonicalKey === agentMainKey) {
|
||||||
|
scanTargets.add(`agent:${agentId}:main`);
|
||||||
|
}
|
||||||
|
// Scan the on-disk store for case variants of every target to find
|
||||||
|
// legacy mixed-case entries (e.g. "agent:ops:MAIN" when canonical is "agent:ops:work").
|
||||||
|
const store = params.store ?? loadSessionStore(storePath);
|
||||||
|
for (const seed of scanTargets) {
|
||||||
|
for (const legacyKey of findStoreKeysIgnoreCase(store, seed)) {
|
||||||
|
storeKeys.add(legacyKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
agentId,
|
agentId,
|
||||||
storePath,
|
storePath,
|
||||||
@@ -441,25 +552,30 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: OpenClawConfig;
|
|||||||
|
|
||||||
// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data.
|
// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data.
|
||||||
function mergeSessionEntryIntoCombined(params: {
|
function mergeSessionEntryIntoCombined(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
combined: Record<string, SessionEntry>;
|
combined: Record<string, SessionEntry>;
|
||||||
entry: SessionEntry;
|
entry: SessionEntry;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
canonicalKey: string;
|
canonicalKey: string;
|
||||||
}) {
|
}) {
|
||||||
const { combined, entry, agentId, canonicalKey } = params;
|
const { cfg, combined, entry, agentId, canonicalKey } = params;
|
||||||
const existing = combined[canonicalKey];
|
const existing = combined[canonicalKey];
|
||||||
|
|
||||||
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
|
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
|
||||||
combined[canonicalKey] = {
|
combined[canonicalKey] = {
|
||||||
...entry,
|
...entry,
|
||||||
...existing,
|
...existing,
|
||||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, existing.spawnedBy ?? entry.spawnedBy),
|
spawnedBy: canonicalizeSpawnedByForAgent(cfg, agentId, existing.spawnedBy ?? entry.spawnedBy),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
combined[canonicalKey] = {
|
combined[canonicalKey] = {
|
||||||
...existing,
|
...existing,
|
||||||
...entry,
|
...entry,
|
||||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
|
spawnedBy: canonicalizeSpawnedByForAgent(
|
||||||
|
cfg,
|
||||||
|
agentId,
|
||||||
|
entry.spawnedBy ?? existing?.spawnedBy,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,6 +593,7 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): {
|
|||||||
for (const [key, entry] of Object.entries(store)) {
|
for (const [key, entry] of Object.entries(store)) {
|
||||||
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
|
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
|
||||||
mergeSessionEntryIntoCombined({
|
mergeSessionEntryIntoCombined({
|
||||||
|
cfg,
|
||||||
combined,
|
combined,
|
||||||
entry,
|
entry,
|
||||||
agentId: defaultAgentId,
|
agentId: defaultAgentId,
|
||||||
@@ -494,6 +611,7 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): {
|
|||||||
for (const [key, entry] of Object.entries(store)) {
|
for (const [key, entry] of Object.entries(store)) {
|
||||||
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
|
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
|
||||||
mergeSessionEntryIntoCombined({
|
mergeSessionEntryIntoCombined({
|
||||||
|
cfg,
|
||||||
combined,
|
combined,
|
||||||
entry,
|
entry,
|
||||||
agentId,
|
agentId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { loadSessionStore } from "../config/sessions.js";
|
import { loadSessionStore, updateSessionStore } from "../config/sessions.js";
|
||||||
import { parseSessionLabel } from "../sessions/session-label.js";
|
import { parseSessionLabel } from "../sessions/session-label.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
@@ -10,15 +10,16 @@ import {
|
|||||||
import {
|
import {
|
||||||
listSessionsFromStore,
|
listSessionsFromStore,
|
||||||
loadCombinedSessionStoreForGateway,
|
loadCombinedSessionStoreForGateway,
|
||||||
|
pruneLegacyStoreKeys,
|
||||||
resolveGatewaySessionStoreTarget,
|
resolveGatewaySessionStoreTarget,
|
||||||
} from "./session-utils.js";
|
} from "./session-utils.js";
|
||||||
|
|
||||||
export type SessionsResolveResult = { ok: true; key: string } | { ok: false; error: ErrorShape };
|
export type SessionsResolveResult = { ok: true; key: string } | { ok: false; error: ErrorShape };
|
||||||
|
|
||||||
export function resolveSessionKeyFromResolveParams(params: {
|
export async function resolveSessionKeyFromResolveParams(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
p: SessionsResolveParams;
|
p: SessionsResolveParams;
|
||||||
}): SessionsResolveResult {
|
}): Promise<SessionsResolveResult> {
|
||||||
const { cfg, p } = params;
|
const { cfg, p } = params;
|
||||||
|
|
||||||
const key = typeof p.key === "string" ? p.key.trim() : "";
|
const key = typeof p.key === "string" ? p.key.trim() : "";
|
||||||
@@ -46,13 +47,25 @@ export function resolveSessionKeyFromResolveParams(params: {
|
|||||||
if (hasKey) {
|
if (hasKey) {
|
||||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||||
const store = loadSessionStore(target.storePath);
|
const store = loadSessionStore(target.storePath);
|
||||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
if (store[target.canonicalKey]) {
|
||||||
if (!existingKey) {
|
return { ok: true, key: target.canonicalKey };
|
||||||
|
}
|
||||||
|
const legacyKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||||
|
if (!legacyKey) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`),
|
error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
await updateSessionStore(target.storePath, (s) => {
|
||||||
|
const liveTarget = resolveGatewaySessionStoreTarget({ cfg, key, store: s });
|
||||||
|
const canonicalKey = liveTarget.canonicalKey;
|
||||||
|
// Migrate the first legacy entry to the canonical key.
|
||||||
|
if (!s[canonicalKey] && s[legacyKey]) {
|
||||||
|
s[canonicalKey] = s[legacyKey];
|
||||||
|
}
|
||||||
|
pruneLegacyStoreKeys({ store: s, canonicalKey, candidates: liveTarget.storeKeys });
|
||||||
|
});
|
||||||
return { ok: true, key: target.canonicalKey };
|
return { ok: true, key: target.canonicalKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user