mirror of https://github.com/openclaw/openclaw.git
refactor: share session id resolution logic
This commit is contained in:
parent
7c0d8c9e9a
commit
52ebbf5188
|
|
@ -118,6 +118,11 @@ Session stores live under the state directory (default `~/.openclaw`):
|
|||
|
||||
You can override the store path via `session.store` and `{agentId}` templating.
|
||||
|
||||
Gateway and ACP session discovery also scans disk-backed agent stores under the
|
||||
default `agents/` root and under templated `session.store` roots. Discovered
|
||||
stores must stay inside that resolved agent root and use a regular
|
||||
`sessions.json` file. Symlinks and out-of-root paths are ignored.
|
||||
|
||||
## WebChat behavior
|
||||
|
||||
WebChat attaches to the **selected agent** and defaults to the agent’s main
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ Scope selection:
|
|||
- `--all-agents`: aggregate all configured agent stores
|
||||
- `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`)
|
||||
|
||||
`openclaw sessions --all-agents` reads configured agent stores. Gateway and ACP
|
||||
session discovery are broader: they also include disk-only stores found under
|
||||
the default `agents/` root or a templated `session.store` root. Those
|
||||
discovered stores must resolve to regular `sessions.json` files inside the
|
||||
agent root; symlinks and out-of-root paths are skipped.
|
||||
|
||||
JSON examples:
|
||||
|
||||
`openclaw sessions --all-agents --json`:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
const loadSessionStoreMock = vi.fn();
|
||||
const updateSessionStoreMock = vi.fn();
|
||||
const callGatewayMock = vi.fn();
|
||||
const loadCombinedSessionStoreForGatewayMock = vi.fn();
|
||||
|
||||
const createMockConfig = () => ({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
|
|
@ -42,6 +43,15 @@ vi.mock("../gateway/call.js", () => ({
|
|||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/session-utils.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../gateway/session-utils.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadCombinedSessionStoreForGateway: (cfg: unknown) =>
|
||||
loadCombinedSessionStoreForGatewayMock(cfg),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
|
|
@ -95,7 +105,12 @@ function resetSessionStore(store: Record<string, unknown>) {
|
|||
loadSessionStoreMock.mockClear();
|
||||
updateSessionStoreMock.mockClear();
|
||||
callGatewayMock.mockClear();
|
||||
loadCombinedSessionStoreForGatewayMock.mockClear();
|
||||
loadSessionStoreMock.mockReturnValue(store);
|
||||
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
|
||||
storePath: "(multiple)",
|
||||
store,
|
||||
});
|
||||
callGatewayMock.mockResolvedValue({});
|
||||
mockConfig = createMockConfig();
|
||||
}
|
||||
|
|
@ -161,6 +176,30 @@ describe("session_status tool", () => {
|
|||
expect(details.sessionKey).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
it("resolves duplicate sessionId inputs deterministically", async () => {
|
||||
resetSessionStore({
|
||||
"agent:main:main": {
|
||||
sessionId: "current",
|
||||
updatedAt: 10,
|
||||
},
|
||||
"agent:main:other": {
|
||||
sessionId: "run-dup",
|
||||
updatedAt: 999,
|
||||
},
|
||||
"agent:main:acp:run-dup": {
|
||||
sessionId: "run-dup",
|
||||
updatedAt: 100,
|
||||
},
|
||||
});
|
||||
|
||||
const tool = getSessionStatusTool();
|
||||
|
||||
const result = await tool.execute("call-dup", { sessionKey: "run-dup" });
|
||||
const details = result.details as { ok?: boolean; sessionKey?: string };
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.sessionKey).toBe("agent:main:acp:run-dup");
|
||||
});
|
||||
|
||||
it("uses non-standard session keys without sessionId resolution", async () => {
|
||||
resetSessionStore({
|
||||
"temp:slug-generator": {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
resolveAgentIdFromSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||
import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js";
|
||||
import { resolveAgentDir } from "../agent-scope.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
||||
import { resolveModelAuthLabel } from "../model-auth-label.js";
|
||||
|
|
@ -100,16 +101,12 @@ function resolveSessionKeyFromSessionId(params: {
|
|||
return null;
|
||||
}
|
||||
const { store } = loadCombinedSessionStoreForGateway(params.cfg);
|
||||
const match = Object.entries(store).find(([key, entry]) => {
|
||||
if (entry?.sessionId !== trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (!params.agentId) {
|
||||
return true;
|
||||
}
|
||||
return resolveAgentIdFromSessionKey(key) === params.agentId;
|
||||
});
|
||||
return match?.[0] ?? null;
|
||||
const matches = Object.entries(store).filter(
|
||||
(entry): entry is [string, SessionEntry] =>
|
||||
entry[1]?.sessionId === trimmed &&
|
||||
(!params.agentId || resolveAgentIdFromSessionKey(entry[0]) === params.agentId),
|
||||
);
|
||||
return resolvePreferredSessionKeyForSessionIdMatches(matches, trimmed) ?? null;
|
||||
}
|
||||
|
||||
async function resolveModelOverride(params: {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ function shouldSkipDiscoveredAgentDirName(dirName: string, agentId: string): boo
|
|||
function resolveValidatedDiscoveredStorePathSync(params: {
|
||||
sessionsDir: string;
|
||||
agentsRoot: string;
|
||||
realAgentsRoot?: string;
|
||||
}): string | undefined {
|
||||
const storePath = path.join(params.sessionsDir, "sessions.json");
|
||||
try {
|
||||
|
|
@ -68,7 +69,7 @@ function resolveValidatedDiscoveredStorePathSync(params: {
|
|||
return undefined;
|
||||
}
|
||||
const realStorePath = fsSync.realpathSync(storePath);
|
||||
const realAgentsRoot = fsSync.realpathSync(params.agentsRoot);
|
||||
const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync(params.agentsRoot);
|
||||
return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined;
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
|
|
@ -81,6 +82,7 @@ function resolveValidatedDiscoveredStorePathSync(params: {
|
|||
async function resolveValidatedDiscoveredStorePath(params: {
|
||||
sessionsDir: string;
|
||||
agentsRoot: string;
|
||||
realAgentsRoot?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const storePath = path.join(params.sessionsDir, "sessions.json");
|
||||
try {
|
||||
|
|
@ -88,10 +90,8 @@ async function resolveValidatedDiscoveredStorePath(params: {
|
|||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
return undefined;
|
||||
}
|
||||
const [realStorePath, realAgentsRoot] = await Promise.all([
|
||||
fs.realpath(storePath),
|
||||
fs.realpath(params.agentsRoot),
|
||||
]);
|
||||
const realStorePath = await fs.realpath(storePath);
|
||||
const realAgentsRoot = params.realAgentsRoot ?? (await fs.realpath(params.agentsRoot));
|
||||
return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined;
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
|
|
@ -146,23 +146,50 @@ export function resolveAllAgentSessionStoreTargetsSync(
|
|||
): SessionStoreTarget[] {
|
||||
const env = params.env ?? process.env;
|
||||
const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
|
||||
const realAgentsRoots = new Map<string, string>();
|
||||
const getRealAgentsRoot = (agentsRoot: string): string | undefined => {
|
||||
const cached = realAgentsRoots.get(agentsRoot);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const realAgentsRoot = fsSync.realpathSync(agentsRoot);
|
||||
realAgentsRoots.set(agentsRoot, realAgentsRoot);
|
||||
return realAgentsRoot;
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const validatedConfiguredTargets = configuredTargets.flatMap((target) => {
|
||||
const agentsRoot = resolveAgentsDirFromSessionStorePath(target.storePath);
|
||||
if (!agentsRoot) {
|
||||
return [target];
|
||||
}
|
||||
const realAgentsRoot = getRealAgentsRoot(agentsRoot);
|
||||
if (!realAgentsRoot) {
|
||||
return [];
|
||||
}
|
||||
const validatedStorePath = resolveValidatedDiscoveredStorePathSync({
|
||||
sessionsDir: path.dirname(target.storePath),
|
||||
agentsRoot,
|
||||
realAgentsRoot,
|
||||
});
|
||||
return validatedStorePath ? [{ ...target, storePath: validatedStorePath }] : [];
|
||||
});
|
||||
const discoveredTargets = agentsRoots.flatMap((agentsDir) => {
|
||||
try {
|
||||
const realAgentsRoot = getRealAgentsRoot(agentsDir);
|
||||
if (!realAgentsRoot) {
|
||||
return [];
|
||||
}
|
||||
return resolveAgentSessionDirsFromAgentsDirSync(agentsDir).flatMap((sessionsDir) => {
|
||||
const validatedStorePath = resolveValidatedDiscoveredStorePathSync({
|
||||
sessionsDir,
|
||||
agentsRoot: agentsDir,
|
||||
realAgentsRoot,
|
||||
});
|
||||
const target = validatedStorePath
|
||||
? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath)
|
||||
|
|
@ -185,6 +212,23 @@ export async function resolveAllAgentSessionStoreTargets(
|
|||
): Promise<SessionStoreTarget[]> {
|
||||
const env = params.env ?? process.env;
|
||||
const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
|
||||
const realAgentsRoots = new Map<string, string>();
|
||||
const getRealAgentsRoot = async (agentsRoot: string): Promise<string | undefined> => {
|
||||
const cached = realAgentsRoots.get(agentsRoot);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const realAgentsRoot = await fs.realpath(agentsRoot);
|
||||
realAgentsRoots.set(agentsRoot, realAgentsRoot);
|
||||
return realAgentsRoot;
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const validatedConfiguredTargets = (
|
||||
await Promise.all(
|
||||
configuredTargets.map(async (target) => {
|
||||
|
|
@ -192,9 +236,14 @@ export async function resolveAllAgentSessionStoreTargets(
|
|||
if (!agentsRoot) {
|
||||
return target;
|
||||
}
|
||||
const realAgentsRoot = await getRealAgentsRoot(agentsRoot);
|
||||
if (!realAgentsRoot) {
|
||||
return undefined;
|
||||
}
|
||||
const validatedStorePath = await resolveValidatedDiscoveredStorePath({
|
||||
sessionsDir: path.dirname(target.storePath),
|
||||
agentsRoot,
|
||||
realAgentsRoot,
|
||||
});
|
||||
return validatedStorePath ? { ...target, storePath: validatedStorePath } : undefined;
|
||||
}),
|
||||
|
|
@ -205,6 +254,10 @@ export async function resolveAllAgentSessionStoreTargets(
|
|||
await Promise.all(
|
||||
agentsRoots.map(async (agentsDir) => {
|
||||
try {
|
||||
const realAgentsRoot = await getRealAgentsRoot(agentsDir);
|
||||
if (!realAgentsRoot) {
|
||||
return [];
|
||||
}
|
||||
const sessionsDirs = await resolveAgentSessionDirsFromAgentsDir(agentsDir);
|
||||
return (
|
||||
await Promise.all(
|
||||
|
|
@ -212,6 +265,7 @@ export async function resolveAllAgentSessionStoreTargets(
|
|||
const validatedStorePath = await resolveValidatedDiscoveredStorePath({
|
||||
sessionsDir,
|
||||
agentsRoot: agentsDir,
|
||||
realAgentsRoot,
|
||||
});
|
||||
return validatedStorePath
|
||||
? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js";
|
|||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { toAgentRequestSessionKey } from "../routing/session-key.js";
|
||||
import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js";
|
||||
import { loadCombinedSessionStoreForGateway } from "./session-utils.js";
|
||||
|
||||
const RUN_LOOKUP_CACHE_LIMIT = 256;
|
||||
|
|
@ -33,41 +34,6 @@ function setResolvedSessionKeyCache(runId: string, sessionKey: string | null): v
|
|||
});
|
||||
}
|
||||
|
||||
function resolvePreferredRunStoreKey(
|
||||
matches: Array<[string, SessionEntry]>,
|
||||
runId: string,
|
||||
): string | undefined {
|
||||
if (matches.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
return matches[0][0];
|
||||
}
|
||||
|
||||
const loweredRunId = runId.trim().toLowerCase();
|
||||
const structuralMatches = matches.filter(([storeKey]) => {
|
||||
const requestKey = toAgentRequestSessionKey(storeKey)?.toLowerCase();
|
||||
return (
|
||||
storeKey.toLowerCase().endsWith(`:${loweredRunId}`) ||
|
||||
requestKey === loweredRunId ||
|
||||
requestKey?.endsWith(`:${loweredRunId}`) === true
|
||||
);
|
||||
});
|
||||
if (structuralMatches.length === 1) {
|
||||
return structuralMatches[0][0];
|
||||
}
|
||||
|
||||
const sortedMatches = [...matches].toSorted(
|
||||
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
|
||||
);
|
||||
const [freshest, secondFreshest] = sortedMatches;
|
||||
if ((freshest?.[1]?.updatedAt ?? 0) > (secondFreshest?.[1]?.updatedAt ?? 0)) {
|
||||
return freshest?.[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSessionKeyForRun(runId: string) {
|
||||
const cached = getAgentRunContext(runId)?.sessionKey;
|
||||
if (cached) {
|
||||
|
|
@ -88,7 +54,7 @@ export function resolveSessionKeyForRun(runId: string) {
|
|||
const matches = Object.entries(store).filter(
|
||||
(entry): entry is [string, SessionEntry] => entry[1]?.sessionId === runId,
|
||||
);
|
||||
const storeKey = resolvePreferredRunStoreKey(matches, runId);
|
||||
const storeKey = resolvePreferredSessionKeyForSessionIdMatches(matches, runId);
|
||||
if (storeKey) {
|
||||
const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey;
|
||||
registerAgentRunContext(runId, { sessionKey });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { toAgentRequestSessionKey } from "../routing/session-key.js";
|
||||
|
||||
export function resolvePreferredSessionKeyForSessionIdMatches(
|
||||
matches: Array<[string, SessionEntry]>,
|
||||
sessionId: string,
|
||||
): string | undefined {
|
||||
if (matches.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
return matches[0][0];
|
||||
}
|
||||
|
||||
const loweredSessionId = sessionId.trim().toLowerCase();
|
||||
const structuralMatches = matches.filter(([storeKey]) => {
|
||||
const requestKey = toAgentRequestSessionKey(storeKey)?.toLowerCase();
|
||||
return (
|
||||
storeKey.toLowerCase().endsWith(`:${loweredSessionId}`) ||
|
||||
requestKey === loweredSessionId ||
|
||||
requestKey?.endsWith(`:${loweredSessionId}`) === true
|
||||
);
|
||||
});
|
||||
if (structuralMatches.length === 1) {
|
||||
return structuralMatches[0][0];
|
||||
}
|
||||
|
||||
const sortedMatches = [...matches].toSorted(
|
||||
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
|
||||
);
|
||||
const [freshest, secondFreshest] = sortedMatches;
|
||||
if ((freshest?.[1]?.updatedAt ?? 0) > (secondFreshest?.[1]?.updatedAt ?? 0)) {
|
||||
return freshest?.[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
Loading…
Reference in New Issue