diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index ac81a20b4a8..154d3cb2b59 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -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: diff --git a/docs/refactor/piless.md b/docs/refactor/piless.md index c45dea2958a..86de054b1bd 100644 --- a/docs/refactor/piless.md +++ b/docs/refactor/piless.md @@ -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 diff --git a/src/commands/status.agent-local.ts b/src/commands/status.agent-local.ts index 9ef1bfa5592..0e73a5e526c 100644 --- a/src/commands/status.agent-local.ts +++ b/src/commands/status.agent-local.ts @@ -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 { + try { + return loadSessionStore(storePath); + } catch { + return {}; + } +} + export async function getAgentLocalStatuses( cfg: OpenClawConfig, ): Promise { @@ -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); diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 730f4f4bf03..90602ccd8cb 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -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(), diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index b8bf6a6d6bb..1247bacd0e2 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -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; + try { + store = loadSessionStore(storePath); + } catch { + store = {}; + } storeCache.set(storePath, store); return store; }; diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index b0ab8f95e4a..d3df4d7769c 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -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 }) => diff --git a/src/config/sessions/store-read.test.ts b/src/config/sessions/store-read.test.ts deleted file mode 100644 index b1886e30620..00000000000 --- a/src/config/sessions/store-read.test.ts +++ /dev/null @@ -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, - }, - }); - }); - }); -}); diff --git a/src/config/sessions/store-read.ts b/src/config/sessions/store-read.ts deleted file mode 100644 index ea396bdf06f..00000000000 --- a/src/config/sessions/store-read.ts +++ /dev/null @@ -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 ->; - -export function readSessionStoreReadOnly( - storePath: string, -): Record { - 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 {}; - } -} diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index 7b389f1b3e1..6b7318ac1e6 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -17,6 +17,10 @@ async function resolveRealStorePath(sessionsDir: string): Promise { 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> { + const storePaths: Record = {}; + 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"]); diff --git a/src/config/sessions/targets.ts b/src/config/sessions/targets.ts index 1ca75d0f048..ba494d0c386 100644 --- a/src/config/sessions/targets.ts +++ b/src/config/sessions/targets.ts @@ -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 { - 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; }