refactor: make sqlite sidecars import-only

This commit is contained in:
Peter Steinberger
2026-05-06 18:31:57 +01:00
parent 3330c1abfc
commit 39462bc997
21 changed files with 298 additions and 341 deletions

View File

@@ -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/<agentId>/agent/auth-profiles.json` (legacy: `~/.openclaw/agent/auth-profiles.json`).
- Runtime auth-routing state is SQLite-primary and compatibility-exported to
`~/.openclaw/agents/<agentId>/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.
</Note>
State is stored in SQLite and compatibility-exported to `auth-state.json`:
State is stored in SQLite:
```json
{

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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<string, string[]>;
lastGood?: Record<string, string>;
usageStats?: Record<string, { lastUsed?: number }>;
};
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),

View File

@@ -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 } },

View File

@@ -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;
}

View File

@@ -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<string, Record<string, unknown> | undefined>;
return (loadPersistedAuthProfileState(agentDir).usageStats ?? {}) as Record<
string,
Record<string, unknown> | 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,
);
}

View File

@@ -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<string, { lastUsed?: number }>),
};
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({

View File

@@ -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(

View File

@@ -7,7 +7,7 @@
* Cache layers (checked in order):
* 1. In-memory Map (instant, cleared on process restart)
* 2. SQLite KV cache (<stateDir>/state/openclaw.sqlite)
* 3. Compatibility JSON file (<stateDir>/cache/openrouter-models.json)
* 3. Legacy JSON import (<stateDir>/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<string, OpenRouterModelCapabilities>): void {
}
}
function writeDiskCache(map: Map<string, OpenRouterModelCapabilities>): 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<string, OpenRouterModelCapabilities>): void {
writeSqliteCache(map);
writeDiskCache(map);
}
function isValidCapabilities(value: unknown): value is OpenRouterModelCapabilities {
@@ -187,6 +172,11 @@ function readPersistentCache(): Map<string, OpenRouterModelCapabilities> | undef
const diskCache = readDiskCache();
if (diskCache) {
writeSqliteCache(diskCache);
try {
unlinkSync(resolveDiskCachePath());
} catch {
// Best-effort legacy cache cleanup.
}
}
return diskCache;
}

View File

@@ -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<string> | 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<typeof import("./subagent-registry.store.js")>(
"./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<string, import("./subagent-registry.types.js").SubagentRunRecord>;
};
return new Map(Object.entries(parsed.runs ?? {}));
} catch {
return new Map();
}
},
saveSubagentRegistryToDisk: (
runs: Map<string, import("./subagent-registry.types.js").SubagentRunRecord>,
) => {
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<string, unknown> };
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(), {

View File

@@ -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 <T>(
registryPath: string,
_registryPath: string,
runId: string,
): Promise<T | undefined> => {
const parsed = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
runs?: Record<string, unknown>;
};
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<string, SubagentRunRecord>) => {
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<string, { cleanupCompletedAt?: number }>;
};
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<string, { cleanupHandled?: boolean; cleanupCompletedAt?: number }>;
};
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<string, { cleanupCompletedAt?: number }>;
};
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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
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 () => {

View File

@@ -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<string, unknown>;
isLegacy: boolean;
}): { migrated: boolean; runs: Map<string, SubagentRunRecord> } {
}): Map<string, SubagentRunRecord> {
const out = new Map<string, SubagentRunRecord>();
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<string, SubagentRunRecord> | null {
@@ -168,7 +164,7 @@ function loadSubagentRegistryFromSqlite(): Map<string, SubagentRunRecord> | 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<string, SubagentRunRecord> {
@@ -203,17 +199,13 @@ export function loadSubagentRegistryFromDisk(): Map<string, SubagentRunRecord> {
setCachedRegistryRead(pathname, signature, new Map());
return new Map();
}
const { migrated, runs: out } = normalizePersistedRunRecords({
const out = normalizePersistedRunRecords({
runsRaw: runsRaw as Record<string, unknown>,
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<string, SubagentRunRecord>)
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 {

View File

@@ -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";

View File

@@ -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 <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "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 <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.argument("<profileIds...>", "Auth profile ids (e.g. anthropic:default)")

View File

@@ -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<Record<string, unknown>>;
}
| 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)"]);
});
});

View File

@@ -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}`);
}

View File

@@ -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 + " <profileIds...>")}.`,
`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 + " <profileIds...>")}.`,
);
}

View File

@@ -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<string, { sessionKey?: string }>;
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", () => {

View File

@@ -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<LastSessionStore> {
}
}
async function deleteStore(filePath: string): Promise<void> {
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<LastSessionStore> {
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;
}