mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-19 04:28:22 +00:00
refactor: remove duplicate session status reader
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user