refactor: remove duplicate session status reader

This commit is contained in:
Peter Steinberger
2026-05-06 18:52:23 +01:00
parent 8cc156665f
commit e1f62f1ea0
10 changed files with 116 additions and 74 deletions

View File

@@ -68,8 +68,11 @@ inside `.openclaw/trajectory-exports/` under the selected workspace.
`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.
discovered stores are keyed by the per-agent `sessions/` directory, so
SQLite-backed agents remain discoverable after doctor removes the legacy
`sessions.json` import file. If a legacy `sessions.json` still exists, it must
be a regular file inside the agent root; symlinks and out-of-root paths are
skipped.
JSON examples:

View File

@@ -53,7 +53,10 @@ This plan has started landing in slices:
persist only: no JSON import, pruning, capping, archive cleanup, or
disk-budget cleanup runs on the hot path. The old maintenance write options
have been removed from the session-store API; doctor owns legacy import and
`openclaw sessions cleanup` owns explicit cleanup.
`openclaw sessions cleanup` owns explicit cleanup. Status and discovery now
use the primary session-store loader instead of a duplicated read-only JSON
parser, and SQLite-backed agent session directories remain discoverable after
doctor deletes the legacy `sessions.json` file.
- Transcript events have a SQLite store primitive with JSONL import/export.
Transcript append paths dual-write when the caller already has agent and
session scope, including gateway-injected assistant messages. Scoped appends
@@ -551,6 +554,8 @@ Phase 1: SQLite session index
- Prove current session list, patch, reset, cleanup, and UI flows.
- Remove load-time/startup session JSON migration, write-time pruning, and
migration-era maintenance options from the runtime store path.
- Remove the duplicate status-only session JSON reader and stop requiring a
physical `sessions.json` file for discovered SQLite-backed agent stores.
Phase 2: VFS scratch

View File

@@ -1,7 +1,8 @@
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { readSessionStoreReadOnly } from "../config/sessions/store-read.js";
import { loadSessionStore } from "../config/sessions/store-load.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.js";
import { listGatewayAgentsBasic } from "../gateway/agent-list.js";
import { pathExists } from "../infra/fs-safe.js";
@@ -24,6 +25,14 @@ type AgentLocalStatusesResult = {
bootstrapPendingCount: number;
};
function loadStatusSessionStore(storePath: string): Record<string, SessionEntry | undefined> {
try {
return loadSessionStore(storePath);
} catch {
return {};
}
}
export async function getAgentLocalStatuses(
cfg: OpenClawConfig,
): Promise<AgentLocalStatusesResult> {
@@ -45,7 +54,7 @@ export async function getAgentLocalStatuses(
const bootstrapPending = bootstrapPath != null ? await pathExists(bootstrapPath) : null;
const sessionsPath = resolveStorePath(cfg.session?.store, { agentId });
const store = readSessionStoreReadOnly(sessionsPath);
const store = loadStatusSessionStore(sessionsPath);
const sessions = Object.entries(store)
.filter(([key]) => key !== "global" && key !== "unknown")
.map(([, entry]) => entry);

View File

@@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const statusSummaryMocks = vi.hoisted(() => ({
hasConfiguredChannelsForReadOnlyScope: vi.fn(() => true),
buildChannelSummary: vi.fn(async () => ["ok"]),
readSessionStoreReadOnly: vi.fn(() => ({})),
loadSessionStore: vi.fn(() => ({})),
}));
vi.mock("../plugins/channel-plugin-ids.js", () => ({
@@ -44,8 +44,8 @@ vi.mock("../config/sessions/paths.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
}));
vi.mock("../config/sessions/store-read.js", () => ({
readSessionStoreReadOnly: statusSummaryMocks.readSessionStoreReadOnly,
vi.mock("../config/sessions/store-load.js", () => ({
loadSessionStore: statusSummaryMocks.loadSessionStore,
}));
vi.mock("../gateway/agent-list.js", () => ({
@@ -142,7 +142,7 @@ describe("getStatusSummary", () => {
vi.clearAllMocks();
statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope.mockReturnValue(true);
statusSummaryMocks.buildChannelSummary.mockResolvedValue(["ok"]);
statusSummaryMocks.readSessionStoreReadOnly.mockReturnValue({});
statusSummaryMocks.loadSessionStore.mockReturnValue({});
});
it("includes runtimeVersion in the status payload", async () => {
@@ -189,7 +189,7 @@ describe("getStatusSummary", () => {
it("includes the selected agent runtime on recent sessions", async () => {
vi.mocked(statusSummaryRuntime.resolveSessionRuntimeLabel).mockReturnValue("OpenAI Codex");
statusSummaryMocks.readSessionStoreReadOnly.mockReturnValue({
statusSummaryMocks.loadSessionStore.mockReturnValue({
"agent:main:main": {
sessionId: "session-1",
updatedAt: Date.now(),

View File

@@ -2,7 +2,7 @@ import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agen
import { getRuntimeConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions/main-session.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { readSessionStoreReadOnly } from "../config/sessions/store-read.js";
import { loadSessionStore } from "../config/sessions/store-load.js";
import { resolveSessionTotalTokens, type SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.js";
import { resolveCronStorePath } from "../cron/store.js";
@@ -179,7 +179,12 @@ export async function getStatusSummary(
if (cached) {
return cached;
}
const store = readSessionStoreReadOnly(storePath);
let store: Record<string, SessionEntry | undefined>;
try {
store = loadSessionStore(storePath);
} catch {
store = {};
}
storeCache.set(storePath, store);
return store;
};

View File

@@ -532,8 +532,8 @@ vi.mock("../config/sessions/main-session.js", () => ({
vi.mock("../config/sessions/paths.js", () => ({
resolveStorePath: mocks.resolveStorePath,
}));
vi.mock("../config/sessions/store-read.js", () => ({
readSessionStoreReadOnly: mocks.loadSessionStore,
vi.mock("../config/sessions/store-load.js", () => ({
loadSessionStore: mocks.loadSessionStore,
}));
vi.mock("../config/sessions/types.js", () => ({
resolveSessionTotalTokens: vi.fn((entry?: { totalTokens?: number }) =>

View File

@@ -1,24 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempDir } from "../../test-helpers/temp-dir.js";
import { readSessionStoreReadOnly } from "./store-read.js";
describe("readSessionStoreReadOnly", () => {
it("returns an empty store for malformed or non-object JSON", async () => {
await withTempDir({ prefix: "openclaw-session-store-" }, async (dir) => {
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, '["not-an-object"]\n', "utf8");
expect(readSessionStoreReadOnly(storePath)).toStrictEqual({});
await fs.writeFile(storePath, '{"session-1":{"sessionId":"s1","updatedAt":1}}\n', "utf8");
expect(readSessionStoreReadOnly(storePath)).toMatchObject({
"session-1": {
sessionId: "s1",
updatedAt: 1,
},
});
});
});
});

View File

@@ -1,30 +0,0 @@
import fs from "node:fs";
import { z } from "zod";
import { safeParseJsonWithSchema } from "../../utils/zod-parse.js";
import {
loadSqliteSessionStore,
resolveSqliteSessionStoreOptionsForPath,
} from "./store-backend.sqlite.js";
import type { SessionEntry } from "./types.js";
const SessionStoreSchema = z.record(z.string(), z.unknown()) as z.ZodType<
Record<string, SessionEntry | undefined>
>;
export function readSessionStoreReadOnly(
storePath: string,
): Record<string, SessionEntry | undefined> {
const sqliteOptions = resolveSqliteSessionStoreOptionsForPath(storePath);
if (sqliteOptions) {
return loadSqliteSessionStore(sqliteOptions);
}
try {
const raw = fs.readFileSync(storePath, "utf-8");
if (!raw.trim()) {
return {};
}
return safeParseJsonWithSchema(SessionStoreSchema, raw) ?? {};
} catch {
return {};
}
}

View File

@@ -17,6 +17,10 @@ async function resolveRealStorePath(sessionsDir: string): Promise<string> {
return fsSync.realpathSync.native(path.join(sessionsDir, "sessions.json"));
}
function resolveRealSyntheticStorePath(sessionsDir: string): string {
return path.join(fsSync.realpathSync.native(sessionsDir), "sessions.json");
}
async function createAgentSessionStores(
root: string,
agentIds: string[],
@@ -31,6 +35,19 @@ async function createAgentSessionStores(
return storePaths;
}
async function createAgentSessionDirs(
root: string,
agentIds: string[],
): Promise<Record<string, string>> {
const storePaths: Record<string, string> = {};
for (const agentId of agentIds) {
const sessionsDir = path.join(root, "agents", agentId, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
storePaths[agentId] = resolveRealSyntheticStorePath(sessionsDir);
}
return storePaths;
}
function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenClawConfig {
return {
session: {
@@ -200,6 +217,24 @@ describe("resolveAllAgentSessionStoreTargets", () => {
});
});
it("discovers sqlite-backed agent session dirs after sessions.json import cleanup", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const storePaths = await createAgentSessionDirs(stateDir, ["ops", "retired"]);
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "ops", default: true }],
},
};
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
expectTargetsToContainStores(targets, storePaths);
expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1);
});
});
it("discovers retired agent stores under a configured custom session root", async () => {
await withTempHome(async (home) => {
const { storePaths, targets } = await resolveTargetsForCustomRoot(home, ["ops", "retired"]);

View File

@@ -65,18 +65,38 @@ function resolveValidatedDiscoveredStorePathSync(params: {
agentsRoot: string;
realAgentsRoot?: string;
}): string | undefined {
const storePath = path.join(params.sessionsDir, "sessions.json");
try {
const sessionsStat = fsSync.lstatSync(params.sessionsDir);
if (sessionsStat.isSymbolicLink() || !sessionsStat.isDirectory()) {
return undefined;
}
const realSessionsDir = fsSync.realpathSync.native(params.sessionsDir);
const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync.native(params.agentsRoot);
if (!isWithinRoot(realSessionsDir, realAgentsRoot)) {
return undefined;
}
const storePath = path.join(params.sessionsDir, "sessions.json");
const stat = fsSync.lstatSync(storePath);
if (stat.isSymbolicLink() || !stat.isFile()) {
return undefined;
}
const realStorePath = fsSync.realpathSync.native(storePath);
const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync.native(params.agentsRoot);
return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined;
} catch (err) {
if (shouldSkipDiscoveryError(err)) {
return undefined;
try {
const realSessionsDir = fsSync.realpathSync.native(params.sessionsDir);
const realAgentsRoot =
params.realAgentsRoot ?? fsSync.realpathSync.native(params.agentsRoot);
return isWithinRoot(realSessionsDir, realAgentsRoot)
? path.join(realSessionsDir, "sessions.json")
: undefined;
} catch (innerErr) {
if (shouldSkipDiscoveryError(innerErr)) {
return undefined;
}
throw innerErr;
}
}
throw err;
}
@@ -87,18 +107,37 @@ async function resolveValidatedDiscoveredStorePath(params: {
agentsRoot: string;
realAgentsRoot?: string;
}): Promise<string | undefined> {
const storePath = path.join(params.sessionsDir, "sessions.json");
try {
const sessionsStat = await fs.lstat(params.sessionsDir);
if (sessionsStat.isSymbolicLink() || !sessionsStat.isDirectory()) {
return undefined;
}
const realSessionsDir = await fs.realpath(params.sessionsDir);
const realAgentsRoot = params.realAgentsRoot ?? (await fs.realpath(params.agentsRoot));
if (!isWithinRoot(realSessionsDir, realAgentsRoot)) {
return undefined;
}
const storePath = path.join(params.sessionsDir, "sessions.json");
const stat = await fs.lstat(storePath);
if (stat.isSymbolicLink() || !stat.isFile()) {
return undefined;
}
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)) {
return undefined;
try {
const realSessionsDir = await fs.realpath(params.sessionsDir);
const realAgentsRoot = params.realAgentsRoot ?? (await fs.realpath(params.agentsRoot));
return isWithinRoot(realSessionsDir, realAgentsRoot)
? path.join(realSessionsDir, "sessions.json")
: undefined;
} catch (innerErr) {
if (shouldSkipDiscoveryError(innerErr)) {
return undefined;
}
throw innerErr;
}
}
throw err;
}