diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md index 88ed1894810..2b2708bea8a 100644 --- a/docs/concepts/model-failover.md +++ b/docs/concepts/model-failover.md @@ -70,8 +70,9 @@ OpenClaw separates the selected provider/model from why it was selected. That so OpenClaw uses **auth profiles** for both API keys and OAuth tokens. - Secrets live in `~/.openclaw/agents//agent/auth-profiles.json` (legacy: `~/.openclaw/agent/auth-profiles.json`). -- Runtime auth-routing state is SQLite-primary and compatibility-exported to - `~/.openclaw/agents//agent/auth-state.json`. +- Runtime auth-routing state is SQLite-primary. Legacy per-agent + `auth-state.json` files are imported into SQLite on first read and removed + after import. - Config `auth.profiles` / `auth.order` are **metadata + routing only** (no secrets). - Legacy import-only OAuth file: `~/.openclaw/credentials/oauth.json` (imported into `auth-profiles.json` on first use). @@ -169,8 +170,7 @@ Cooldowns use exponential backoff: - 25 minutes - 1 hour (cap) -State is stored in SQLite and compatibility-exported to `auth-state.json` under -`usageStats`: +State is stored in SQLite under `usageStats`: ```json { @@ -194,7 +194,7 @@ Not every billing-shaped response is `402`, and not every HTTP `402` lands here. Meanwhile temporary `402` usage-window and organization/workspace spend-limit errors are classified as `rate_limit` when the message looks retryable (for example `weekly usage limit exhausted`, `daily limit reached, resets tomorrow`, or `organization spending limit exceeded`). Those stay on the short cooldown/failover path instead of the long billing-disable path. -State is stored in SQLite and compatibility-exported to `auth-state.json`: +State is stored in SQLite: ```json { diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 4d3f8aaa1f9..f7643c8974a 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -189,7 +189,7 @@ Use `/model` (or `/model list`) for a compact picker; use `/model status` for th ### Per-agent (CLI override) -Set an explicit auth profile order override for an agent (SQLite-primary, compatibility-exported to that agent's `auth-state.json`): +Set an explicit auth profile order override for an agent (stored in SQLite): ```bash openclaw models auth order get --provider anthropic diff --git a/docs/help/faq-models.md b/docs/help/faq-models.md index bdba15de6ec..f4f9024c672 100644 --- a/docs/help/faq-models.md +++ b/docs/help/faq-models.md @@ -488,7 +488,7 @@ Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-a for one model can still be usable for a sibling model on the same provider, while billing/disabled windows still block the whole profile. - You can also set a **per-agent** order override (SQLite-primary, compatibility-exported to that agent's `auth-state.json`) via the CLI: + You can also set a **per-agent** order override via the CLI. The runtime order state is stored in SQLite: ```bash # Defaults to the configured default agent (omit --agent) diff --git a/docs/refactor/piless.md b/docs/refactor/piless.md index 1a8e098c923..286fd7ba505 100644 --- a/docs/refactor/piless.md +++ b/docs/refactor/piless.md @@ -97,25 +97,23 @@ This plan has started landing in slices: still imported and compatibility-exported so downgrade/debug workflows keep working while HTTP serving and cleanup can survive a missing JSON sidecar. - The subagent run registry now uses the shared SQLite `kv` store as the - primary record path. `subagents/runs.json` remains a compatibility export and - legacy import source, while restore paths can recover runs from SQLite when - the JSON sidecar is missing. + primary record path. Legacy `subagents/runs.json` files import into SQLite + when SQLite is empty and are removed after import. - Sandbox container and browser registries now use the shared SQLite `kv` store as the primary record path. Existing sharded JSON entries import into SQLite on first read, and the shard files remain compatibility exports for downgrade and debugging workflows. - OpenRouter model capability cache now uses the shared SQLite `kv` store as the primary persistent cache. The older - `cache/openrouter-models.json` file remains a compatibility import/export - layer for downgrade and manual debugging workflows. + `cache/openrouter-models.json` file is a legacy import source and is removed + after import. - TUI last-session restore pointers now use the shared SQLite `kv` store as the - primary record path. The older `tui/last-session.json` file remains a - compatibility import/export layer, and doctor cleanup clears stale pointers - through the same SQLite-primary store. + primary record path. The older `tui/last-session.json` file is a legacy + import source and is removed after import. - Auth profile runtime routing state now uses the shared SQLite `kv` store as - the primary record path. The older per-agent `auth-state.json` file remains a - compatibility import/export layer for downgrade, CLI display, and manual - debugging; `auth-profiles.json` still owns credentials and stays file-backed. + the primary record path. The older per-agent `auth-state.json` file is a + legacy import source and is removed after import; `auth-profiles.json` still + owns credentials and stays file-backed. - `AgentRuntimeBackend`, `PreparedAgentRun`, and the Node worker runner exist for serializable prepared runs. `RunEventBus` owns serial parent event delivery for worker event streams. The worker runner enforces prepared-run @@ -231,7 +229,7 @@ Use three explicit layers: ```text agent runtime boundary OpenClaw-owned interface, PI as one backend -agent state database SQLite primary store, JSON import/export compatibility +agent state database SQLite primary store, legacy JSON import where needed agent filesystem boundary VFS scratch plus host capability filesystem ``` @@ -518,20 +516,18 @@ Add tests before each migration step: - Plugin state and task registry coexistence with the shared state DB. - Managed outgoing media record import from legacy JSON plus SQLite-primary serving when the compatibility JSON record is missing. -- Subagent run registry restore from SQLite when `subagents/runs.json` is - missing, plus session-store test helpers that use the current SQLite-primary - backend instead of raw compatibility JSON. +- Subagent run registry import from legacy `subagents/runs.json`, legacy file + removal after import, and restore from SQLite without JSON exports. - Sandbox container and browser registry reads from SQLite when compatibility shard files are missing, while legacy monolithic registry migration stays an explicit repair operation. -- OpenRouter model capability cache reads from SQLite when the compatibility - JSON cache file is missing, without triggering a network fetch. -- TUI last-session restore pointers read from SQLite when the compatibility JSON - file is missing, import legacy JSON on read, and clear stale pointers from - both stores. +- OpenRouter model capability cache reads from SQLite when the legacy JSON + cache file is missing, imports old cache JSON, and removes it after import. +- TUI last-session restore pointers read from SQLite without JSON exports, + import legacy JSON on read, remove it, and clear stale pointers from SQLite. - Auth profile runtime state reads from SQLite when the compatibility - `auth-state.json` file is missing, imports legacy JSON on read, and deletes - both stores when runtime state is empty. + `auth-state.json` file is missing, imports legacy JSON on read, removes it, + and deletes SQLite state when runtime state is empty. ## Rollout Plan diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index e3d338c57c3..a929ca35b9d 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -153,7 +153,7 @@ describe("saveAuthProfileStore", () => { } }); - it("writes runtime scheduling state to SQLite and auth-state.json compatibility", async () => { + it("writes runtime scheduling state to SQLite only", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-state-")); try { const store: AuthProfileStore = { @@ -197,14 +197,9 @@ describe("saveAuthProfileStore", () => { expect(authProfiles.lastGood).toBeUndefined(); expect(authProfiles.usageStats).toBeUndefined(); - const authState = JSON.parse(await fs.readFile(resolveAuthStatePath(agentDir), "utf8")) as { - order?: Record; - lastGood?: Record; - usageStats?: Record; - }; - expect(authState.order?.anthropic).toEqual(["anthropic:default"]); - expect(authState.lastGood?.anthropic).toBe("anthropic:default"); - expect(authState.usageStats?.["anthropic:default"]?.lastUsed).toBe(123); + await expect(fs.access(resolveAuthStatePath(agentDir))).rejects.toMatchObject({ + code: "ENOENT", + }); const sqliteState = readOpenClawStateKvJson( "auth-profile-state", resolveAuthStatePath(agentDir), diff --git a/src/agents/auth-profiles/state.test.ts b/src/agents/auth-profiles/state.test.ts index dc5ce830187..8ba2296b19b 100644 --- a/src/agents/auth-profiles/state.test.ts +++ b/src/agents/auth-profiles/state.test.ts @@ -26,7 +26,7 @@ describe("auth profile runtime state persistence", () => { await fs.rm(agentDir, { recursive: true, force: true }); }); - it("reads runtime state from SQLite when auth-state.json is missing", async () => { + it("reads runtime state from SQLite without auth-state.json", async () => { savePersistedAuthProfileState( { order: { openai: ["openai:default"] }, @@ -35,7 +35,9 @@ describe("auth profile runtime state persistence", () => { }, agentDir, ); - await fs.unlink(resolveAuthStatePath(agentDir)); + await expect(fs.access(resolveAuthStatePath(agentDir))).rejects.toMatchObject({ + code: "ENOENT", + }); expect(loadPersistedAuthProfileState(agentDir)).toEqual({ order: { openai: ["openai:default"] }, @@ -44,7 +46,7 @@ describe("auth profile runtime state persistence", () => { }); }); - it("imports legacy auth-state.json into SQLite on read", async () => { + it("imports legacy auth-state.json into SQLite on read and removes the file", async () => { const statePath = resolveAuthStatePath(agentDir); await fs.writeFile( statePath, @@ -65,9 +67,10 @@ describe("auth profile runtime state persistence", () => { order: { anthropic: ["anthropic:default"] }, lastGood: { anthropic: "anthropic:default" }, }); + await expect(fs.access(statePath)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("deletes SQLite and compatibility state when runtime state is empty", async () => { + it("deletes SQLite state when runtime state is empty", async () => { savePersistedAuthProfileState( { usageStats: { "openai:default": { lastUsed: 123 } }, diff --git a/src/agents/auth-profiles/state.ts b/src/agents/auth-profiles/state.ts index 1e7b1cfc7e0..5baeaa51048 100644 --- a/src/agents/auth-profiles/state.ts +++ b/src/agents/auth-profiles/state.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; +import { loadJsonFile } from "../../infra/json-file.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { deleteOpenClawStateKvJson, @@ -97,6 +97,13 @@ export function loadPersistedAuthProfileState(agentDir?: string): AuthProfileSta key, authProfileStateToJsonValue(payload), ); + try { + fs.unlinkSync(key); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + } } return legacyState; } @@ -136,6 +143,12 @@ export function savePersistedAuthProfileState( authProfileStateKey(agentDir), authProfileStateToJsonValue(payload), ); - saveJsonFile(statePath, payload); + try { + fs.unlinkSync(statePath); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + } return payload; } diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 2c1fe0284cd..428e00f9949 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -1,9 +1,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import type { AuthProfileFailureReason } from "./auth-profiles.js"; +import { + loadPersistedAuthProfileState, + savePersistedAuthProfileState, +} from "./auth-profiles/state.js"; import { runWithModelFallback } from "./model-fallback.js"; import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js"; import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; @@ -66,6 +71,10 @@ beforeEach(() => { sleepWithAbortMock.mockClear(); }); +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + const OVERLOADED_ERROR_PAYLOAD = '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}'; const RATE_LIMIT_ERROR_MESSAGE = "rate limit exceeded"; @@ -165,23 +174,24 @@ async function writeAuthStore( }, }), ); - await fs.writeFile( - path.join(agentDir, "auth-state.json"), - JSON.stringify({ - version: 1, + savePersistedAuthProfileState( + { usageStats: usageStats ?? ({ "openai:p1": { lastUsed: 1 }, "groq:p1": { lastUsed: 2 }, } as const), - }), + }, + agentDir, ); } async function readUsageStats(agentDir: string) { - const raw = await fs.readFile(path.join(agentDir, "auth-state.json"), "utf-8"); - return JSON.parse(raw).usageStats as Record | undefined>; + return (loadPersistedAuthProfileState(agentDir).usageStats ?? {}) as Record< + string, + Record | undefined + >; } function expectFailureCount( @@ -207,17 +217,16 @@ async function writeMultiProfileAuthStore(agentDir: string) { }, }), ); - await fs.writeFile( - path.join(agentDir, "auth-state.json"), - JSON.stringify({ - version: 1, + savePersistedAuthProfileState( + { usageStats: { "openai:p1": { lastUsed: 1 }, "openai:p2": { lastUsed: 2 }, "openai:p3": { lastUsed: 3 }, "groq:p1": { lastUsed: 4 }, }, - }), + }, + agentDir, ); } diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 6c8094df581..fcd76e2eefe 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -4,7 +4,12 @@ import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { redactIdentifier } from "../logging/redact-identifier.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import type { AuthProfileFailureReason } from "./auth-profiles.js"; +import { + loadPersistedAuthProfileState, + savePersistedAuthProfileState, +} from "./auth-profiles/state.js"; import type { AssistantMessage } from "./pi-ai-contract.js"; import { buildAttemptReplayMetadata } from "./pi-embedded-runner/run/incomplete-turn.js"; import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; @@ -135,6 +140,7 @@ afterEach(() => { cleanupLogCapture = undefined; setLoggerOverrideFn(null); resetLoggerFn(); + closeOpenClawStateDatabaseForTest(); }); const baseUsage = { @@ -331,7 +337,6 @@ const writeAuthStore = async ( }, ) => { const authPath = path.join(agentDir, "auth-profiles.json"); - const statePath = path.join(agentDir, "auth-state.json"); const authPayload = { version: 1, profiles: { @@ -353,7 +358,7 @@ const writeAuthStore = async ( } as Record), }; await fs.writeFile(authPath, JSON.stringify(authPayload)); - await fs.writeFile(statePath, JSON.stringify(statePayload)); + savePersistedAuthProfileState(statePayload, agentDir); }; const writeCopilotAuthStore = async (agentDir: string, token = "gh-token") => { @@ -451,17 +456,7 @@ async function runAutoPinnedOpenAiTurn(params: { } async function readUsageStats(agentDir: string) { - const stored = JSON.parse(await fs.readFile(path.join(agentDir, "auth-state.json"), "utf-8")) as { - usageStats?: Record< - string, - { - lastUsed?: number; - cooldownUntil?: number; - disabledUntil?: number; - disabledReason?: AuthProfileFailureReason; - } - >; - }; + const stored = loadPersistedAuthProfileState(agentDir); return stored.usageStats ?? {}; } @@ -1534,9 +1529,8 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { try { await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { const authPath = path.join(agentDir, "auth-profiles.json"); - const authStatePath = path.join(agentDir, "auth-state.json"); await fs.writeFile(authPath, JSON.stringify({ version: 1, profiles: {} })); - await fs.writeFile(authStatePath, JSON.stringify({ version: 1, usageStats: {} })); + savePersistedAuthProfileState({ usageStats: {} }, agentDir); await expectFailoverError( runEmbeddedPiAgentInline({ diff --git a/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts b/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts index 3675df310ef..4c8d8b232ec 100644 --- a/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts +++ b/src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; @@ -83,6 +83,48 @@ describe("openrouter-model-capabilities", () => { }); }); + it("imports legacy JSON cache into SQLite and removes the file", async () => { + await withOpenRouterStateDir(async (stateDir) => { + const cachePath = join(stateDir, "cache", "openrouter-models.json"); + mkdirSync(join(stateDir, "cache"), { recursive: true }); + writeFileSync( + cachePath, + JSON.stringify({ + models: { + "acme/legacy-json": { + name: "Legacy JSON", + input: ["text"], + reasoning: false, + contextWindow: 111_000, + maxTokens: 22_000, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + }, + }, + }), + ); + const fetchSpy = vi.fn(async () => { + throw new Error("unexpected OpenRouter fetch"); + }); + vi.stubGlobal("fetch", fetchSpy); + + const module = await importOpenRouterModelCapabilities("legacy-json-cache"); + await module.loadOpenRouterModelCapabilities("acme/legacy-json"); + + expect(module.getOpenRouterModelCapabilities("acme/legacy-json")).toMatchObject({ + name: "Legacy JSON", + contextWindow: 111_000, + maxTokens: 22_000, + }); + expect(existsSync(cachePath)).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + it("uses top-level OpenRouter max token fields when top_provider is absent", async () => { await withOpenRouterStateDir(async () => { vi.stubGlobal( diff --git a/src/agents/pi-embedded-runner/openrouter-model-capabilities.ts b/src/agents/pi-embedded-runner/openrouter-model-capabilities.ts index 0d3517dc882..ec5e36d2c14 100644 --- a/src/agents/pi-embedded-runner/openrouter-model-capabilities.ts +++ b/src/agents/pi-embedded-runner/openrouter-model-capabilities.ts @@ -7,7 +7,7 @@ * Cache layers (checked in order): * 1. In-memory Map (instant, cleared on process restart) * 2. SQLite KV cache (/state/openclaw.sqlite) - * 3. Compatibility JSON file (/cache/openrouter-models.json) + * 3. Legacy JSON import (/cache/openrouter-models.json) * 4. OpenRouter API fetch (populates all layers) * * Model capabilities are assumed stable — the cache has no TTL expiry. @@ -19,12 +19,11 @@ * capabilities instead of the text-only fallback. */ -import { existsSync, readFileSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; import { resolveStateDir } from "../../config/paths.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { resolveProxyFetchFromEnv } from "../../infra/net/proxy-fetch.js"; -import { privateFileStoreSync } from "../../infra/private-file-store.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { readOpenClawStateKvJson, @@ -110,22 +109,8 @@ function writeSqliteCache(map: Map): void { } } -function writeDiskCache(map: Map): void { - try { - const cachePath = resolveDiskCachePath(); - privateFileStoreSync(dirname(cachePath)).writeJson( - basename(cachePath), - mapToDiskCachePayload(map), - ); - } catch (err: unknown) { - const message = formatErrorMessage(err); - log.debug(`Failed to write OpenRouter disk cache: ${message}`); - } -} - function writePersistentCache(map: Map): void { writeSqliteCache(map); - writeDiskCache(map); } function isValidCapabilities(value: unknown): value is OpenRouterModelCapabilities { @@ -187,6 +172,11 @@ function readPersistentCache(): Map | undef const diskCache = readDiskCache(); if (diskCache) { writeSqliteCache(diskCache); + try { + unlinkSync(resolveDiskCachePath()); + } catch { + // Best-effort legacy cache cleanup. + } } return diskCache; } diff --git a/src/agents/subagent-registry.persistence.resume.test.ts b/src/agents/subagent-registry.persistence.resume.test.ts index 2b1d32fed0f..78a75273dd0 100644 --- a/src/agents/subagent-registry.persistence.resume.test.ts +++ b/src/agents/subagent-registry.persistence.resume.test.ts @@ -7,16 +7,16 @@ import { clearSessionStoreCacheForTest, drainSessionStoreWriterQueuesForTest, } from "../config/sessions/store.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { captureEnv } from "../test-utils/env.js"; import { createSubagentRegistryTestDeps, writeSubagentSessionEntry, } from "./subagent-registry.persistence.test-support.js"; +import { saveSubagentRegistryToDisk } from "./subagent-registry.store.js"; const hoisted = vi.hoisted(() => ({ announceSpy: vi.fn(async () => true), - allowedRunIds: undefined as Set | undefined, - registryPath: undefined as string | undefined, })); const { announceSpy } = hoisted; vi.mock("./subagent-announce.js", () => ({ @@ -27,46 +27,6 @@ vi.mock("./subagent-orphan-recovery.js", () => ({ scheduleOrphanRecovery: vi.fn(), })); -vi.mock("./subagent-registry.store.js", async () => { - const actual = await vi.importActual( - "./subagent-registry.store.js", - ); - const fsSync = await import("node:fs"); - const pathSync = await import("node:path"); - const resolvePath = () => hoisted.registryPath ?? actual.resolveSubagentRegistryPath(); - return { - ...actual, - resolveSubagentRegistryPath: resolvePath, - loadSubagentRegistryFromDisk: () => { - try { - const parsed = JSON.parse(fsSync.readFileSync(resolvePath(), "utf8")) as { - runs?: Record; - }; - return new Map(Object.entries(parsed.runs ?? {})); - } catch { - return new Map(); - } - }, - saveSubagentRegistryToDisk: ( - runs: Map, - ) => { - const pathname = resolvePath(); - const persistedRuns = hoisted.allowedRunIds - ? new Map([...runs].filter(([runId]) => hoisted.allowedRunIds?.has(runId))) - : runs; - if (hoisted.allowedRunIds && persistedRuns.size === 0 && runs.size > 0) { - return; - } - fsSync.mkdirSync(pathSync.dirname(pathname), { recursive: true }); - fsSync.writeFileSync( - pathname, - `${JSON.stringify({ version: 2, runs: Object.fromEntries(persistedRuns) }, null, 2)}\n`, - "utf8", - ); - }, - }; -}); - let mod: typeof import("./subagent-registry.js"); let callGatewayModule: typeof import("../gateway/call.js"); let agentEventsModule: typeof import("../infra/agent-events.js"); @@ -127,65 +87,42 @@ describe("subagent registry persistence resume", () => { mod.resetSubagentRegistryForTests({ persist: false }); await drainSessionStoreWriterQueuesForTest(); clearSessionStoreCacheForTest(); + closeOpenClawStateDatabaseForTest(); if (tempStateDir) { await fs.rm(tempStateDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); tempStateDir = null; } - hoisted.registryPath = undefined; - hoisted.allowedRunIds = undefined; envSnapshot.restore(); }); - it("persists runs to disk and resumes after restart", async () => { + it("persists runs to SQLite and resumes after restart", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; const registryPath = path.join(tempStateDir, "subagents", "runs.json"); - hoisted.registryPath = registryPath; - await fs.mkdir(path.dirname(registryPath), { recursive: true }); - await fs.writeFile( - registryPath, - `${JSON.stringify( - { - version: 2, - runs: { - "run-1": { - runId: "run-1", - childSessionKey: "agent:main:subagent:test", - requesterSessionKey: "agent:main:main", - requesterOrigin: { channel: "whatsapp", accountId: "acct-main" }, - requesterDisplayKey: "main", - task: "do the thing", - cleanup: "keep", - createdAt: Date.now(), - }, + + saveSubagentRegistryToDisk( + new Map([ + [ + "run-1", + { + runId: "run-1", + childSessionKey: "agent:main:subagent:test", + requesterSessionKey: "agent:main:main", + requesterOrigin: { channel: "whatsapp", accountId: "acct-main" }, + requesterDisplayKey: "main", + task: "do the thing", + cleanup: "keep", + createdAt: Date.now(), }, - }, - null, - 2, - )}\n`, - "utf8", + ], + ]), ); + await expect(fs.access(registryPath)).rejects.toMatchObject({ code: "ENOENT" }); await writeChildSessionEntry({ sessionKey: "agent:main:subagent:test", sessionId: "sess-test", }); - const raw = await fs.readFile(registryPath, "utf8"); - const parsed = JSON.parse(raw) as { runs?: Record }; - expect(parsed.runs && Object.keys(parsed.runs)).toContain("run-1"); - const run = parsed.runs?.["run-1"] as - | { - requesterOrigin?: { channel?: string; accountId?: string }; - } - | undefined; - if (run === undefined) { - throw new Error("expected persisted run"); - } - expect("requesterAccountId" in run).toBe(false); - expect("requesterChannel" in run).toBe(false); - expect(run.requesterOrigin?.channel).toBe("whatsapp"); - expect(run?.requesterOrigin?.accountId).toBe("acct-main"); - mod.initSubagentRegistry(); await vi.waitFor(() => expect(announceSpy).toHaveBeenCalled(), { diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 9d96f6eb6be..50b281393b6 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -1,4 +1,3 @@ -import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -126,13 +125,10 @@ describe("subagent registry persistence", () => { }; const readPersistedRun = async ( - registryPath: string, + _registryPath: string, runId: string, ): Promise => { - const parsed = JSON.parse(await fs.readFile(registryPath, "utf8")) as { - runs?: Record; - }; - return parsed.runs?.[runId] as T | undefined; + return loadSubagentRegistryFromDisk().get(runId) as T | undefined; }; const createPersistedEndedRun = (params: { @@ -178,15 +174,7 @@ describe("subagent registry persistence", () => { }; const fastPersistSubagentRunsToDisk = (runs: Map) => { - const registryPath = tempStateDir - ? path.join(tempStateDir, "subagents", "runs.json") - : resolveSubagentRegistryPath(); - fsSync.mkdirSync(path.dirname(registryPath), { recursive: true }); - fsSync.writeFileSync( - registryPath, - `${JSON.stringify({ version: 2, runs: Object.fromEntries(runs) })}\n`, - "utf8", - ); + saveSubagentRegistryToDisk(runs); }; beforeEach(() => { @@ -328,11 +316,14 @@ describe("subagent registry persistence", () => { expect(entry?.requesterOrigin?.channel).toBe("whatsapp"); expect(entry?.requesterOrigin?.accountId).toBe("legacy-account"); - const after = JSON.parse(await fs.readFile(registryPath, "utf8")) as { version?: number }; - expect(after.version).toBe(2); + await expect(fs.access(registryPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(loadSubagentRegistryFromDisk().get("run-legacy")).toMatchObject({ + cleanupHandled: true, + cleanupCompletedAt: 9, + }); }); - it("restores persisted runs from SQLite when the compatibility JSON registry is missing", async () => { + it("restores persisted runs from SQLite without legacy JSON", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; const registryPath = path.join(tempStateDir, "subagents", "runs.json"); @@ -350,7 +341,7 @@ describe("subagent registry persistence", () => { }; saveSubagentRegistryToDisk(new Map([[record.runId, record]])); - await fs.rm(registryPath, { force: true }); + await expect(fs.access(registryPath)).rejects.toMatchObject({ code: "ENOENT" }); expect(loadSubagentRegistryFromDisk().get("run-sqlite")).toMatchObject({ runId: "run-sqlite", @@ -404,12 +395,12 @@ describe("subagent registry persistence", () => { expect(second.get("run-cached")?.endedAt).toBeUndefined(); expect(second.get("run-cached")?.cleanupHandled).toBeUndefined(); - await fs.writeFile( - registryPath, - `${JSON.stringify({ - version: 2, - runs: { - "run-updated": { + await expect(fs.access(registryPath)).rejects.toMatchObject({ code: "ENOENT" }); + saveSubagentRegistryToDisk( + new Map([ + [ + "run-updated", + { runId: "run-updated", childSessionKey: "agent:main:subagent:updated", requesterSessionKey: "agent:main:main", @@ -419,9 +410,8 @@ describe("subagent registry persistence", () => { createdAt: 2, startedAt: 2, }, - }, - })}\n`, - "utf8", + ], + ]), ); expect(loadSubagentRegistryFromDisk().has("run-updated")).toBe(true); @@ -549,10 +539,11 @@ describe("subagent registry persistence", () => { }); expect(announceSpy).toHaveBeenCalledTimes(2); - const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { - runs: Record; - }; - expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeGreaterThanOrEqual(beforeRetry); + const afterSecond = await readPersistedRun<{ cleanupCompletedAt?: number }>( + registryPath, + "run-3", + ); + expect(afterSecond?.cleanupCompletedAt).toBeGreaterThanOrEqual(beforeRetry); }); it("retries cleanup announce after announce flow rejects", async () => { @@ -579,11 +570,12 @@ describe("subagent registry persistence", () => { }); expect(announceSpy).toHaveBeenCalledTimes(1); - const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as { - runs: Record; - }; - expect(afterFirst.runs["run-reject"].cleanupHandled).toBe(false); - expect(afterFirst.runs["run-reject"].cleanupCompletedAt).toBeUndefined(); + const afterFirst = await readPersistedRun<{ + cleanupHandled?: boolean; + cleanupCompletedAt?: number; + }>(registryPath, "run-reject"); + expect(afterFirst?.cleanupHandled).toBe(false); + expect(afterFirst?.cleanupCompletedAt).toBeUndefined(); announceSpy.mockResolvedValueOnce(true); const beforeRetry = Date.now(); @@ -596,10 +588,11 @@ describe("subagent registry persistence", () => { }); expect(announceSpy).toHaveBeenCalledTimes(2); - const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { - runs: Record; - }; - expect(afterSecond.runs["run-reject"].cleanupCompletedAt).toBeGreaterThanOrEqual(beforeRetry); + const afterSecond = await readPersistedRun<{ cleanupCompletedAt?: number }>( + registryPath, + "run-reject", + ); + expect(afterSecond?.cleanupCompletedAt).toBeGreaterThanOrEqual(beforeRetry); }); it("keeps delete-mode runs retryable when announce is deferred", async () => { @@ -628,17 +621,12 @@ describe("subagent registry persistence", () => { announceSpy.mockResolvedValueOnce(true); restartRegistry(); await waitForRegistryWork(async () => { - const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { - runs?: Record; - }; - return announceSpy.mock.calls.length === 2 && afterSecond.runs?.["run-4"] === undefined; + const afterSecond = await readPersistedRun(registryPath, "run-4"); + return announceSpy.mock.calls.length === 2 && afterSecond === undefined; }); expect(announceSpy).toHaveBeenCalledTimes(2); - const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { - runs?: Record; - }; - expect(afterSecond.runs?.["run-4"]).toBeUndefined(); + await expect(readPersistedRun(registryPath, "run-4")).resolves.toBeUndefined(); }); it("reconciles orphaned restored runs by pruning them from registry", async () => { @@ -654,17 +642,11 @@ describe("subagent registry persistence", () => { restartRegistry(); await waitForRegistryWork(async () => { - const after = JSON.parse(await fs.readFile(registryPath, "utf8")) as { - runs?: Record; - }; - return after.runs?.["run-orphan-restore"] === undefined; + return (await readPersistedRun(registryPath, "run-orphan-restore")) === undefined; }); expect(announceSpy).not.toHaveBeenCalled(); - const after = JSON.parse(await fs.readFile(registryPath, "utf8")) as { - runs?: Record; - }; - expect(after.runs?.["run-orphan-restore"]).toBeUndefined(); + await expect(readPersistedRun(registryPath, "run-orphan-restore")).resolves.toBeUndefined(); expect(listSubagentRunsForRequester("agent:main:main")).toHaveLength(0); }); @@ -690,10 +672,7 @@ describe("subagent registry persistence", () => { restartRegistry(); await waitForRegistryWork(async () => { - const after = JSON.parse(await fs.readFile(registryPath, "utf8")) as { - runs?: Record; - }; - return after.runs?.[runId] === undefined; + return (await readPersistedRun(registryPath, runId)) === undefined; }); expect(callGateway).not.toHaveBeenCalled(); @@ -787,10 +766,8 @@ describe("subagent registry persistence", () => { }); await expect(fs.access(attachmentsDir)).rejects.toMatchObject({ code: "ENOENT" }); - const after = JSON.parse(await fs.readFile(registryPath, "utf8")) as { - runs?: Record; - }; - expect(after.runs?.["run-orphan-attachments"]).toBeUndefined(); + await expect(readPersistedRun(registryPath, "run-orphan-attachments")).resolves.toBeUndefined(); + await expect(fs.access(registryPath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("prefers active runs and can resolve them from persisted registry snapshots", async () => { diff --git a/src/agents/subagent-registry.store.ts b/src/agents/subagent-registry.store.ts index 021483cf5eb..48a662360ca 100644 --- a/src/agents/subagent-registry.store.ts +++ b/src/agents/subagent-registry.store.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; -import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; +import { loadJsonFile } from "../infra/json-file.js"; import { readStringValue } from "../shared/string-coerce.js"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import { @@ -96,9 +96,8 @@ function subagentRegistryDbOptions(): OpenClawStateDatabaseOptions { function normalizePersistedRunRecords(params: { runsRaw: Record; isLegacy: boolean; -}): { migrated: boolean; runs: Map } { +}): Map { const out = new Map(); - let migrated = false; for (const [runId, entry] of Object.entries(params.runsRaw)) { if (!entry || typeof entry !== "object") { continue; @@ -149,11 +148,8 @@ function normalizePersistedRunRecords(params: { cleanupHandled, spawnMode: typed.spawnMode === "session" ? "session" : "run", }); - if (params.isLegacy) { - migrated = true; - } } - return { migrated, runs: out }; + return out; } function loadSubagentRegistryFromSqlite(): Map | null { @@ -168,7 +164,7 @@ function loadSubagentRegistryFromSqlite(): Map | null for (const entry of entries) { runsRaw[entry.key] = entry.value; } - return normalizePersistedRunRecords({ runsRaw, isLegacy: false }).runs; + return normalizePersistedRunRecords({ runsRaw, isLegacy: false }); } export function loadSubagentRegistryFromDisk(): Map { @@ -203,17 +199,13 @@ export function loadSubagentRegistryFromDisk(): Map { setCachedRegistryRead(pathname, signature, new Map()); return new Map(); } - const { migrated, runs: out } = normalizePersistedRunRecords({ + const out = normalizePersistedRunRecords({ runsRaw: runsRaw as Record, isLegacy: record.version === 1, }); - if (migrated) { - try { - saveSubagentRegistryToDisk(out); - } catch { - // ignore migration write failures - } - } else { + try { + saveSubagentRegistryToDisk(out); + } catch { setCachedRegistryRead(pathname, signature, out); } return out; @@ -241,14 +233,14 @@ export function saveSubagentRegistryToDisk(runs: Map) for (const [runId, entry] of runs.entries()) { writeOpenClawStateKvJson(SUBAGENT_REGISTRY_KV_SCOPE, runId, entry, subagentRegistryDbOptions()); } - // Compatibility export for older tools, downgrade paths, and readable support state. - saveJsonFile(pathname, out); - const signature = statRegistryFileSignature(pathname); - if (signature === null) { - registryReadCache.delete(pathname); - } else { - setCachedRegistryRead(pathname, signature, runs); + try { + fs.unlinkSync(pathname); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } } + registryReadCache.delete(pathname); } function statRegistryFileSignature(pathname: string): string | null { diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts index 3e8e4dc9162..a049a172e5c 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts @@ -14,6 +14,7 @@ import { expectBareNewOrResetAcknowledged, withTempHome, } from "../../test/helpers/auto-reply/trigger-handling-test-harness.js"; +import { savePersistedAuthProfileState } from "../agents/auth-profiles/state.js"; import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; import { registerGroupIntroPromptCases } from "./reply.triggers.group-intro-prompts.cases.js"; import { registerTriggerHandlingUsageSummaryCases } from "./reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.js"; @@ -775,18 +776,13 @@ describe("trigger handling", () => { 2, ), ); - await fs.writeFile( - join(authDir, "auth-state.json"), - JSON.stringify( - { - version: 1, - order: { - "openai-codex": [TEST_PRIMARY_PROFILE_ID], - }, + savePersistedAuthProfileState( + { + order: { + "openai-codex": [TEST_PRIMARY_PROFILE_ID], }, - null, - 2, - ), + }, + authDir, ); const slashSessionKey = "telegram:slash:111"; diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 89601119b78..46830caf6cd 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -428,7 +428,7 @@ export function registerModelsCli(program: Command) { order .command("get") - .description("Show per-agent auth order override (from auth-state.json)") + .description("Show per-agent auth order override") .requiredOption("--provider ", "Provider id (e.g. anthropic)") .option("--agent ", "Agent id (default: configured default agent)") .option("--json", "Output JSON", false) @@ -450,7 +450,7 @@ export function registerModelsCli(program: Command) { order .command("set") - .description("Set per-agent auth order override (writes auth-state.json)") + .description("Set per-agent auth order override") .requiredOption("--provider ", "Provider id (e.g. anthropic)") .option("--agent ", "Agent id (default: configured default agent)") .argument("", "Auth profile ids (e.g. anthropic:default)") diff --git a/src/commands/models/auth-list.test.ts b/src/commands/models/auth-list.test.ts index 71c7eca6953..bf5d8a916fc 100644 --- a/src/commands/models/auth-list.test.ts +++ b/src/commands/models/auth-list.test.ts @@ -21,7 +21,6 @@ vi.mock("../../agents/auth-profiles.js", () => ({ ensureAuthProfileStore: mocks.ensureAuthProfileStore, externalCliDiscoveryForProviderAuth: mocks.externalCliDiscoveryForProviderAuth, resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, - resolveAuthStatePathForDisplay: (agentDir: string) => `${agentDir}/auth-state.json`, })); vi.mock("./load-config.js", () => ({ @@ -96,23 +95,21 @@ describe("modelsAuthListCommand", () => { }); expect(runtime.jsonPayloads).toHaveLength(1); expect(JSON.stringify(runtime.jsonPayloads[0])).not.toContain("secret"); - const payload = runtime.jsonPayloads[0] as - | { - agentId?: unknown; - provider?: unknown; - profiles?: Array>; - } - | undefined; - expect(payload?.agentId).toBe("coder"); - expect(payload?.provider).toBe("openai-codex"); - expect(payload?.profiles).toHaveLength(1); - const [profile] = payload?.profiles ?? []; - expect(profile?.id).toBe("openai-codex:user@example.com"); - expect(profile?.provider).toBe("openai-codex"); - expect(profile?.type).toBe("oauth"); - expect(profile?.email).toBe("user@example.com"); - expect(profile?.expiresAt).toBe("2027-01-15T08:00:00.000Z"); - expect(profile?.cooldownUntil).toBe("2027-01-15T08:00:10.000Z"); + expect(runtime.jsonPayloads[0]).toMatchObject({ + agentId: "coder", + authStateStore: "sqlite", + provider: "openai-codex", + profiles: [ + { + id: "openai-codex:user@example.com", + provider: "openai-codex", + type: "oauth", + email: "user@example.com", + expiresAt: "2027-01-15T08:00:00.000Z", + cooldownUntil: "2027-01-15T08:00:10.000Z", + }, + ], + }); }); it("prints an empty profile list without failing", async () => { @@ -121,10 +118,6 @@ describe("modelsAuthListCommand", () => { await modelsAuthListCommand({}, runtime); - expect(runtime.logs).toEqual([ - "Agent: main", - "Auth state file: /tmp/openclaw/agents/main/auth-state.json", - "Profiles: (none)", - ]); + expect(runtime.logs).toEqual(["Agent: main", "Auth runtime state: SQLite", "Profiles: (none)"]); }); }); diff --git a/src/commands/models/auth-list.ts b/src/commands/models/auth-list.ts index 907dd5a90d9..c1b38f4f38a 100644 --- a/src/commands/models/auth-list.ts +++ b/src/commands/models/auth-list.ts @@ -3,7 +3,6 @@ import { ensureAuthProfileStore, externalCliDiscoveryForProviderAuth, resolveAuthProfileDisplayLabel, - resolveAuthStatePathForDisplay, type AuthProfileCredential, type AuthProfileStore, type ProfileUsageStats, @@ -118,7 +117,7 @@ export async function modelsAuthListCommand( writeRuntimeJson(runtime, { agentId, agentDir: shortenHomePath(agentDir), - authStatePath: shortenHomePath(resolveAuthStatePathForDisplay(agentDir)), + authStateStore: "sqlite", provider: provider ?? null, profiles, }); @@ -126,7 +125,7 @@ export async function modelsAuthListCommand( } runtime.log(`Agent: ${agentId}`); - runtime.log(`Auth state file: ${shortenHomePath(resolveAuthStatePathForDisplay(agentDir))}`); + runtime.log("Auth runtime state: SQLite"); if (provider) { runtime.log(`Provider: ${provider}`); } diff --git a/src/commands/models/auth-order.ts b/src/commands/models/auth-order.ts index 25374bb8e98..a06274170b8 100644 --- a/src/commands/models/auth-order.ts +++ b/src/commands/models/auth-order.ts @@ -3,14 +3,12 @@ import { type AuthProfileStore, externalCliDiscoveryForProviderAuth, ensureAuthProfileStore, - resolveAuthStatePathForDisplay, setAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; -import { shortenHomePath } from "../../utils.js"; import { loadModelsConfig } from "./load-config.js"; import { resolveKnownAgentId } from "./shared.js"; @@ -63,7 +61,7 @@ export async function modelsAuthOrderGetCommand( agentId, agentDir, provider, - authStatePath: shortenHomePath(resolveAuthStatePathForDisplay(agentDir)), + authStateStore: "sqlite", order: order.length > 0 ? order : null, }); return; @@ -71,7 +69,7 @@ export async function modelsAuthOrderGetCommand( runtime.log(`Agent: ${agentId}`); runtime.log(`Provider: ${provider}`); - runtime.log(`Auth state file: ${shortenHomePath(resolveAuthStatePathForDisplay(agentDir))}`); + runtime.log("Auth runtime state: SQLite"); runtime.log(order.length > 0 ? `Order override: ${order.join(", ")}` : "Order override: (none)"); } @@ -87,7 +85,7 @@ export async function modelsAuthOrderClearCommand( }); if (!updated) { throw new Error( - `Failed to update auth-state.json; the auth state lock may be busy. Wait a moment and rerun ${formatCliCommand("openclaw models auth order clear --provider " + provider)}.`, + `Failed to update auth runtime state; the SQLite lock may be busy. Wait a moment and rerun ${formatCliCommand("openclaw models auth order clear --provider " + provider)}.`, ); } @@ -132,7 +130,7 @@ export async function modelsAuthOrderSetCommand( }); if (!updated) { throw new Error( - `Failed to update auth-state.json; the auth state lock may be busy. Wait a moment and rerun ${formatCliCommand("openclaw models auth order set --provider " + provider + " ")}.`, + `Failed to update auth runtime state; the SQLite lock may be busy. Wait a moment and rerun ${formatCliCommand("openclaw models auth order set --provider " + provider + " ")}.`, ); } diff --git a/src/tui/tui-last-session.test.ts b/src/tui/tui-last-session.test.ts index 063a885119a..236529d5c85 100644 --- a/src/tui/tui-last-session.test.ts +++ b/src/tui/tui-last-session.test.ts @@ -42,11 +42,12 @@ describe("tui last session state", () => { }); await expect(readTuiLastSessionKey({ scopeKey, stateDir })).resolves.toBe("agent:main:tui-123"); - const raw = await fs.readFile(resolveTuiLastSessionStatePath(stateDir), "utf8"); - expect(raw).not.toContain("127.0.0.1"); + await expect(fs.access(resolveTuiLastSessionStatePath(stateDir))).rejects.toMatchObject({ + code: "ENOENT", + }); }); - it("restores from SQLite when the compatibility JSON file is missing", async () => { + it("restores from SQLite without legacy JSON", async () => { const stateDir = await makeTempStateDir(); const scopeKey = buildTuiLastSessionScopeKey({ connectionUrl: "local", @@ -59,14 +60,16 @@ describe("tui last session state", () => { sessionKey: "agent:main:tui-sqlite", stateDir, }); - await fs.rm(resolveTuiLastSessionStatePath(stateDir), { force: true }); + await expect(fs.access(resolveTuiLastSessionStatePath(stateDir))).rejects.toMatchObject({ + code: "ENOENT", + }); await expect(readTuiLastSessionKey({ scopeKey, stateDir })).resolves.toBe( "agent:main:tui-sqlite", ); }); - it("imports legacy compatibility JSON into SQLite on read", async () => { + it("imports legacy JSON into SQLite on read and removes it", async () => { const stateDir = await makeTempStateDir(); const scopeKey = buildTuiLastSessionScopeKey({ connectionUrl: "legacy", @@ -83,13 +86,13 @@ describe("tui last session state", () => { await expect(readTuiLastSessionKey({ scopeKey, stateDir })).resolves.toBe( "agent:main:legacy-json", ); - await fs.rm(statePath, { force: true }); + await expect(fs.access(statePath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(readTuiLastSessionKey({ scopeKey, stateDir })).resolves.toBe( "agent:main:legacy-json", ); }); - it("clears stale pointers from SQLite and compatibility JSON", async () => { + it("clears stale pointers from SQLite and imported legacy JSON", async () => { const stateDir = await makeTempStateDir(); const staleScope = buildTuiLastSessionScopeKey({ connectionUrl: "stale", @@ -111,23 +114,31 @@ describe("tui last session state", () => { sessionKey: "agent:main:tui-live", stateDir, }); + const legacyScope = buildTuiLastSessionScopeKey({ + connectionUrl: "legacy-stale", + agentId: "main", + sessionScope: "per-sender", + }); + const statePath = resolveTuiLastSessionStatePath(stateDir); + await fs.mkdir(path.dirname(statePath), { recursive: true }); + await fs.writeFile( + statePath, + JSON.stringify({ [legacyScope]: { sessionKey: "agent:main:legacy-stale", updatedAt: 1000 } }), + ); await expect( clearTuiLastSessionPointers({ stateDir, - sessionKeys: new Set(["agent:main:main"]), + sessionKeys: new Set(["agent:main:main", "agent:main:legacy-stale"]), }), - ).resolves.toBe(1); + ).resolves.toBe(2); await expect(readTuiLastSessionKey({ scopeKey: staleScope, stateDir })).resolves.toBeNull(); + await expect(readTuiLastSessionKey({ scopeKey: legacyScope, stateDir })).resolves.toBeNull(); await expect(readTuiLastSessionKey({ scopeKey: liveScope, stateDir })).resolves.toBe( "agent:main:tui-live", ); - const raw = JSON.parse( - await fs.readFile(resolveTuiLastSessionStatePath(stateDir), "utf8"), - ) as Record; - expect(raw[staleScope]).toBeUndefined(); - expect(raw[liveScope]?.sessionKey).toBe("agent:main:tui-live"); + await expect(fs.access(statePath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("restores only a remembered session that still belongs to the current agent", () => { diff --git a/src/tui/tui-last-session.ts b/src/tui/tui-last-session.ts index 553b6ad7e5b..a613eea49e7 100644 --- a/src/tui/tui-last-session.ts +++ b/src/tui/tui-last-session.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { privateFileStore } from "../infra/private-file-store.js"; @@ -51,6 +52,10 @@ async function readStore(filePath: string): Promise { } } +async function deleteStore(filePath: string): Promise { + await fs.rm(filePath, { force: true }); +} + function stateKvOptionsForStateDir(stateDir?: string) { return stateDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } } : {}; } @@ -81,6 +86,28 @@ function writeTuiLastSessionKv(params: { ); } +async function importLegacyTuiLastSessionStore(params: { + stateDir?: string; +}): Promise { + const filePath = resolveTuiLastSessionStatePath(params.stateDir); + const store = await readStore(filePath); + for (const [scopeKey, value] of Object.entries(store)) { + const record = normalizeLastSessionRecord(value); + if (!record) { + continue; + } + writeTuiLastSessionKv({ + scopeKey, + record, + stateDir: params.stateDir, + }); + } + if (Object.keys(store).length > 0) { + await deleteStore(filePath); + } + return store; +} + function normalizeMarker(value: unknown): string { return typeof value === "string" ? value.trim().toLowerCase() : ""; } @@ -119,7 +146,7 @@ export async function readTuiLastSessionKey(params: { return kvRecord.sessionKey; } - const store = await readStore(resolveTuiLastSessionStatePath(params.stateDir)); + const store = await importLegacyTuiLastSessionStore({ stateDir: params.stateDir }); const diskRecord = normalizeLastSessionRecord(store[params.scopeKey]); if (!diskRecord) { return null; @@ -141,7 +168,6 @@ export async function writeTuiLastSessionKey(params: { if (!sessionKey || sessionKey === "unknown" || isHeartbeatSessionKey(sessionKey)) { return; } - const filePath = resolveTuiLastSessionStatePath(params.stateDir); const record = { sessionKey, updatedAt: Date.now(), @@ -151,11 +177,7 @@ export async function writeTuiLastSessionKey(params: { record, stateDir: params.stateDir, }); - const store = await readStore(filePath); - store[params.scopeKey] = record; - await privateFileStore(path.dirname(filePath)).writeJson(path.basename(filePath), store, { - trailingNewline: true, - }); + await deleteStore(resolveTuiLastSessionStatePath(params.stateDir)); } export async function clearTuiLastSessionPointers(params: { @@ -179,23 +201,13 @@ export async function clearTuiLastSessionPointers(params: { } } - const filePath = resolveTuiLastSessionStatePath(params.stateDir); - const store = await readStore(filePath); - const next: LastSessionStore = {}; - let diskRemoved = 0; + const store = await importLegacyTuiLastSessionStore({ stateDir: params.stateDir }); for (const [key, value] of Object.entries(store)) { const record = normalizeLastSessionRecord(value); if (record && params.sessionKeys.has(record.sessionKey)) { - diskRemoved += 1; + deleteOpenClawStateKvJson(TUI_LAST_SESSION_KV_SCOPE, key, kvOptions); removedScopeKeys.add(key); - continue; } - next[key] = value; - } - if (diskRemoved > 0) { - await privateFileStore(path.dirname(filePath)).writeJson(path.basename(filePath), next, { - trailingNewline: true, - }); } return removedScopeKeys.size; }