mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-11 12:58:34 +00:00
refactor: make sqlite sidecars import-only
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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...>")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user