From 60c1577860feb9e0c161ba4d936e27ad71680dd4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 12 Mar 2026 17:08:49 +0000 Subject: [PATCH] Gateway: preserve discovered session store paths --- src/gateway/session-utils.test.ts | 62 ++++++++++++++ src/gateway/session-utils.ts | 129 ++++++++++++++++++++++++++---- 2 files changed, 177 insertions(+), 14 deletions(-) diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 796c20167bc..af90c96d1b9 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -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 = { "agent:ops:work": { sessionId: "canonical", updatedAt: 3 }, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 4d71c32246a..8867d17a460 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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(); + 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(); + 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; +}): { + storePath: string; + store: Record; + 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);