Gateway: preserve discovered session store paths
This commit is contained in:
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { clearConfigCache, writeConfigFile } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
listAgentsForGateway,
|
||||
listSessionsFromStore,
|
||||
loadCombinedSessionStoreForGateway,
|
||||
loadSessionEntry,
|
||||
parseGroupKey,
|
||||
pruneLegacyStoreKeys,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
@@ -262,6 +264,66 @@ describe("gateway session utils", () => {
|
||||
expect(target.storeKeys).toEqual(expect.arrayContaining(["agent:ops:MAIN"]));
|
||||
});
|
||||
|
||||
test("resolveGatewaySessionStoreTarget preserves discovered store paths for non-round-tripping agent dirs", async () => {
|
||||
await withStateDirEnv("session-utils-discovered-store-", async ({ stateDir }) => {
|
||||
const retiredSessionsDir = path.join(stateDir, "agents", "Retired Agent", "sessions");
|
||||
fs.mkdirSync(retiredSessionsDir, { recursive: true });
|
||||
const retiredStorePath = path.join(retiredSessionsDir, "sessions.json");
|
||||
fs.writeFileSync(
|
||||
retiredStorePath,
|
||||
JSON.stringify({
|
||||
"agent:retired-agent:main": { sessionId: "sess-retired", updatedAt: 1 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:retired-agent:main" });
|
||||
|
||||
expect(target.storePath).toBe(fs.realpathSync(retiredStorePath));
|
||||
});
|
||||
});
|
||||
|
||||
test("loadSessionEntry reads discovered stores from non-round-tripping agent dirs", async () => {
|
||||
clearConfigCache();
|
||||
try {
|
||||
await withStateDirEnv("session-utils-load-entry-", async ({ stateDir }) => {
|
||||
const retiredSessionsDir = path.join(stateDir, "agents", "Retired Agent", "sessions");
|
||||
fs.mkdirSync(retiredSessionsDir, { recursive: true });
|
||||
const retiredStorePath = path.join(retiredSessionsDir, "sessions.json");
|
||||
fs.writeFileSync(
|
||||
retiredStorePath,
|
||||
JSON.stringify({
|
||||
"agent:retired-agent:main": { sessionId: "sess-retired", updatedAt: 7 },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeConfigFile({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
});
|
||||
clearConfigCache();
|
||||
|
||||
const loaded = loadSessionEntry("agent:retired-agent:main");
|
||||
|
||||
expect(loaded.storePath).toBe(fs.realpathSync(retiredStorePath));
|
||||
expect(loaded.entry?.sessionId).toBe("sess-retired");
|
||||
});
|
||||
} finally {
|
||||
clearConfigCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("pruneLegacyStoreKeys removes alias and case-variant ghost keys", () => {
|
||||
const store: Record<string, unknown> = {
|
||||
"agent:ops:work": { sessionId: "canonical", updatedAt: 3 },
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveMainSessionKey,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
type SessionStoreTarget,
|
||||
type SessionScope,
|
||||
} from "../config/sessions.js";
|
||||
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||
@@ -178,12 +179,14 @@ export function deriveSessionTitle(
|
||||
|
||||
export function loadSessionEntry(sessionKey: string) {
|
||||
const cfg = loadConfig();
|
||||
const sessionCfg = cfg.session;
|
||||
const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey });
|
||||
const agentId = resolveSessionStoreAgentId(cfg, canonicalKey);
|
||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const match = findStoreMatch(store, canonicalKey, sessionKey.trim());
|
||||
const { storePath, store, match } = resolveGatewaySessionStoreLookup({
|
||||
cfg,
|
||||
key: sessionKey.trim(),
|
||||
canonicalKey,
|
||||
agentId,
|
||||
});
|
||||
const legacyKey = match?.key !== canonicalKey ? match?.key : undefined;
|
||||
return { cfg, storePath, store, entry: match?.entry, canonicalKey, legacyKey };
|
||||
}
|
||||
@@ -478,6 +481,101 @@ export function canonicalizeSpawnedByForAgent(
|
||||
return canonicalizeMainSessionAlias({ cfg, agentId: resolvedAgent, sessionKey: result });
|
||||
}
|
||||
|
||||
function buildGatewaySessionStoreScanTargets(params: {
|
||||
cfg: OpenClawConfig;
|
||||
key: string;
|
||||
canonicalKey: string;
|
||||
agentId: string;
|
||||
}): string[] {
|
||||
const targets = new Set<string>();
|
||||
if (params.canonicalKey) {
|
||||
targets.add(params.canonicalKey);
|
||||
}
|
||||
if (params.key && params.key !== params.canonicalKey) {
|
||||
targets.add(params.key);
|
||||
}
|
||||
if (params.canonicalKey === "global" || params.canonicalKey === "unknown") {
|
||||
return [...targets];
|
||||
}
|
||||
const agentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: params.agentId });
|
||||
if (params.canonicalKey === agentMainKey) {
|
||||
targets.add(`agent:${params.agentId}:main`);
|
||||
}
|
||||
return [...targets];
|
||||
}
|
||||
|
||||
function resolveGatewaySessionStoreCandidates(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): SessionStoreTarget[] {
|
||||
const storeConfig = cfg.session?.store;
|
||||
const defaultTarget = {
|
||||
agentId,
|
||||
storePath: resolveStorePath(storeConfig, { agentId }),
|
||||
};
|
||||
if (!isStorePathTemplate(storeConfig)) {
|
||||
return [defaultTarget];
|
||||
}
|
||||
const targets = new Map<string, SessionStoreTarget>();
|
||||
targets.set(defaultTarget.storePath, defaultTarget);
|
||||
for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) {
|
||||
if (target.agentId === agentId) {
|
||||
targets.set(target.storePath, target);
|
||||
}
|
||||
}
|
||||
return [...targets.values()];
|
||||
}
|
||||
|
||||
function resolveGatewaySessionStoreLookup(params: {
|
||||
cfg: OpenClawConfig;
|
||||
key: string;
|
||||
canonicalKey: string;
|
||||
agentId: string;
|
||||
initialStore?: Record<string, SessionEntry>;
|
||||
}): {
|
||||
storePath: string;
|
||||
store: Record<string, SessionEntry>;
|
||||
match: { entry: SessionEntry; key: string } | undefined;
|
||||
} {
|
||||
const scanTargets = buildGatewaySessionStoreScanTargets(params);
|
||||
const candidates = resolveGatewaySessionStoreCandidates(params.cfg, params.agentId);
|
||||
const fallback = candidates[0] ?? {
|
||||
agentId: params.agentId,
|
||||
storePath: resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }),
|
||||
};
|
||||
let selectedStorePath = fallback.storePath;
|
||||
let selectedStore = params.initialStore ?? loadSessionStore(fallback.storePath);
|
||||
let selectedMatch = findStoreMatch(selectedStore, ...scanTargets);
|
||||
let selectedUpdatedAt = selectedMatch?.entry.updatedAt ?? Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (let index = 1; index < candidates.length; index += 1) {
|
||||
const candidate = candidates[index];
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
const store = loadSessionStore(candidate.storePath);
|
||||
const match = findStoreMatch(store, ...scanTargets);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = match.entry.updatedAt ?? 0;
|
||||
// Mirror combined-store merge behavior so follow-up mutations target the
|
||||
// same backing store that won the listing merge when ids collide.
|
||||
if (!selectedMatch || updatedAt >= selectedUpdatedAt) {
|
||||
selectedStorePath = candidate.storePath;
|
||||
selectedStore = store;
|
||||
selectedMatch = match;
|
||||
selectedUpdatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
storePath: selectedStorePath,
|
||||
store: selectedStore,
|
||||
match: selectedMatch,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGatewaySessionStoreTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
key: string;
|
||||
@@ -495,8 +593,13 @@ export function resolveGatewaySessionStoreTarget(params: {
|
||||
sessionKey: key,
|
||||
});
|
||||
const agentId = resolveSessionStoreAgentId(params.cfg, canonicalKey);
|
||||
const storeConfig = params.cfg.session?.store;
|
||||
const storePath = resolveStorePath(storeConfig, { agentId });
|
||||
const { storePath, store } = resolveGatewaySessionStoreLookup({
|
||||
cfg: params.cfg,
|
||||
key,
|
||||
canonicalKey,
|
||||
agentId,
|
||||
initialStore: params.store,
|
||||
});
|
||||
|
||||
if (canonicalKey === "global" || canonicalKey === "unknown") {
|
||||
const storeKeys = key && key !== canonicalKey ? [canonicalKey, key] : [key];
|
||||
@@ -509,16 +612,14 @@ export function resolveGatewaySessionStoreTarget(params: {
|
||||
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);
|
||||
const scanTargets = buildGatewaySessionStoreScanTargets({
|
||||
cfg: params.cfg,
|
||||
key,
|
||||
canonicalKey,
|
||||
agentId,
|
||||
});
|
||||
for (const seed of scanTargets) {
|
||||
for (const legacyKey of findStoreKeysIgnoreCase(store, seed)) {
|
||||
storeKeys.add(legacyKey);
|
||||
|
||||
Reference in New Issue
Block a user