refactor(sessions): collapse legacy JSON store (#113075)

* refactor(sessions): collapse legacy JSON store

* chore(sessions): ratchet legacy importer boundary

* fix(sessions): align cleanup checks
This commit is contained in:
Peter Steinberger
2026-07-23 09:36:48 -07:00
committed by GitHub
parent 54f8f61167
commit c440ae3e86
105 changed files with 1156 additions and 8852 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
# Distinct OPENCLAW_* names in production source under src, packages, and extensions.
# Ratchet: lower this number when cleanup removes names; never raise it.
525
523
-3
View File
@@ -724,9 +724,6 @@ src/config/schema.ts
src/config/sessions/disk-budget.ts
src/config/sessions/session-accessor.conformance.test.ts
src/config/sessions/session-accessor.test.ts
src/config/sessions/sessions.test.ts
src/config/sessions/store.pruning.integration.test.ts
src/config/sessions/store.ts
src/config/sessions/transcript.test.ts
src/config/sessions/transcript.ts
src/config/validation.ts
@@ -100,6 +100,4 @@ The focused proof should use:
node scripts/run-vitest.mjs src/config/sessions/session-accessor.conformance.test.ts
```
If the final tests live in `store.session-lifecycle-mutation.test.ts`, run that
file explicitly with the same wrapper. Broad `pnpm` gates should stay on
Crabbox/Testbox for this Codex worktree.
Broad `pnpm` gates should stay on Crabbox/Testbox for this Codex worktree.
+3 -2
View File
@@ -1852,8 +1852,9 @@ runtime contract:
`patchSessionEntry`, `deleteSessionEntry`, and `listSessionEntries`.
- Whole-store rewrite helpers, file writers, queue tests, alias pruning, and
legacy-key deletion parameters are gone from runtime.
- Deprecated root-package compatibility exports still adapt canonical
`sessions.json` paths onto the SQLite row APIs.
- Deprecated root-package compatibility exports delegate to the doctor-only
`sessions.json` importer through 2026-10-12; Plugin SDK compatibility reads
continue to project canonical SQLite rows.
- `sessions.json` parsing remains only in doctor migration/import code and
doctor tests.
- Runtime lifecycle fallback reads SQLite transcript headers, not JSONL first
+1 -1
View File
@@ -1837,7 +1837,7 @@
"test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs",
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "node scripts/test-projects.mjs --watch",
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/config/sessions/store.session-lifecycle-mutation.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
"ts-topology": "node --import tsx scripts/ts-topology.ts",
@@ -6,30 +6,18 @@
"src/agents/subagent-announce.test-support.ts": 2,
"src/agents/tools/transcripts-tool.ts": 2,
"src/auto-reply/reply/commands-session-store.ts": 2,
"src/commands/doctor-heartbeat-session-target.ts": 2,
"src/commands/doctor-state-integrity.ts": 2,
"src/commands/doctor/shared/codex-route-session-repair.ts": 3,
"src/config/sessions/session-accessor.entry.ts": 2,
"src/config/sessions/store-load.ts": 3,
"src/config/sessions/store.ts": 9,
"src/config/sessions/transcript.ts": 2
},
"sessionAccessorWrite": {
"src/agents/subagent-spawn.test-helpers.ts": 1,
"src/commands/doctor-heartbeat-main-session-repair.ts": 2,
"src/commands/doctor-session-snapshots.ts": 2,
"src/commands/doctor-session-state-providers.ts": 2,
"src/commands/doctor-state-integrity.ts": 2,
"src/commands/doctor/shared/codex-route-session-repair.ts": 2,
"src/gateway/test-helpers.mocks.ts": 1,
"src/infra/state-migrations.session-store.ts": 2,
"src/plugins/registry-runtime.ts": 1
},
"sessionCompactManualTrim": {},
"sessionLifecycleCleanup": {
"src/config/sessions/session-accessor.sqlite-projection.ts": 2,
"src/config/sessions/store-maintenance-operations.ts": 2,
"src/config/sessions/store.ts": 2
"src/infra/state-migrations.legacy-session-store.ts": 2
},
"transcriptWriter": {
"src/agents/embedded-agent-runner/compaction-hooks.ts": 2,
+3 -3
View File
@@ -1,9 +1,9 @@
/** Tests ACP session metadata persistence, joins, and migration helpers. */
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { loadSessionStore } from "../../config/sessions/store-load.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
@@ -83,7 +83,7 @@ describe("ACP session metadata SQLite store", () => {
});
expect(result?.acp?.runtimeSessionName).toBe("codex-discord");
expect(loadSessionStore(storePath)[sessionKey]?.acp).toBeUndefined();
expect(fs.existsSync(storePath)).toBe(false);
expect(
readAcpSessionEntry({
cfg,
@@ -226,7 +226,7 @@ describe("ACP session metadata SQLite store", () => {
sessionKey: storeSessionKey,
})?.acp?.runtimeSessionName,
).toBe("codex-normalized");
expect(loadSessionStore(storePath)[storeSessionKey]?.acp).toBeUndefined();
expect(fs.existsSync(storePath)).toBe(false);
const legacyEmbeddedEntry = readStoredAcpSessionEntry({
storePath,
sessionKey: storeSessionKey,
-4
View File
@@ -198,10 +198,6 @@ vi.mock("../config/sessions/paths.js", () => ({
resolveStorePath: hoisted.resolveStorePathMock,
}));
vi.mock("../config/sessions/store.js", () => ({
loadSessionStore: hoisted.loadSessionStoreMock,
}));
vi.mock("../config/sessions/session-accessor.js", () => hoisted.createSessionAccessorMock());
vi.mock("../config/sessions.js", () => ({
@@ -8,7 +8,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import { ADMIN_SCOPE } from "../gateway/method-scopes.js";
import { startGatewayServer } from "../gateway/server.js";
import {
@@ -15,7 +15,7 @@ import {
formatSqliteSessionFileMarker,
parseSqliteSessionFileMarker,
} from "../../config/sessions/sqlite-marker.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store-writer-state.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js";
@@ -4,7 +4,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store-writer-state.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import {
INTERNAL_RUNTIME_CONTEXT_BEGIN,
+3 -8
View File
@@ -6,7 +6,6 @@ import { Value } from "typebox/value";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelMessagingAdapter } from "../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../config/config.js";
import { clearSessionStoreCacheForTest } from "../config/sessions.js";
import {
appendTranscriptMessage,
upsertSessionEntry,
@@ -1188,14 +1187,10 @@ describe("sessions tools", () => {
const requesterSessionKey = "agent:main:clickclack:discussion-proof";
const targetSessionKey = "agent:main:main";
const expectedSessionId = "scoped-main-incarnation";
fs.writeFileSync(
storePath,
`${JSON.stringify({
[targetSessionKey]: { sessionId: expectedSessionId, updatedAt: 1 },
})}\n`,
"utf8",
await upsertSessionEntry(
{ agentId: "main", sessionKey: targetSessionKey, storePath },
{ sessionId: expectedSessionId, updatedAt: 1 },
);
clearSessionStoreCacheForTest();
const unregister = createSessionVisibilityChecker.registerScopedAccessProvider((request) =>
request.requesterSessionKey === requesterSessionKey &&
request.targetSessionKey === targetSessionKey
@@ -13,6 +13,7 @@ type DeliveryDeps = {
isActive: boolean;
};
isRequesterSessionAbandoned: (requesterSessionKey: string, sessionId?: string) => boolean;
loadSessionEntry: typeof import("./subagent-announce-delivery.runtime.js").loadSessionEntry;
loadRequesterSessionEntry: typeof import("./subagent-announce-delivery.js").loadRequesterSessionEntry;
queueEmbeddedAgentMessageWithOutcome: (
sessionId: string,
+4 -2
View File
@@ -102,6 +102,7 @@ type SubagentAnnounceDeliveryDeps = {
isActive: boolean;
};
isRequesterSessionAbandoned: (requesterSessionKey: string, sessionId?: string) => boolean;
loadSessionEntry: typeof loadSessionEntry;
loadRequesterSessionEntry: typeof loadRequesterSessionEntry;
queueEmbeddedAgentMessageWithOutcome: (
sessionId: string,
@@ -125,6 +126,7 @@ const defaultSubagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = {
},
isRequesterSessionAbandoned: (requesterSessionKey, sessionId) =>
isEmbeddedRunAbandoned({ sessionKey: requesterSessionKey, sessionId }),
loadSessionEntry,
loadRequesterSessionEntry,
queueEmbeddedAgentMessageWithOutcome: queueEmbeddedAgentMessageWithOutcomeAsync,
sendMessage,
@@ -743,7 +745,7 @@ export function loadRequesterSessionEntry(requesterSessionKey: string) {
const canonicalKey = resolveRequesterStoreKey(cfg, requesterSessionKey);
const agentId = resolveAgentIdFromSessionKey(canonicalKey);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const entry = loadSessionEntry({
const entry = subagentAnnounceDeliveryDeps.loadSessionEntry({
storePath,
sessionKey: canonicalKey,
clone: false,
@@ -755,7 +757,7 @@ export function loadSessionEntryByKey(sessionKey: string) {
const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig();
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
return loadSessionEntry({
return subagentAnnounceDeliveryDeps.loadSessionEntry({
storePath,
sessionKey,
clone: false,
@@ -8,7 +8,6 @@ import {
type OpenClawConfig,
} from "../config/config.js";
import * as configSessions from "../config/sessions.js";
import * as sessionAccessor from "../config/sessions/session-accessor.js";
import type { SessionEntry } from "../config/sessions/types.js";
import * as gatewayCall from "../gateway/call.js";
import {
@@ -125,8 +124,6 @@ function expectAgentCallFields(
const agentSpy = vi.fn(async (_req: AgentCallRequest) => visibleAgentResponse());
const sendSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "send-main", status: "ok" }));
const sessionsDeleteSpy = vi.fn((_req: AgentCallRequest) => undefined);
const loadSessionEntrySpy = vi.spyOn(sessionAccessor, "loadSessionEntry");
const loadSessionStoreSpy = vi.spyOn(configSessions, "loadSessionStore");
const resolveAgentIdFromSessionKeySpy = vi.spyOn(configSessions, "resolveAgentIdFromSessionKey");
const resolveStorePathSpy = vi.spyOn(configSessions, "resolveStorePath");
const resolveMainSessionKeySpy = vi.spyOn(configSessions, "resolveMainSessionKey");
@@ -401,6 +398,7 @@ describe("subagent announce formatting", () => {
req: Parameters<typeof gatewayCall.callGateway>[0],
) => (await callGatewaySpy(req)) as T,
getRuntimeConfig: () => configOverride,
loadSessionEntry: (scope) => loadSessionStoreFixture()[scope.sessionKey],
getRequesterSessionActivity: (requesterSessionKey: string) => {
const entry = loadSessionStoreFixture()[requesterSessionKey];
const sessionId = entry?.sessionId;
@@ -427,10 +425,6 @@ describe("subagent announce formatting", () => {
resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions.json",
});
loadSessionEntrySpy
.mockReset()
.mockImplementation((scope) => loadSessionStoreFixture()[scope.sessionKey]);
loadSessionStoreSpy.mockReset().mockImplementation(() => loadSessionStoreFixture());
resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main");
resolveStorePathSpy.mockReset().mockImplementation(() => "/tmp/sessions.json");
resolveMainSessionKeySpy.mockReset().mockImplementation(() => "agent:main:main");
+6 -5
View File
@@ -5,11 +5,12 @@
* IO without changing the announce logic itself.
*/
export { getRuntimeConfig } from "../config/config.js";
export {
readSessionEntry,
resolveAgentIdFromSessionKey,
resolveStorePath,
} from "../config/sessions.js";
export { resolveAgentIdFromSessionKey, resolveStorePath } from "../config/sessions.js";
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
export function readSessionEntry(storePath: string, sessionKey: string) {
return loadSessionEntry({ storePath, sessionKey });
}
export { callGateway } from "../gateway/call.js";
export { readSessionMessagesAsync } from "../gateway/session-transcript-readers.js";
export { dispatchGatewayMethodInProcess } from "../gateway/server-plugins.js";
+14 -15
View File
@@ -2,7 +2,6 @@
// embedded run was interrupted while the registry still considers them active.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as config from "../config/config.js";
import * as sessions from "../config/sessions.js";
import * as sessionAccessor from "../config/sessions/session-accessor.js";
import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js";
import {
@@ -165,7 +164,7 @@ function createActiveRuns(...runs: SubagentRunRecord[]) {
}
function mockSingleAbortedSession(
overrides: Partial<NonNullable<ReturnType<typeof sessions.loadSessionStore>[string]>> = {},
overrides: Partial<NonNullable<ReturnType<typeof sessionMocks.loadSessionStore>[string]>> = {},
) {
const store = {
"agent:main:subagent:test-session-1": {
@@ -175,12 +174,12 @@ function mockSingleAbortedSession(
...overrides,
},
};
vi.mocked(sessions.loadSessionStore).mockReturnValue(store);
sessionMocks.loadSessionStore.mockReturnValue(store);
return store;
}
async function expectSkippedRecovery(store: ReturnType<typeof sessions.loadSessionStore>) {
vi.mocked(sessions.loadSessionStore).mockReturnValue(store);
async function expectSkippedRecovery(store: ReturnType<typeof sessionMocks.loadSessionStore>) {
sessionMocks.loadSessionStore.mockReturnValue(store);
const result = await recoverOrphanedSubagentSessions({
getActiveRuns: () => createActiveRuns(createTestRunRecord()),
@@ -241,7 +240,7 @@ describe("subagent-orphan-recovery", () => {
abortedLastRun: true,
};
vi.mocked(sessions.loadSessionStore).mockReturnValue({
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:test-session-1": sessionEntry,
});
@@ -387,7 +386,7 @@ describe("subagent-orphan-recovery", () => {
abortedLastRun: true,
},
};
vi.mocked(sessions.loadSessionStore).mockReturnValue(store);
sessionMocks.loadSessionStore.mockReturnValue(store);
const activeRuns = createActiveRuns(
createTestRunRecord({
runId: "fresh-run",
@@ -467,7 +466,7 @@ describe("subagent-orphan-recovery", () => {
});
it("recovers restart-aborted timeout runs even when the registry marked them ended", async () => {
vi.mocked(sessions.loadSessionStore).mockReturnValue({
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:test-session-1": {
sessionId: "session-abc",
updatedAt: Date.now(),
@@ -525,12 +524,12 @@ describe("subagent-orphan-recovery", () => {
endedAt: 2_000,
});
expect(config.getRuntimeConfig).not.toHaveBeenCalled();
expect(sessions.loadSessionStore).not.toHaveBeenCalled();
expect(sessionMocks.loadSessionStore).not.toHaveBeenCalled();
expect(dispatchAgent).not.toHaveBeenCalled();
});
it("handles multiple orphaned sessions", async () => {
vi.mocked(sessions.loadSessionStore).mockReturnValue({
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:session-a": {
sessionId: "id-a",
updatedAt: Date.now(),
@@ -584,7 +583,7 @@ describe("subagent-orphan-recovery", () => {
});
it("handles instance dispatch failure gracefully and preserves abortedLastRun flag", async () => {
vi.mocked(sessions.loadSessionStore).mockReturnValue({
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:test-session-1": {
sessionId: "session-abc",
updatedAt: Date.now(),
@@ -639,7 +638,7 @@ describe("subagent-orphan-recovery", () => {
abortedLastRun: true,
},
};
vi.mocked(sessions.loadSessionStore).mockReturnValue(store);
sessionMocks.loadSessionStore.mockReturnValue(store);
const activeRuns = new Map<string, SubagentRunRecord>();
activeRuns.set("run-1", createTestRunRecord());
@@ -841,7 +840,7 @@ describe("subagent-orphan-recovery", () => {
dispatchAgent.mockResolvedValue({ runId: "new-run" } as never);
vi.mocked(sessionAccessor.patchSessionEntry).mockRejectedValueOnce(new Error("write failed"));
vi.mocked(sessions.loadSessionStore).mockReturnValue({
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:test-session-1": {
sessionId: "session-abc",
updatedAt: Date.now(),
@@ -870,7 +869,7 @@ describe("subagent-orphan-recovery", () => {
dispatchAgent.mockResolvedValue({ runId: "new-run" } as never);
vi.mocked(subagentRegistrySteerRuntime.replaceSubagentRunAfterSteer).mockReturnValue(false);
vi.mocked(sessions.loadSessionStore).mockReturnValue({
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:test-session-1": {
sessionId: "session-abc",
updatedAt: Date.now(),
@@ -900,7 +899,7 @@ describe("subagent-orphan-recovery", () => {
});
it("finalizes interrupted runs with a readable failure after recovery retries are exhausted", async () => {
vi.mocked(sessions.loadSessionStore).mockReturnValue({
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:subagent:test-session-1": {
sessionId: "session-abc",
updatedAt: Date.now(),
+2 -1
View File
@@ -1,4 +1,5 @@
import { getSessionEntry, resolveStorePath } from "../../config/sessions.js";
import { resolveStorePath } from "../../config/sessions.js";
import { loadSessionEntry as getSessionEntry } from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js";
+28 -14
View File
@@ -6,7 +6,10 @@ import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { Value } from "typebox/value";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { clearSessionStoreCacheForTest } from "../../config/sessions.js";
import {
applySessionStoreProjection,
replaceSessionEntrySync,
} from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { callGateway as gatewayCall } from "../../gateway/call.js";
import { createSessionVisibilityChecker } from "../../plugin-sdk/session-visibility.js";
@@ -33,16 +36,25 @@ function useLoggingConfig(name: string, logging: Record<string, unknown>): void
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
}
function writeSessionStore(
async function writeSessionStore(
name: string,
entries: Record<string, { sessionId: string; updatedAt: number; archivedAt?: number }>,
): string {
): Promise<string> {
if (!tempDir) {
throw new Error("tempDir not initialized");
}
const storePath = path.join(tempDir, name);
fs.writeFileSync(storePath, `${JSON.stringify(entries)}\n`, "utf8");
clearSessionStoreCacheForTest();
await applySessionStoreProjection({
storePath,
skipMaintenance: true,
update: (store) => {
for (const sessionKey of Object.keys(store)) {
delete store[sessionKey];
}
Object.assign(store, entries);
return { persist: true, result: undefined };
},
});
return storePath;
}
@@ -428,7 +440,7 @@ describe("sessions_history redaction", () => {
const requesterSessionKey = "agent:main:clickclack:discussion-proof";
const targetSessionKey = "agent:main:main";
const expectedSessionId = "main-session-incarnation";
const storePath = writeSessionStore("scoped-grant.json", {
const storePath = await writeSessionStore("scoped-grant.json", {
[targetSessionKey]: { sessionId: expectedSessionId, updatedAt: 1 },
});
const requests: CallGatewayRequest[] = [];
@@ -474,7 +486,7 @@ describe("sessions_history redaction", () => {
const requesterSessionKey = "agent:main:clickclack:discussion-race";
const targetSessionKey = "agent:main:main";
const expectedSessionId = "old-incarnation";
const storePath = writeSessionStore("scoped-grant-race.json", {
const storePath = await writeSessionStore("scoped-grant-race.json", {
[targetSessionKey]: { sessionId: expectedSessionId, updatedAt: 1 },
});
let grantChecks = 0;
@@ -488,9 +500,10 @@ describe("sessions_history redaction", () => {
}
grantChecks += 1;
if (grantChecks === 2) {
writeSessionStore("scoped-grant-race.json", {
[targetSessionKey]: { sessionId: "replacement-incarnation", updatedAt: 2 },
});
replaceSessionEntrySync(
{ storePath, sessionKey: targetSessionKey },
{ sessionId: "replacement-incarnation", updatedAt: 2 },
);
}
return { expectedSessionId };
});
@@ -527,7 +540,7 @@ describe("sessions_history redaction", () => {
const requesterSessionKey = "agent:main:clickclack:discussion-archive-race";
const targetSessionKey = "agent:main:main";
const expectedSessionId = "main-incarnation";
const storePath = writeSessionStore("scoped-grant-archive-race.json", {
const storePath = await writeSessionStore("scoped-grant-archive-race.json", {
[targetSessionKey]: { sessionId: expectedSessionId, updatedAt: 1 },
});
let grantChecks = 0;
@@ -541,9 +554,10 @@ describe("sessions_history redaction", () => {
}
grantChecks += 1;
if (grantChecks === 2) {
writeSessionStore("scoped-grant-archive-race.json", {
[targetSessionKey]: { sessionId: expectedSessionId, updatedAt: 2, archivedAt: 2 },
});
replaceSessionEntrySync(
{ storePath, sessionKey: targetSessionKey },
{ sessionId: expectedSessionId, updatedAt: 2, archivedAt: 2 },
);
}
return { expectedSessionId };
});
@@ -2,7 +2,7 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { afterEach, beforeEach, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles.js";
import { clearSessionStoreCacheForTest } from "../config/sessions.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import { resetSystemEventsForTest } from "../infra/system-events.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import type { PluginRegistry } from "../plugins/registry.js";
@@ -11,7 +11,6 @@ import {
import { testing as embeddedRunTesting } from "../../agents/embedded-agent-runner/runs.test-support.js";
import { clearRuntimeConfigSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/config.js";
import * as sessionTypesModule from "../../config/sessions.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import {
@@ -2014,87 +2013,6 @@ describe("runReplyAgent Active Memory inline debug", () => {
expect(traceText).toContain("show me\n\\~~~\nnot a fence");
expect(traceText).toContain("assistant\n\\~~~\nresponse");
});
it("does not reload the session store when verbose is disabled", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-inline-"));
const storePath = path.join(tmp, "sessions.json");
const sessionKey = "main";
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
await replaceSessionEntry({ storePath, sessionKey }, sessionEntry);
const loadSessionStoreSpy = vi.spyOn(sessionTypesModule, "loadSessionStore");
runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "Normal reply" }],
meta: {},
});
const typing = createMockTypingController();
const sessionCtx = {
Provider: "telegram",
OriginatingTo: "chat:1",
AccountId: "primary",
MessageSid: "msg",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
agentId: "main",
sessionId: "session",
sessionKey,
messageProvider: "telegram",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
const result = await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: sessionKey,
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
storePath,
defaultModel: "anthropic/claude-opus-4-6",
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
expect(loadSessionStoreSpy).not.toHaveBeenCalledWith(storePath, { skipCache: true });
expectReplyText(result, "Normal reply");
});
});
describe("runReplyAgent claude-cli routing", () => {
@@ -50,10 +50,6 @@ vi.mock("../../config/sessions/paths.js", () => ({
resolveSessionFilePathOptions: hoisted.resolveSessionFilePathOptionsMock,
}));
vi.mock("../../config/sessions/store.js", () => ({
loadSessionStore: hoisted.loadSessionStoreMock,
}));
vi.mock("../../config/sessions/session-accessor.js", () => {
const loadSessionEntry = (scope: { storePath?: string; sessionKey: string }) =>
(hoisted.loadSessionStoreMock(scope.storePath) as Record<string, unknown>)[scope.sessionKey];
+1 -1
View File
@@ -7,11 +7,11 @@ import {
clearSessionGoal,
createSessionGoal,
formatSessionGoalStatus,
getSessionEntry,
getSessionGoal,
updateSessionGoalObjective,
updateSessionGoalStatus,
} from "../../config/sessions.js";
import { loadSessionEntry as getSessionEntry } from "../../config/sessions/session-accessor.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
import { markCommandSessionMetadataChanged } from "./command-session-metadata.js";
import type {
@@ -20,10 +20,6 @@ vi.mock("../../agents/sandbox.js", () => ({
resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })),
}));
vi.mock("../../config/sessions/store.js", () => ({
updateSessionStore: vi.fn(async () => {}),
}));
vi.mock("../../infra/system-events.js", () => ({
enqueueSystemEvent: vi.fn(),
}));
+12 -13
View File
@@ -37,10 +37,9 @@ let resolveQueuedReplyExecutionConfigActual:
| undefined;
let createFollowupRunner: typeof import("./followup-runner.js").createFollowupRunner;
let clearRuntimeConfigSnapshot: typeof import("../../config/config.js").clearRuntimeConfigSnapshot;
let loadSessionStore: typeof import("../../config/sessions/store.js").loadSessionStore;
let saveSessionStore: typeof import("../../config/sessions/store.js").saveSessionStore;
let loadSessionEntry: typeof import("../../config/sessions/session-accessor.js").loadSessionEntry;
let replaceSessionEntrySync: typeof import("../../config/sessions/session-accessor.js").replaceSessionEntrySync;
let clearSessionStoreCacheForTest: typeof import("../../config/sessions/store.js").clearSessionStoreCacheForTest;
let clearSessionStoreCacheForTest: typeof import("../../config/sessions/store-writer-state.js").clearSessionStoreCacheForTest;
let clearFollowupQueue: typeof import("./queue/state.js").clearFollowupQueue;
let enqueueFollowupRun: typeof import("./queue.js").enqueueFollowupRun;
let sessionRunAccounting: typeof import("./session-run-accounting.js");
@@ -334,8 +333,7 @@ async function persistRunSessionUsageForFollowupTest(
return;
}
const registeredStore = FOLLOWUP_TEST_SESSION_STORES.get(storePath);
const store = registeredStore ?? loadSessionStore(storePath, { skipCache: true });
const entry = store[sessionKey];
const entry = registeredStore?.[sessionKey] ?? loadSessionEntry({ storePath, sessionKey });
if (!entry) {
return;
}
@@ -377,11 +375,11 @@ async function persistRunSessionUsageForFollowupTest(
if (params.cliSessionBinding && params.providerUsed && !preserveUserFacingRunState) {
setCliSessionBinding(nextEntry, params.providerUsed, params.cliSessionBinding);
}
store[sessionKey] = nextEntry;
if (registeredStore) {
registeredStore[sessionKey] = nextEntry;
return;
}
await saveSessionStore(storePath, store);
replaceSessionEntrySync({ storePath, sessionKey }, nextEntry);
}
async function loadFreshFollowupRunnerModuleForTest() {
@@ -532,9 +530,9 @@ async function loadFreshFollowupRunnerModuleForTest() {
({ createFollowupRunner } = await import("./followup-runner.js"));
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
await import("../../config/config.js"));
({ clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } =
await import("../../config/sessions/store.js"));
({ replaceSessionEntrySync } = await import("../../config/sessions/session-accessor.js"));
({ clearSessionStoreCacheForTest } = await import("../../config/sessions/store-writer-state.js"));
({ loadSessionEntry, replaceSessionEntrySync } =
await import("../../config/sessions/session-accessor.js"));
({ clearFollowupQueue } = await import("./queue/state.js"));
({ enqueueFollowupRun } = await import("./queue.js"));
sessionRunAccounting = await import("./session-run-accounting.js");
@@ -4690,9 +4688,10 @@ describe("createFollowupRunner compaction", () => {
if (registeredStore) {
registeredStore[params.sessionKey] = updatedEntry;
} else {
const store = loadSessionStore(params.storePath, { skipCache: true });
store[params.sessionKey] = updatedEntry;
await saveSessionStore(params.storePath, store);
replaceSessionEntrySync(
{ storePath: params.storePath, sessionKey: params.sessionKey },
updatedEntry,
);
}
}
}
@@ -2,7 +2,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store-writer-state.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { persistReplySessionEntry } from "./session-entry-persistence.js";
@@ -7,7 +7,7 @@ import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store-writer-state.js";
import type { ModelAliasIndex } from "./model-selection-directive.js";
const loadPreparedModelCatalog = vi.hoisted(() => vi.fn(async () => modelCatalog));
+29
View File
@@ -0,0 +1,29 @@
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { replaceSessionEntry } from "../config/sessions/session-accessor.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { resolveInboundSessionEnvelopeContext } from "./session-envelope.js";
describe("resolveInboundSessionEnvelopeContext", () => {
afterEach(() => closeOpenClawAgentDatabasesForTest());
it("reads the previous timestamp from SQLite without a sessions.json file", async () => {
await withTempDir({ prefix: "openclaw-session-envelope-" }, async (dir) => {
const storePath = path.join(dir, "sessions.json");
const sessionKey = "agent:main:telegram:dm:1";
await replaceSessionEntry(
{ agentId: "main", sessionKey, storePath },
{ sessionId: "session-1", updatedAt: 42 },
);
expect(
resolveInboundSessionEnvelopeContext({
cfg: { session: { store: storePath } },
agentId: "main",
sessionKey,
}),
).toMatchObject({ storePath, previousTimestamp: 42 });
});
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
// Session-envelope context resolver for inbound channel turns.
import { resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
import { resolveStorePath } from "../config/sessions.js";
import { readSessionUpdatedAt } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
/** Resolves envelope options and previous timestamp for one inbound channel session. */
+1 -1
View File
@@ -10,7 +10,7 @@ import {
loadSessionEntry,
replaceSessionEntry,
} from "../config/sessions/session-accessor.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+1 -1
View File
@@ -25,7 +25,7 @@ import {
replaceSessionEntry,
} from "../config/sessions/session-accessor.js";
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import type { InternalSessionEntry as SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { emitAgentEvent, onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js";
+1 -1
View File
@@ -10,7 +10,7 @@ import { ensureAgentWorkspace } from "../agents/workspace.js";
import { getRegistryWorktree } from "../agents/worktrees/registry.js";
import { managedWorktrees } from "../agents/worktrees/service.js";
import { upsertSqliteSessionEntry } from "../config/sessions/session-accessor.sqlite.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { testing as agentCommandTesting } from "./agent.js";
@@ -10,9 +10,9 @@ import {
resolveSessionFilePath,
type resolveSessionFilePathOptions,
} from "../config/sessions/paths.js";
import { updateSessionStore } from "../config/sessions/store.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { updateLegacySessionStore } from "../infra/state-migrations.legacy-session-store.js";
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { clearTuiLastSessionPointers } from "../tui/tui-last-session.js";
@@ -339,7 +339,7 @@ export async function repairHeartbeatPoisonedMainSession(params: {
return;
}
let movedEntry: SessionEntry | undefined;
await updateSessionStore(params.absoluteStorePath, (currentStore) => {
await updateLegacySessionStore(params.absoluteStorePath, (currentStore) => {
const currentEntry = currentStore[mainKey];
const currentCandidate = resolveHeartbeatMainSessionRepairCandidate({
entry: currentEntry,
@@ -3,11 +3,11 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
import { listAgentEntries, listAgentIds, resolveAgentConfig } from "../agents/agent-scope.js";
import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { loadSessionStore } from "../config/sessions/store-load.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveHeartbeatIntervalMs } from "../infra/heartbeat-summary.js";
import { resolveHeartbeatDeliveryTarget } from "../infra/outbound/targets.js";
import { loadLegacySessionStore } from "../infra/state-migrations.legacy-session-store.js";
import {
normalizeAgentId,
resolveAgentIdFromSessionKey,
@@ -121,7 +121,7 @@ export function describeHeartbeatSessionTargetIssues(cfg: OpenClawConfig): strin
}
const storeAgentId = resolvedAgentId;
const storePath = resolveStorePath(cfg.session?.store, { agentId: storeAgentId });
const store = loadSessionStore(storePath, { skipCache: true, clone: false });
const store = loadLegacySessionStore(storePath);
const entry = store[canonicalSession];
if (entry) {
continue;
+36 -52
View File
@@ -5,13 +5,13 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearSessionStoreCacheForTest,
saveSessionStore,
updateSessionStore,
} from "../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
saveLegacySessionStore as saveSessionStore,
updateLegacySessionStore as updateSessionStore,
} from "../infra/state-migrations.legacy-session-store.js";
import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../sessions/agent-harness-session-key.js";
import type { Skill } from "../skills/loading/skill-contract.js";
@@ -506,18 +506,14 @@ describe("doctor session snapshot stale runtime metadata", () => {
);
const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json");
const prompt = `${skillPrompt(stalePath)}\n${"padding\n".repeat(200)}`;
await saveSessionStore(
storePath,
{
"agent:main": sessionEntry({
skillsSnapshot: {
prompt,
skills: [{ name: "doctor" }],
},
}),
},
{ skipMaintenance: true },
);
await saveSessionStore(storePath, {
"agent:main": sessionEntry({
skillsSnapshot: {
prompt,
skills: [{ name: "doctor" }],
},
}),
});
const raw = await fs.readFile(storePath, "utf-8");
expect(raw).not.toContain(stalePath);
expect(raw).toContain("promptRef");
@@ -702,17 +698,13 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
await backupStarted;
const supervisedKey = "agent:main:harness:codex:supervision:concurrent";
const concurrentWrite = updateSessionStore(
storePath,
(store) => {
store[supervisedKey] = sessionEntry({
sessionId: "supervised-session",
agentHarnessId: "codex",
modelSelectionLocked: true,
});
},
{ requireWriteSuccess: true, skipMaintenance: true },
);
const concurrentWrite = updateSessionStore(storePath, (store) => {
store[supervisedKey] = sessionEntry({
sessionId: "supervised-session",
agentHarnessId: "codex",
modelSelectionLocked: true,
});
});
releaseBackup();
await Promise.all([repair, concurrentWrite]);
@@ -783,18 +775,14 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
);
const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json");
const prompt = `${skillPrompt(stalePath)}\n${"padding\n".repeat(200)}`;
await saveSessionStore(
storePath,
{
"agent:main": sessionEntry({
skillsSnapshot: {
prompt,
skills: [{ name: "doctor" }],
},
}),
},
{ skipMaintenance: true },
);
await saveSessionStore(storePath, {
"agent:main": sessionEntry({
skillsSnapshot: {
prompt,
skills: [{ name: "doctor" }],
},
}),
});
const rawBefore = await fs.readFile(storePath, "utf-8");
expect(rawBefore).toContain("promptRef");
@@ -943,18 +931,14 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
);
const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json");
const prompt = `${skillPrompt(stalePath)}\n${"padding\n".repeat(200)}`;
await saveSessionStore(
storePath,
{
"agent:main": sessionEntry({
skillsSnapshot: {
prompt,
skills: [{ name: "doctor" }],
},
}),
},
{ skipMaintenance: true },
);
await saveSessionStore(storePath, {
"agent:main": sessionEntry({
skillsSnapshot: {
prompt,
skills: [{ name: "doctor" }],
},
}),
});
const rawBefore = await fs.readFile(storePath, "utf-8");
expect(rawBefore).toContain("promptRef");
+2 -2
View File
@@ -5,7 +5,6 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { note } from "../../packages/terminal-core/src/note.js";
import { resolveStateDir } from "../config/paths.js";
import { hydrateSessionStoreSkillPromptRefs } from "../config/sessions/skill-prompt-blobs.js";
import { updateSessionStore } from "../config/sessions/store.js";
import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -13,6 +12,7 @@ import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.j
import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js";
import { writeTextAtomic } from "../infra/json-files.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { updateLegacySessionStore } from "../infra/state-migrations.legacy-session-store.js";
import { resolveBundledSkillsDir } from "../skills/loading/bundled-dir.js";
import { resolveConfigDir, shortenHomePath } from "../utils.js";
@@ -563,7 +563,7 @@ export async function noteSessionSnapshotHealth(params?: {
for (const [storePath, findings] of findingsByStore) {
try {
const repairResult = await updateSessionStore(
const repairResult = await updateLegacySessionStore(
storePath,
async (store) => {
const raw = fs.readFileSync(storePath, "utf-8");
+1 -1
View File
@@ -11,7 +11,6 @@ import {
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import { normalizeStoreSessionKey } from "../config/sessions/store-entry.js";
import { normalizeSessionEntryDelivery } from "../config/sessions/store-load.js";
import {
resolveAgentSessionStoreTargetsSync,
resolveAllAgentSessionStoreCandidateTargetsSync,
@@ -24,6 +23,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveStoredSessionOwnerAgentId } from "../gateway/session-store-key.js";
import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js";
import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js";
import { normalizeLegacySessionEntryDelivery as normalizeSessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { closeOpenClawAgentDatabaseByPath } from "../state/openclaw-agent-db.js";
import { compactDoctorSessionSqliteTarget } from "./doctor-session-sqlite-compact.js";
@@ -15,8 +15,8 @@ import {
} from "../agents/model-selection.js";
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
import type { SessionEntry } from "../config/sessions.js";
import { updateSessionStore } from "../config/sessions/store.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { updateLegacySessionStore } from "../infra/state-migrations.legacy-session-store.js";
import { listPluginDoctorSessionRouteStateOwners } from "../plugins/doctor-contract-registry.js";
import type { DoctorSessionRouteStateOwner } from "../plugins/doctor-session-route-state-owner-types.js";
import { isValidAgentHarnessSessionStoreEntry } from "../sessions/agent-harness-session-key.js";
@@ -528,7 +528,7 @@ export async function runPluginSessionStateDoctorRepairs(params: {
let repaired = 0;
const repairedAt = Date.now();
const repairsByKey = new Map(repairs.map((repair) => [repair.key, repair]));
await updateSessionStore(params.absoluteStorePath, (currentStore) => {
await updateLegacySessionStore(params.absoluteStorePath, (currentStore) => {
const currentMutableStore = currentStore as unknown as Record<
string,
Record<string, unknown>
+8 -7
View File
@@ -30,11 +30,13 @@ import {
resolveSessionTranscriptsDirForAgent,
resolveStorePath,
} from "../config/sessions/paths.js";
import { loadSessionStore } from "../config/sessions/store-load.js";
import { updateSessionStore } from "../config/sessions/store.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import {
loadLegacySessionStore,
updateLegacySessionStore,
} from "../infra/state-migrations.legacy-session-store.js";
import { resolveMemoryBackendConfig } from "../memory-host-sdk/engine-storage.js";
import { listConfiguredChannelIdsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -1277,10 +1279,9 @@ export async function noteStateIntegrity(
);
}
// Read-only diagnostic load: skip the cache and the defensive return clone so a
// very large monolithic sessions.json is materialized once, not several times.
// Re-cloning a multi-hundred-MB store here is what made `doctor` OOM (#56827).
const store = loadSessionStore(storePath, { skipCache: true, clone: false });
// The doctor importer is uncached and returns one mutable parse, avoiding the
// duplicate materialization that made large legacy stores OOM (#56827).
const store = loadLegacySessionStore(storePath);
const sessionPathOpts = resolveSessionFilePathOptions({ agentId, storePath });
const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object");
const canonicalEntryCount = await noteMainSessionRecoveryIntegrity({
@@ -1343,7 +1344,7 @@ export async function noteStateIntegrity(
if (repairWedged) {
let repaired = 0;
const repairedAt = Date.now();
await updateSessionStore(absoluteStorePath, (currentStore) => {
await updateLegacySessionStore(absoluteStorePath, (currentStore) => {
for (const [key] of wedgedSubagentSessions) {
const current = currentStore[key];
if (current && clearWedgedSubagentRecoveryAbort(current, repairedAt)) {
@@ -1,9 +1,12 @@
import fs from "node:fs";
import { normalizeOptionalLowercaseString as normalizeString } from "@openclaw/normalization-core/string-coerce";
import { loadSessionStore, updateSessionStore } from "../../../config/sessions/store.js";
import { resolveAllAgentSessionStoreTargetsSync } from "../../../config/sessions/targets.js";
import type { SessionEntry } from "../../../config/sessions/types.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import {
loadLegacySessionStore,
updateLegacySessionStore,
} from "../../../infra/state-migrations.legacy-session-store.js";
import { isValidAgentHarnessSessionStoreEntry } from "../../../sessions/agent-harness-session-key.js";
import {
isOpenAICodexAuthProfileRef,
@@ -294,7 +297,7 @@ export async function maybeRepairCodexSessionRoutes(params: {
if (!params.shouldRepair) {
const stale = targets.flatMap((target) => {
const sessionKeys = scanCodexSessionStoreRoutes(
loadSessionStore(target.storePath, { skipCache: true, clone: false }),
loadLegacySessionStore(target.storePath),
params.blockedModelIdentities,
);
return sessionKeys.map((sessionKey) => `${target.agentId}:${sessionKey}`);
@@ -320,13 +323,13 @@ export async function maybeRepairCodexSessionRoutes(params: {
let repairedSessions = 0;
for (const target of targets) {
const staleSessionKeys = scanCodexSessionStoreRoutes(
loadSessionStore(target.storePath, { skipCache: true, clone: false }),
loadLegacySessionStore(target.storePath),
params.blockedModelIdentities,
);
if (staleSessionKeys.length === 0) {
continue;
}
const result = await updateSessionStore(
const result = await updateLegacySessionStore(
target.storePath,
(store) =>
repairCodexSessionStoreRoutes({
-3
View File
@@ -69,9 +69,6 @@ async function loadFreshHealthModulesForTest() {
vi.doMock("../config/sessions/paths.js", () => ({
resolveStorePath: () => "/tmp/sessions.json",
}));
vi.doMock("../config/sessions/store.js", () => ({
loadSessionStore: () => testStore,
}));
vi.doMock("../config/sessions/session-accessor.js", () => ({
listSessionEntriesReadOnly: (scope?: { agentId?: string; storePath?: string }) => {
listHealthSessionEntriesCalls.push(scope ?? {});
+2 -14
View File
@@ -1,18 +1,6 @@
// Covers config cache utility invalidation and snapshot behavior.
// Covers synchronous config cache behavior.
import { describe, expect, it } from "vitest";
import { createExpiringMapCache, resolveCacheTtlMs } from "./cache-utils.js";
describe("resolveCacheTtlMs", () => {
it("accepts exact non-negative integers", () => {
expect(resolveCacheTtlMs({ envValue: "0", defaultTtlMs: 60_000 })).toBe(0);
expect(resolveCacheTtlMs({ envValue: "120000", defaultTtlMs: 60_000 })).toBe(120_000);
});
it("rejects malformed env values and falls back to the default", () => {
expect(resolveCacheTtlMs({ envValue: "0abc", defaultTtlMs: 60_000 })).toBe(60_000);
expect(resolveCacheTtlMs({ envValue: "15ms", defaultTtlMs: 60_000 })).toBe(60_000);
});
});
import { createExpiringMapCache } from "./cache-utils.js";
describe("createExpiringMapCache", () => {
it("expires entries on read after the TTL", () => {
+1 -38
View File
@@ -1,21 +1,4 @@
// Provides config cache helpers with filesystem freshness checks.
import fs from "node:fs";
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
/** Resolves a cache TTL from an env override, falling back unless the override is exact. */
export function resolveCacheTtlMs(params: {
envValue: string | undefined;
defaultTtlMs: number;
}): number {
const { envValue, defaultTtlMs } = params;
if (envValue) {
const parsed = parseStrictNonNegativeInteger(envValue);
if (parsed !== undefined) {
return parsed;
}
}
return defaultTtlMs;
}
// Provides small synchronous config cache helpers.
/** Returns whether a TTL keeps cache reads and writes active. */
export function isCacheEnabled(ttlMs: number): boolean {
@@ -148,23 +131,3 @@ export function createExpiringMapCache<TKey, TValue>(options: {
},
};
}
type FileStatSnapshot = {
ctimeNs: bigint;
mtimeNs: bigint;
sizeBytes: number;
};
/** Captures the file attributes used by cache invalidation without exposing fs.Stats. */
export function getFileStatSnapshot(filePath: string): FileStatSnapshot | undefined {
try {
const stats = fs.statSync(filePath, { bigint: true });
return {
ctimeNs: stats.ctimeNs,
mtimeNs: stats.mtimeNs,
sizeBytes: Number(stats.size),
};
} catch {
return undefined;
}
}
-768
View File
@@ -1,768 +0,0 @@
// Verifies session config cache invalidation and reload behavior.
import fs from "node:fs";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as jsonFiles from "../infra/json-files.js";
import { createCanonicalFixtureSkill } from "../skills/test-support/test-helpers.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import {
getSerializedSessionStorePromptRefs,
readSessionStoreCache,
setSerializedSessionStore,
setSerializedSessionStorePromptRefs,
writeSessionStoreCache,
} from "./sessions/store-cache.js";
import {
clearSessionStoreCacheForTest,
loadSessionStore,
saveSessionStore,
updateSessionStore,
patchSessionEntryWithKey,
} from "./sessions/store.js";
import type { SessionEntry } from "./sessions/types.js";
import type { SessionSkillPromptRef } from "./sessions/types.js";
function createSessionEntry(overrides: Partial<SessionEntry> = {}): SessionEntry {
return {
sessionId: "id-1",
updatedAt: Date.now(),
displayName: "Test Session 1",
...overrides,
};
}
function createSingleSessionStore(
entry: SessionEntry = createSessionEntry(),
key = "session:1",
): Record<string, SessionEntry> {
return { [key]: entry };
}
describe("Session Store Cache", () => {
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "session-cache-test-" });
let testDir: string;
let storePath: string;
beforeAll(async () => {
await suiteRootTracker.setup();
});
afterAll(async () => {
await suiteRootTracker.cleanup();
});
beforeEach(async () => {
testDir = await suiteRootTracker.make("case");
storePath = path.join(testDir, "sessions.json");
// Clear cache before each test
clearSessionStoreCacheForTest();
// Reset environment variable
delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS;
});
afterEach(() => {
clearSessionStoreCacheForTest();
delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS;
delete process.env.OPENCLAW_SESSION_SERIALIZED_CACHE_MAX_BYTES;
});
it("keeps serialized prompt refs on the serialized cache entry lifecycle", () => {
const promptRef: SessionSkillPromptRef = {
version: 1,
algorithm: "sha256",
hash: "a".repeat(64),
bytes: 123,
};
const refs = new Map([["session:1", promptRef]]);
setSerializedSessionStore("store:refs", "{}");
setSerializedSessionStorePromptRefs("store:refs", refs);
expect(getSerializedSessionStorePromptRefs("store:refs")).toBe(refs);
setSerializedSessionStore("store:refs", "{}");
expect(getSerializedSessionStorePromptRefs("store:refs")).toBeUndefined();
});
it("should load session store from disk on first call", async () => {
const testStore = createSingleSessionStore();
// Write test data
await saveSessionStore(storePath, testStore);
// Load it
const loaded = loadSessionStore(storePath);
expect(loaded).toEqual(testStore);
});
it("retries transient session store read failures", async () => {
const testStore = createSingleSessionStore();
await saveSessionStore(storePath, testStore);
clearSessionStoreCacheForTest();
const originalReadFileSync = fs.readFileSync.bind(fs);
let storeReads = 0;
const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation((file, ...args) => {
if (file === storePath) {
storeReads += 1;
if (storeReads === 1) {
throw Object.assign(
new Error("Unknown system error -11: Unknown system error -11, read"),
{ code: "EAGAIN", errno: -11 },
);
}
}
return originalReadFileSync(file, ...(args as [Parameters<typeof fs.readFileSync>[1]]));
});
try {
expect(loadSessionStore(storePath, { skipCache: true })).toEqual(testStore);
expect(storeReads).toBe(2);
} finally {
readSpy.mockRestore();
}
});
it("does not retry permanent session store read failures", () => {
clearSessionStoreCacheForTest();
const missingPath = path.join(testDir, "missing-sessions.json");
const readSpy = vi.spyOn(fs, "readFileSync");
try {
expect(loadSessionStore(missingPath, { skipCache: true })).toEqual({});
expect(readSpy).toHaveBeenCalledOnce();
} finally {
readSpy.mockRestore();
}
});
it("should serve freshly saved session stores from cache without disk reads", async () => {
const testStore = createSingleSessionStore();
await saveSessionStore(storePath, testStore);
const readSpy = vi.spyOn(fs, "readFileSync");
// First load - served from write-through cache
const loaded1 = loadSessionStore(storePath);
expect(loaded1).toEqual(testStore);
// Second load - should stay cached (still no disk read)
const loaded2 = loadSessionStore(storePath);
expect(loaded2).toEqual(testStore);
expect(readSpy).toHaveBeenCalledTimes(0);
readSpy.mockRestore();
});
it("should not allow cached session mutations to leak across loads", async () => {
const testStore = createSingleSessionStore(
createSessionEntry({
origin: { provider: "openai" },
skillsSnapshot: {
prompt: "skills",
skills: [{ name: "alpha" }],
},
}),
);
await saveSessionStore(storePath, testStore);
const loaded1 = loadSessionStore(storePath);
expectDefined(loaded1["session:1"], 'loaded1["session:1"] test invariant').origin = {
provider: "mutated",
};
for (const skill of expectDefined(loaded1["session:1"], "loaded session").skillsSnapshot
?.skills ?? []) {
skill.name = "mutated";
break;
}
const loaded2 = loadSessionStore(storePath);
expect(
expectDefined(loaded2["session:1"], 'loaded2["session:1"] test invariant').origin?.provider,
).toBe("openai");
expect(
expectDefined(loaded2["session:1"], 'loaded2["session:1"] test invariant').skillsSnapshot
?.skills?.[0]?.name,
).toBe("alpha");
});
it("honors explicit clone:false on cache hits", async () => {
const testStore = createSingleSessionStore(
createSessionEntry({
origin: { provider: "openai" },
}),
);
await saveSessionStore(storePath, testStore);
const parseSpy = vi.spyOn(JSON, "parse");
const loaded1 = loadSessionStore(storePath, { clone: false });
expect(parseSpy).not.toHaveBeenCalled();
expectDefined(loaded1["session:1"], 'loaded1["session:1"] test invariant').origin = {
provider: "mutated",
};
const loaded2 = loadSessionStore(storePath, { clone: false });
expect(loaded2).toBe(loaded1);
expect(
expectDefined(loaded2["session:1"], 'loaded2["session:1"] test invariant').origin?.provider,
).toBe("mutated");
expect(parseSpy).not.toHaveBeenCalled();
parseSpy.mockRestore();
});
it("keeps disk-loaded clone:false cache hits by reference", () => {
const testStore = createSingleSessionStore();
fs.writeFileSync(storePath, JSON.stringify(testStore), "utf8");
const loaded1 = loadSessionStore(storePath, { clone: false });
const loaded2 = loadSessionStore(storePath, { clone: false });
expect(loaded2["session:1"]).toBe(loaded1["session:1"]);
});
it("does not cache pre-migration or pre-normalization disk JSON", () => {
fs.writeFileSync(
storePath,
JSON.stringify({
"session:1": {
sessionId: "id-1",
updatedAt: Date.now(),
provider: "telegram",
room: "room-1",
modelProvider: " openai ",
model: " gpt-5.4 ",
},
}),
);
const loaded1 = loadSessionStore(storePath);
const entry1 = loaded1["session:1"] as SessionEntry & { provider?: string; room?: string };
expect(entry1.channel).toBe("telegram");
expect(entry1.groupChannel).toBe("room-1");
expect(entry1.provider).toBeUndefined();
expect(entry1.room).toBeUndefined();
expect(entry1.modelProvider).toBe("openai");
expect(entry1.model).toBe("gpt-5.4");
const loaded2 = loadSessionStore(storePath);
const entry2 = loaded2["session:1"] as SessionEntry & { provider?: string; room?: string };
expect(entry2.channel).toBe("telegram");
expect(entry2.groupChannel).toBe("room-1");
expect(entry2.provider).toBeUndefined();
expect(entry2.room).toBeUndefined();
expect(entry2.modelProvider).toBe("openai");
expect(entry2.model).toBe("gpt-5.4");
});
it("isolates cached session stores without structuredClone", async () => {
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
const testStore = createSingleSessionStore(
createSessionEntry({
origin: { provider: "openai" },
skillsSnapshot: {
prompt: "skills",
skills: [{ name: "alpha" }],
},
}),
);
await saveSessionStore(storePath, testStore);
const loaded1 = loadSessionStore(storePath);
expectDefined(loaded1["session:1"], 'loaded1["session:1"] test invariant').origin = {
provider: "mutated",
};
for (const skill of expectDefined(loaded1["session:1"], "loaded session").skillsSnapshot
?.skills ?? []) {
skill.name = "mutated";
break;
}
const loaded2 = loadSessionStore(storePath);
expect(
expectDefined(loaded2["session:1"], 'loaded2["session:1"] test invariant').origin?.provider,
).toBe("openai");
expect(
expectDefined(loaded2["session:1"], 'loaded2["session:1"] test invariant').skillsSnapshot
?.skills?.[0]?.name,
).toBe("alpha");
expect(structuredCloneSpy).not.toHaveBeenCalled();
structuredCloneSpy.mockRestore();
});
it("parses serialized stores only when cloning object-cache hits", () => {
const testStore = createSingleSessionStore(
createSessionEntry({
origin: { provider: "openai" },
}),
);
const serialized = JSON.stringify(testStore);
const parseSpy = vi.spyOn(JSON, "parse");
try {
writeSessionStoreCache({
storePath,
store: testStore,
serialized,
cloneSerialized: serialized,
});
expect(parseSpy).not.toHaveBeenCalled();
expectDefined(testStore["session:1"], 'testStore["session:1"] test invariant').origin = {
provider: "mutated",
};
const cached = readSessionStoreCache({ storePath });
expect(
expectDefined(cached?.["session:1"], 'cached?.["session:1"] test invariant').origin
?.provider,
).toBe("openai");
expect(parseSpy).toHaveBeenCalledOnce();
} finally {
parseSpy.mockRestore();
}
});
it("clones cached session records without invoking prototype setters", () => {
const testStore = JSON.parse(
`{"session:1":{"sessionId":"id-1","updatedAt":${Date.now()},"displayName":"Test Session 1","__proto__":{"polluted":true}}}`,
) as Record<string, SessionEntry>;
writeSessionStoreCache({ storePath, store: testStore });
const cached = readSessionStoreCache({ storePath });
const entry = cached?.["session:1"] as (SessionEntry & { polluted?: boolean }) | undefined;
expect(entry).toBeDefined();
if (!entry) {
throw new Error("Expected cached entry");
}
expect(entry?.polluted).toBeUndefined();
expect(Object.hasOwn(entry as object, "__proto__")).toBe(true);
expect(Object.prototype).not.toHaveProperty("polluted");
});
it("preserves own __proto__ plugin JSON fields without changing clone prototypes", () => {
const pluginState: { [key: string]: unknown } = { ok: true };
Object.defineProperty(pluginState, "__proto__", {
value: { polluted: true },
enumerable: true,
configurable: true,
writable: true,
});
const testStore = createSingleSessionStore(
createSessionEntry({
pluginExtensions: {
demo: {
pluginState: pluginState as never,
},
},
}),
);
writeSessionStoreCache({ storePath, store: testStore });
const cached = readSessionStoreCache({ storePath });
const cachedState = expectDefined(cached?.["session:1"], 'cached?.["session:1"] test invariant')
.pluginExtensions?.demo?.pluginState as Record<string, unknown> | undefined;
expect(cachedState).toBeTruthy();
expect(Object.hasOwn(cachedState ?? {}, "__proto__")).toBe(true);
expect(Object.getOwnPropertyDescriptor(cachedState, "__proto__")?.value).toEqual({
polluted: true,
});
expect(Object.getPrototypeOf(cachedState ?? {})).toBe(Object.prototype);
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it("clones disk-loaded stores from the raw serialized JSON", () => {
const testStore = createSingleSessionStore(
createSessionEntry({
origin: { provider: "openai" },
skillsSnapshot: {
prompt: "skills",
skills: [{ name: "alpha" }],
},
}),
);
const serialized = JSON.stringify(testStore);
fs.writeFileSync(storePath, serialized);
const stringifySpy = vi.spyOn(JSON, "stringify");
const loaded = loadSessionStore(storePath, { skipCache: true });
expect(loaded).toEqual(testStore);
expect(stringifySpy).not.toHaveBeenCalled();
expectDefined(loaded["session:1"], 'loaded["session:1"] test invariant').origin = {
provider: "mutated",
};
for (const skill of expectDefined(loaded["session:1"], "loaded session").skillsSnapshot
?.skills ?? []) {
skill.name = "mutated";
break;
}
const reloaded = loadSessionStore(storePath, { skipCache: true });
expect(
expectDefined(reloaded["session:1"], 'reloaded["session:1"] test invariant').origin?.provider,
).toBe("openai");
expect(
expectDefined(reloaded["session:1"], 'reloaded["session:1"] test invariant').skillsSnapshot
?.skills?.[0]?.name,
).toBe("alpha");
stringifySpy.mockRestore();
});
it("keeps whole-store update results detached from the mutable cache by default", async () => {
await saveSessionStore(storePath, createSingleSessionStore());
const persisted = await updateSessionStore(
storePath,
(store) => {
const next = {
...expectDefined(store["session:1"], 'store["session:1"] test invariant'),
displayName: "Updated Session",
updatedAt: Date.now() + 1,
};
store["session:1"] = next;
return next;
},
{ skipMaintenance: true },
);
persisted.displayName = "Mutated after write";
const cached = loadSessionStore(storePath, { clone: false });
expect(cached["session:1"]).not.toBe(persisted);
expect(
expectDefined(cached["session:1"], 'cached["session:1"] test invariant').displayName,
).toBe("Updated Session");
});
it("can publish writer-owned session updates directly into the object cache", async () => {
await saveSessionStore(storePath, createSingleSessionStore());
const persisted = await updateSessionStore(
storePath,
(store) => {
const next = {
...expectDefined(store["session:1"], 'store["session:1"] test invariant'),
displayName: "Writer owned",
updatedAt: Date.now() + 1,
};
store["session:1"] = next;
return next;
},
{ takeCacheOwnership: true },
);
const cached = loadSessionStore(storePath, { clone: false });
expect(cached["session:1"]).toBe(persisted);
expect(
expectDefined(cached["session:1"], 'cached["session:1"] test invariant').displayName,
).toBe("Writer owned");
});
it("can publish writer-owned entry patches directly into the object cache", async () => {
await saveSessionStore(storePath, createSingleSessionStore());
const persisted = await patchSessionEntryWithKey({
storePath,
sessionKey: "session:1",
takeCacheOwnership: true,
update: async () => ({
displayName: "Entry writer owned",
updatedAt: Date.now() + 1,
}),
});
const cached = loadSessionStore(storePath, { clone: false });
expect(cached["session:1"]).toBe(persisted?.entry);
expect(
expectDefined(cached["session:1"], 'cached["session:1"] test invariant').displayName,
).toBe("Entry writer owned");
});
it("publishes high-level entry patches without cloning the whole object cache", async () => {
await saveSessionStore(storePath, {
"session:1": createSessionEntry({ sessionId: "id-1" }),
"session:2": createSessionEntry({ sessionId: "id-2" }),
});
const before = loadSessionStore(storePath, { clone: false });
const untouched = before["session:2"];
const persisted = await patchSessionEntryWithKey({
storePath,
sessionKey: "session:1",
update: async () => ({
displayName: "Entry writer owned by default",
updatedAt: Date.now() + 1,
}),
});
const cached = loadSessionStore(storePath, { clone: false });
expect(cached["session:2"]).toBe(untouched);
expect(cached["session:1"]).not.toBe(persisted?.entry);
persisted!.entry.displayName = "Mutated returned entry";
expect(
expectDefined(cached["session:1"], 'cached["session:1"] test invariant').displayName,
).toBe("Entry writer owned by default");
});
it("detaches caller-owned patch objects before publishing writer-owned caches", async () => {
await saveSessionStore(storePath, {
"session:1": createSessionEntry({ sessionId: "id-1" }),
"session:2": createSessionEntry({ sessionId: "id-2" }),
});
const before = loadSessionStore(storePath, { clone: false });
const untouched = before["session:2"];
const deliveryContext = { channel: "telegram", to: "chat-1" };
await patchSessionEntryWithKey({
storePath,
sessionKey: "session:1",
update: async () => ({ deliveryContext }),
});
deliveryContext.to = "mutated-after-persist";
const cached = loadSessionStore(storePath, { clone: false });
expect(cached["session:2"]).toBe(untouched);
expect(
expectDefined(cached["session:1"], 'cached["session:1"] test invariant').deliveryContext?.to,
).toBe("chat-1");
});
it("falls back to full projection when untouched entries need prompt blob repair", async () => {
const prompt = "skill prompt ".repeat(80);
await saveSessionStore(storePath, {
"session:1": createSessionEntry({ sessionId: "id-1", displayName: "Before" }),
"session:2": createSessionEntry({
sessionId: "id-2",
skillsSnapshot: {
prompt,
skills: [{ name: "alpha" }],
},
}),
});
const cached = loadSessionStore(storePath, { clone: false });
expect(
expectDefined(cached["session:2"], 'cached["session:2"] test invariant').skillsSnapshot
?.prompt,
).toBe(prompt);
await fs.promises.rm(path.join(testDir, "skills-prompts"), {
recursive: true,
force: true,
});
await patchSessionEntryWithKey({
storePath,
sessionKey: "session:1",
update: async () => ({ displayName: "After" }),
takeCacheOwnership: true,
});
clearSessionStoreCacheForTest();
const loaded = loadSessionStore(storePath);
expect(
expectDefined(loaded["session:1"], 'loaded["session:1"] test invariant').displayName,
).toBe("After");
expect(
expectDefined(loaded["session:2"], 'loaded["session:2"] test invariant').skillsSnapshot
?.prompt,
).toBe(prompt);
});
it("serializes the normalized entry when applying the one-entry fast path", async () => {
await saveSessionStore(storePath, {
"session:1": createSessionEntry({ sessionId: "id-1", displayName: "Before" }),
"session:2": createSessionEntry({ sessionId: "id-2", displayName: "Untouched" }),
});
await patchSessionEntryWithKey({
storePath,
sessionKey: "session:1",
update: async () => ({
displayName: "After",
skillsSnapshot: {
prompt: "short prompt",
skills: [{ name: "alpha" }],
resolvedSkills: [
createCanonicalFixtureSkill({
name: "alpha",
description: "alpha skill",
filePath: "/skills/alpha/SKILL.md",
baseDir: "/skills/alpha",
source: "transient",
}),
],
} as SessionEntry["skillsSnapshot"],
}),
takeCacheOwnership: true,
});
const disk = JSON.parse(fs.readFileSync(storePath, "utf8")) as Record<string, SessionEntry>;
expect(expectDefined(disk["session:1"], 'disk["session:1"] test invariant').displayName).toBe(
"After",
);
expect(
expectDefined(disk["session:1"], 'disk["session:1"] test invariant').skillsSnapshot?.prompt,
).toBe("short prompt");
expect(
"resolvedSkills" in
(expectDefined(disk["session:1"], 'disk["session:1"] test invariant').skillsSnapshot ?? {}),
).toBe(false);
});
it("restores the writer-owned cache when update result proves the store unchanged", async () => {
await saveSessionStore(storePath, {
"session:1": createSessionEntry({ sessionId: "id-1" }),
"session:2": createSessionEntry({ sessionId: "id-2" }),
});
const before = loadSessionStore(storePath, { clone: false });
const writeSpy = vi.spyOn(jsonFiles, "writeTextAtomic");
const result = await updateSessionStore(storePath, () => 0, {
skipSaveWhenResult: (cleared) => cleared === 0,
});
const after = loadSessionStore(storePath, { clone: false });
expect(result).toBe(0);
expect(writeSpy).not.toHaveBeenCalled();
expect(after).toBe(before);
});
it("should refresh cache when store file changes on disk", async () => {
const testStore = createSingleSessionStore();
await saveSessionStore(storePath, testStore);
// First load - from disk
const loaded1 = loadSessionStore(storePath);
expect(loaded1).toEqual(testStore);
// Modify file on disk while cache is valid
const modifiedStore: Record<string, SessionEntry> = {
"session:99": { sessionId: "id-99", updatedAt: Date.now() },
};
fs.writeFileSync(storePath, JSON.stringify(modifiedStore, null, 2));
const bump = new Date(Date.now() + 2000);
fs.utimesSync(storePath, bump, bump);
// Second load - should return the updated store
const loaded2 = loadSessionStore(storePath);
expect(loaded2).toEqual(modifiedStore);
});
it("should invalidate cache on write", async () => {
const testStore = createSingleSessionStore();
await saveSessionStore(storePath, testStore);
// Load - should cache
const loaded1 = loadSessionStore(storePath);
expect(loaded1).toEqual(testStore);
// Update store
const updatedStore: Record<string, SessionEntry> = {
"session:1": {
...expectDefined(testStore["session:1"], 'testStore["session:1"] test invariant'),
displayName: "Updated Session 1",
},
};
// Save - should invalidate cache
await saveSessionStore(storePath, updatedStore);
// Load again - should get new data from disk
const loaded2 = loadSessionStore(storePath);
expect(
expectDefined(loaded2["session:1"], 'loaded2["session:1"] test invariant').displayName,
).toBe("Updated Session 1");
});
it("should respect OPENCLAW_SESSION_CACHE_TTL_MS=0 to disable cache", async () => {
process.env.OPENCLAW_SESSION_CACHE_TTL_MS = "0";
clearSessionStoreCacheForTest();
const testStore = createSingleSessionStore();
await saveSessionStore(storePath, testStore);
// First load
const loaded1 = loadSessionStore(storePath);
expect(loaded1).toEqual(testStore);
// Modify file on disk
const modifiedStore = createSingleSessionStore(
createSessionEntry({ sessionId: "id-2", displayName: "Test Session 2" }),
"session:2",
);
fs.writeFileSync(storePath, JSON.stringify(modifiedStore, null, 2));
// Second load - should read from disk (cache disabled)
const loaded2 = loadSessionStore(storePath);
expect(loaded2).toEqual(modifiedStore); // Should be modified, not cached
});
it("should handle non-existent store gracefully", () => {
const nonExistentPath = path.join(testDir, "non-existent.json");
// Should return empty store
const loaded = loadSessionStore(nonExistentPath);
expect(loaded).toStrictEqual({});
});
it("should handle invalid JSON gracefully", () => {
// Write invalid JSON
fs.writeFileSync(storePath, "not valid json {");
// Should return empty store
const loaded = loadSessionStore(storePath);
expect(loaded).toStrictEqual({});
});
it("should refresh cache when file is rewritten within the same mtime tick", async () => {
// This reproduces the CI flake where fast test writes complete within the
// same mtime granularity (typically 1s on HFS+/ext4), so mtime-only
// invalidation returns stale cached data.
const store1: Record<string, SessionEntry> = {
"session:1": createSessionEntry({ sessionId: "id-1", displayName: "Original" }),
};
await saveSessionStore(storePath, store1);
// Warm the cache
const loaded1 = loadSessionStore(storePath);
expect(
expectDefined(loaded1["session:1"], 'loaded1["session:1"] test invariant').displayName,
).toBe("Original");
// Rewrite the file directly (bypassing saveSessionStore's write-through
// cache) with different content but preserve the same mtime so only size
// changes.
const store2: Record<string, SessionEntry> = {
"session:1": createSessionEntry({ sessionId: "id-1", displayName: "Original" }),
"session:2": createSessionEntry({ sessionId: "id-2", displayName: "Added" }),
};
const preWriteStat = fs.statSync(storePath);
const json2 = JSON.stringify(store2, null, 2);
fs.writeFileSync(storePath, json2);
// Force mtime to match the cached value so only size differs
fs.utimesSync(storePath, preWriteStat.atime, preWriteStat.mtime);
// The cache should detect the size change and reload from disk
const loaded2 = loadSessionStore(storePath);
expect(loaded2["session:2"]?.displayName).toBe("Added");
});
});
+2 -221
View File
@@ -1,20 +1,17 @@
// Covers session config persistence and compatibility behavior.
import fsSync from "node:fs";
// Covers session config path and compatibility behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import {
buildGroupDisplayName,
deriveSessionKey,
loadSessionStore,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionKey,
resolveSessionTranscriptPath,
resolveSessionTranscriptsDir,
updateSessionStore,
} from "./sessions.js";
describe("sessions", () => {
@@ -38,16 +35,6 @@ describe("sessions", () => {
const withStateDir = <T>(stateDir: string, fn: () => T): T =>
withEnv({ OPENCLAW_STATE_DIR: stateDir }, fn);
async function createSessionStoreFixture(params: {
prefix: string;
entries: Record<string, Record<string, unknown>>;
}): Promise<{ storePath: string }> {
const dir = await createCaseDir(params.prefix);
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, JSON.stringify(params.entries), "utf-8");
return { storePath };
}
function expectedBot1FallbackSessionPath() {
return path.join(
path.resolve("/different/state"),
@@ -206,128 +193,6 @@ describe("sessions", () => {
});
}
it("updateSessionStore preserves concurrent additions", async () => {
const dir = await createCaseDir("updateSessionStore");
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, "{}", "utf-8");
await Promise.all([
updateSessionStore(storePath, (store) => {
store["agent:main:one"] = { sessionId: "sess-1", updatedAt: Date.now() };
}),
updateSessionStore(storePath, (store) => {
store["agent:main:two"] = { sessionId: "sess-2", updatedAt: Date.now() };
}),
]);
const store = loadSessionStore(storePath);
expect(store["agent:main:one"]?.sessionId).toBe("sess-1");
expect(store["agent:main:two"]?.sessionId).toBe("sess-2");
});
it("recovers from array-backed session stores", async () => {
const dir = await createCaseDir("updateSessionStore");
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, "[]", "utf-8");
await updateSessionStore(storePath, (store) => {
store["agent:main:main"] = { sessionId: "sess-1", updatedAt: Date.now() };
});
const store = loadSessionStore(storePath);
expect(store["agent:main:main"]?.sessionId).toBe("sess-1");
const raw = await fs.readFile(storePath, "utf-8");
expect(raw.trim().startsWith("{")).toBe(true);
});
it("normalizes last route fields on write", async () => {
const dir = await createCaseDir("updateSessionStore");
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, "{}", "utf-8");
await updateSessionStore(storePath, (store) => {
store["agent:main:main"] = {
sessionId: "sess-normalized",
updatedAt: Date.now(),
lastChannel: " Demo Chat ",
lastTo: " +1555 ",
lastAccountId: " acct-1 ",
};
});
const store = loadSessionStore(storePath);
expect(store["agent:main:main"]?.lastChannel).toBe("demo chat");
expect(store["agent:main:main"]?.lastTo).toBe("+1555");
expect(store["agent:main:main"]?.lastAccountId).toBe("acct-1");
expect(store["agent:main:main"]?.deliveryContext).toEqual({
channel: "demo chat",
to: "+1555",
accountId: "acct-1",
});
});
it("updateSessionStore keeps deletions when concurrent writes happen", async () => {
const dir = await createCaseDir("updateSessionStore");
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:old": { sessionId: "sess-old", updatedAt: Date.now() },
"agent:main:keep": { sessionId: "sess-keep", updatedAt: Date.now() },
},
null,
2,
),
"utf-8",
);
await Promise.all([
updateSessionStore(storePath, (store) => {
delete store["agent:main:old"];
}),
updateSessionStore(storePath, (store) => {
store["agent:main:new"] = { sessionId: "sess-new", updatedAt: Date.now() };
}),
]);
const store = loadSessionStore(storePath);
expect(store["agent:main:old"]).toBeUndefined();
expect(store["agent:main:keep"]?.sessionId).toBe("sess-keep");
expect(store["agent:main:new"]?.sessionId).toBe("sess-new");
});
it("loadSessionStore auto-migrates legacy provider keys to channel keys", async () => {
const mainSessionKey = "agent:main:main";
const dir = await createCaseDir("loadSessionStore");
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
[mainSessionKey]: {
sessionId: "sess-legacy",
updatedAt: 123,
provider: "slack",
lastProvider: "telegram",
lastTo: "user:U123",
},
},
null,
2,
),
"utf-8",
);
const store = loadSessionStore(storePath) as unknown as Record<string, Record<string, unknown>>;
const entry = store[mainSessionKey] ?? {};
expect(entry.channel).toBe("slack");
expect(entry.provider).toBeUndefined();
expect(entry.lastChannel).toBe("telegram");
expect(entry.lastProvider).toBeUndefined();
});
it("derives session transcripts dir from OPENCLAW_STATE_DIR", () => {
const dir = resolveSessionTranscriptsDir(
{ OPENCLAW_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv,
@@ -456,88 +321,4 @@ describe("sessions", () => {
await normalizePathForComparison(expectedPath),
);
});
it("updateSessionStore uses the writer-owned mutable cache without disk read", async () => {
const mainSessionKey = "agent:main:main";
const { storePath } = await createSessionStoreFixture({
prefix: "updateSessionStore-mutable-cache",
entries: {
[mainSessionKey]: {
sessionId: "sess-1",
updatedAt: 123,
thinkingLevel: "low",
},
},
});
expect(loadSessionStore(storePath)[mainSessionKey]?.thinkingLevel).toBe("low");
const readSpy = vi.spyOn(fsSync, "readFileSync");
try {
await updateSessionStore(
storePath,
(store) => {
const existing = store[mainSessionKey];
if (!existing) {
throw new Error("missing session entry");
}
store[mainSessionKey] = {
...existing,
thinkingLevel: "high",
};
},
{ skipMaintenance: true },
);
expect(readSpy).not.toHaveBeenCalled();
} finally {
readSpy.mockRestore();
}
const store = loadSessionStore(storePath, { skipCache: true });
expect(store[mainSessionKey]?.thinkingLevel).toBe("high");
});
it("updateSessionStore drops a borrowed cache entry when a mutator throws", async () => {
const mainSessionKey = "agent:main:main";
const { storePath } = await createSessionStoreFixture({
prefix: "updateSessionStore-mutable-cache-throw",
entries: {
[mainSessionKey]: {
sessionId: "sess-1",
updatedAt: 123,
thinkingLevel: "low",
},
},
});
expect(loadSessionStore(storePath)[mainSessionKey]?.thinkingLevel).toBe("low");
await expect(
updateSessionStore(
storePath,
(store) => {
const existing = store[mainSessionKey];
if (!existing) {
throw new Error("missing session entry");
}
store[mainSessionKey] = {
...existing,
thinkingLevel: "mutated-before-throw",
};
throw new Error("boom");
},
{ skipMaintenance: true },
),
).rejects.toThrow("boom");
const readSpy = vi.spyOn(fsSync, "readFileSync");
try {
const store = loadSessionStore(storePath);
expect(readSpy).toHaveBeenCalled();
expect(store[mainSessionKey]?.thinkingLevel).toBe("low");
} finally {
readSpy.mockRestore();
}
});
});
+1 -1
View File
@@ -19,7 +19,7 @@ export {
resolveSessionEntryCandidateTarget,
} from "./sessions/session-accessor.js";
export * from "./sessions/session-key.js";
export * from "./sessions/store.js";
export { resolveSessionStoreEntry } from "./sessions/store-entry.js";
export * from "./sessions/types.js";
export * from "./sessions/transcript.js";
export * from "./sessions/session-file.js";
+3 -4
View File
@@ -27,7 +27,6 @@ import {
inspectSqliteSessionHistoryDiskBudget,
} from "./session-history-eviction.js";
import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js";
import { cloneSessionStoreRecord } from "./store-cache.js";
import { collectSessionMaintenancePreserveKeysForStore } from "./store-maintenance-preserve.js";
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
import {
@@ -355,7 +354,7 @@ async function previewStoreCleanup(params: {
createIfMissing: !params.dryRun,
});
// Preview always mutates a clone so dry-run output can report exact counts without touching disk.
const previewStore = cloneSessionStoreRecord(beforeStore);
const previewStore = structuredClone(beforeStore);
const staleKeys = new Set<string>();
const cappedKeys = new Set<string>();
const missingKeys = new Set<string>();
@@ -547,7 +546,7 @@ export async function runSessionsCleanup(params: {
onPruned: (sessionKey, entry) => {
missingRemovals.push({
sessionKey,
expectedEntry: cloneSessionStoreRecord({ entry }).entry,
expectedEntry: structuredClone(entry),
});
},
});
@@ -561,7 +560,7 @@ export async function runSessionsCleanup(params: {
onRetired: (sessionKey, entry) => {
dmScopeRetiredRemovals.push({
sessionKey,
expectedEntry: cloneSessionStoreRecord({ entry }).entry,
expectedEntry: structuredClone(entry),
archiveRemovedTranscript: true,
});
},
+1 -1
View File
@@ -4,6 +4,7 @@ import type { PathLike, StatOptions } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { saveLegacySessionStore as saveSessionStore } from "../../infra/state-migrations.legacy-session-store.js";
import { withTempDir } from "../../test-helpers/temp-dir.js";
import {
resolveTrajectoryFilePath,
@@ -16,7 +17,6 @@ import {
pruneUnreferencedSessionArtifacts,
} from "./disk-budget.js";
import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js";
import { saveSessionStore } from "./store.js";
import type { SessionEntry } from "./types.js";
async function expectPathExists(targetPath: string): Promise<void> {
@@ -10,6 +10,7 @@ import {
patchSessionEntry,
resolveSessionEntryFromStore,
} from "./session-accessor.entry.js";
import type { SessionLifecycleStoreTarget } from "./session-accessor.lifecycle-types.js";
import { applySessionEntryLifecycleMutation } from "./session-accessor.lifecycle.js";
import {
appendSqliteTranscriptEvent,
@@ -43,15 +44,31 @@ import {
normalizeTargetStoreKeys,
resolveFreshestTargetEntry,
} from "./session-entry-selection.js";
import { projectSessionStoreForPersistence } from "./skill-prompt-blobs.js";
import { formatSqliteSessionFileMarker } from "./sqlite-marker.js";
import { normalizeStoreSessionKey } from "./store-entry.js";
import {
projectSessionEntryForPersistenceRevision,
type SessionLifecycleStoreTarget,
} from "./store.js";
import { createSessionTranscriptHeader } from "./transcript-header.js";
import type { GroupKeyResolution, SessionEntry } from "./types.js";
function projectSessionEntryForPersistenceRevision(params: {
storePath: string;
entry: SessionEntry;
}): SessionEntry {
const snapshot = params.entry.skillsSnapshot;
const stripped =
snapshot?.resolvedSkills === undefined
? params.entry
: {
...params.entry,
skillsSnapshot: (({ resolvedSkills: _drop, ...rest }) => rest)(snapshot),
};
const projected = projectSessionStoreForPersistence({
storePath: params.storePath,
store: { entry: stripped },
});
return projected.store.entry ?? stripped;
}
export async function forkSessionFromParentTranscript(
params: ForkSessionFromParentTranscriptParams,
): Promise<ForkSessionFromParentTranscriptResult> {
@@ -41,8 +41,7 @@ import type {
SessionEntryTargetPatchScope,
} from "./session-accessor.types.js";
import { resolveSessionStorePathForScope } from "./session-store-path.js";
import { normalizeStoreSessionKey } from "./store-entry.js";
import { resolveSessionStoreEntry } from "./store.js";
import { normalizeStoreSessionKey, resolveSessionStoreEntry } from "./store-entry.js";
import { resolveAllAgentSessionStoreTargetsSync, type SessionStoreTarget } from "./targets.js";
import type { SessionEntry } from "./types.js";
@@ -0,0 +1,101 @@
import type { OpenClawConfig } from "../types.openclaw.js";
import type { SessionUnreferencedArtifactSweepResult } from "./disk-budget.js";
import type { SessionResetBoundaryReason } from "./session-reset-boundary-event.js";
import type { SessionMaintenanceApplyReport } from "./store-maintenance-operations.js";
import type { SessionEntry } from "./types.js";
export type SessionLifecycleArtifactCleanupParams = {
agentId?: string;
storePath: string;
archiveRemovedEntryTranscripts?: boolean;
sessionKeySegmentPrefix: string;
transcriptContentMarker: string;
orphanTranscriptMinAgeMs: number;
nowMs?: number;
};
export type SessionLifecycleArtifactCleanupResult = {
removedEntries: number;
archivedTranscriptArtifacts: number;
};
export type SessionLifecycleStoreTarget = {
canonicalKey: string;
storeKeys: string[];
};
export type SessionLifecycleArchivedTranscript = {
sourcePath: string;
archivedPath: string;
};
export type ResetSessionEntryLifecycleResult = {
archivedTranscripts: SessionLifecycleArchivedTranscript[];
previousEntry?: SessionEntry;
previousSessionFile?: string;
previousSessionId?: string;
nextEntry: SessionEntry;
};
export type ResetSessionEntryLifecycleMutation = Omit<
ResetSessionEntryLifecycleResult,
"archivedTranscripts"
>;
export type DeleteSessionEntryLifecycleResult = {
archivedTranscripts: SessionLifecycleArchivedTranscript[];
deleted: boolean;
expectedEntryMismatch?: true;
deletedEntry?: SessionEntry;
deletedSessionFile?: string;
deletedSessionId?: string;
};
export type SessionEntryLifecycleRemoval = {
sessionKey: string;
expectedEntry?: SessionEntry;
archiveRemovedTranscript?: boolean;
expectedSessionId?: string;
expectedLifecycleRevision?: string;
expectedUpdatedAt?: number;
};
export type SessionEntryLifecycleUpsert = {
sessionKey: string;
resetBoundaryReason?: SessionResetBoundaryReason;
} & (
| {
entry: SessionEntry;
buildEntry?: never;
}
| {
buildEntry: (context: {
currentEntry?: SessionEntry;
sessionKey: string;
store: Record<string, SessionEntry>;
}) => Promise<SessionEntry | null | undefined> | SessionEntry | null | undefined;
entry?: never;
}
);
export type SessionArchivedTranscriptCleanupRule = {
reason: "deleted" | "reset";
olderThanMs: number;
};
export type SessionEntryLifecycleMutationResult = {
removedEntries: number;
removedSessionKeys: string[];
archivedTranscriptDirectories: string[];
unreferencedArtifacts: SessionUnreferencedArtifactSweepResult | null;
maintenanceReport: SessionMaintenanceApplyReport | null;
afterCount: number;
artifactCleanupError?: unknown;
};
export type DeletedAgentSessionEntryPurgeParams = {
cfg: OpenClawConfig;
agentId: string;
storeAgentId: string;
storePath: string;
};
@@ -16,6 +16,17 @@ import {
replaceSessionEntry,
patchSessionEntry,
} from "./session-accessor.entry.js";
import type {
DeleteSessionEntryLifecycleResult,
ResetSessionEntryLifecycleResult,
DeletedAgentSessionEntryPurgeParams,
SessionArchivedTranscriptCleanupRule,
SessionEntryLifecycleMutationResult,
SessionEntryLifecycleRemoval,
SessionEntryLifecycleUpsert,
SessionLifecycleArtifactCleanupParams,
SessionLifecycleArtifactCleanupResult,
} from "./session-accessor.lifecycle-types.js";
import {
applySqliteSessionEntryLifecycleMutation,
applySqliteSessionEntryReplacements,
@@ -48,17 +59,6 @@ import type {
} from "./session-accessor.types.js";
import { resolveProjectionExistingEntry } from "./session-entry-selection.js";
import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js";
import type {
DeleteSessionEntryLifecycleResult,
ResetSessionEntryLifecycleResult,
DeletedAgentSessionEntryPurgeParams,
SessionArchivedTranscriptCleanupRule,
SessionEntryLifecycleMutationResult,
SessionEntryLifecycleRemoval,
SessionEntryLifecycleUpsert,
SessionLifecycleArtifactCleanupParams,
SessionLifecycleArtifactCleanupResult,
} from "./store.js";
import type { SessionCompactionCheckpoint, SessionEntry } from "./types.js";
type TemporarySessionMappingSnapshot =
@@ -11,6 +11,7 @@ import {
listSessionEntriesReadOnly,
resolveSessionEntryFromStore,
} from "./session-accessor.entry.js";
import type { SessionEntryLifecycleUpsert } from "./session-accessor.lifecycle-types.js";
import { applySessionEntryLifecycleMutation } from "./session-accessor.lifecycle.js";
import type {
SessionLifecycleTranscriptInfo,
@@ -23,7 +24,6 @@ import type {
ResolvedSessionMaintenanceConfig,
SessionMaintenanceWarning,
} from "./store-maintenance.js";
import type { SessionEntryLifecycleUpsert } from "./store.js";
import type { SessionEntry } from "./types.js";
type SessionEntryRetirement = {
@@ -1,6 +1,5 @@
import type { SessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import type { OpenClawConfig } from "../types.openclaw.js";
import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js";
import type {
DeletedAgentSessionEntryPurgeParams,
DeleteSessionEntryLifecycleResult,
@@ -13,7 +12,8 @@ import type {
SessionLifecycleArtifactCleanupParams,
SessionLifecycleArtifactCleanupResult,
SessionLifecycleStoreTarget,
} from "./store.js";
} from "./session-accessor.lifecycle-types.js";
import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js";
import type { SessionEntry } from "./types.js";
export type SessionAccessScope = {
@@ -13,6 +13,7 @@ import {
runOpenClawAgentWriteTransaction,
type OpenClawAgentDatabase,
} from "../../state/openclaw-agent-db.js";
import type { ResetSessionEntryLifecycleMutation } from "./session-accessor.lifecycle-types.js";
import { materializeSqliteSessionStateDeletePlans } from "./session-accessor.sqlite-archive.js";
import type {
SessionLifecycleArchivedTranscript,
@@ -57,7 +58,6 @@ import {
kickSessionHistoryDiskBudgetMaintenance,
} from "./session-history-eviction.js";
import { buildSessionResetBoundaryPlan } from "./session-reset-boundary-event.js";
import type { ResetSessionEntryLifecycleMutation } from "./store.js";
import type { SessionEntry } from "./types.js";
// Single-target lifecycle owner: cleanup, reset, guarded delete, and trusted rollback.
@@ -9,6 +9,7 @@ import {
openOpenClawAgentDatabase,
runOpenClawAgentWriteTransaction,
} from "../../state/openclaw-agent-db.js";
import type { SessionArchivedTranscriptCleanupRule } from "./session-accessor.lifecycle-types.js";
import {
materializeSqliteSessionStateDeletePlans,
type MaterializedSqliteSessionStateDeletePlan,
@@ -65,7 +66,6 @@ import { readSqliteSessionEntriesByStatus } from "./session-accessor.sqlite-stat
import { appendTranscriptEventsInTransaction } from "./session-accessor.sqlite-transcript-store.js";
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js";
import type { SessionArchivedTranscriptCleanupRule } from "./store.js";
import type { SessionEntry } from "./types.js";
// Bulk mutation owner. Detached callbacks prepare first; one validated transaction commits the projection.
+8 -12
View File
@@ -1,10 +1,5 @@
import type { SessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import type { OpenClawConfig } from "../types.openclaw.js";
import type {
SessionTranscriptTurnExpectedState,
SessionTranscriptTurnLifecyclePatch,
} from "./session-transcript-turn-lifecycle.types.js";
import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js";
import type {
DeleteSessionEntryLifecycleResult,
ResetSessionEntryLifecycleMutation,
@@ -18,7 +13,12 @@ import type {
SessionLifecycleArtifactCleanupParams,
SessionLifecycleArtifactCleanupResult,
SessionLifecycleStoreTarget,
} from "./store.js";
} from "./session-accessor.lifecycle-types.js";
import type {
SessionTranscriptTurnExpectedState,
SessionTranscriptTurnLifecyclePatch,
} from "./session-transcript-turn-lifecycle.types.js";
import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js";
import type { SessionCompactionCheckpoint, SessionEntry } from "./types.js";
/**
@@ -27,12 +27,8 @@ import type { SessionCompactionCheckpoint, SessionEntry } from "./types.js";
* identity, and this module resolves the current entry/transcript target while
* preserving canonical-key, transcript-linking, and update-notification rules.
*
* Ownership contract (#88838): this accessor is the permanent storage-neutral
* domain boundary for session/transcript runtime access; the SQLite storage
* flip implements this interface. The entry workflow helpers in store.ts are
* the file-backend implementation it delegates to plus the plugin-SDK
* deprecation-window surface (RFC 0007); they become internal as direct
* callers migrate here. New runtime callers use this module, not store.ts.
* This accessor is the permanent storage-neutral domain boundary for
* session/transcript runtime access. Legacy JSON import remains doctor-only.
*/
export type SessionAccessScope = {
/** Agent owner used when the session key does not already encode one. */
@@ -8,7 +8,7 @@ import {
} from "./session-accessor.js";
import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js";
import { collectActiveSessionWorkAdmissionKeys } from "./store-maintenance-preserve.js";
import { pruneStaleEntries } from "./store.js";
import { pruneStaleEntries } from "./store-maintenance.js";
import type { SessionEntry } from "./types.js";
type SessionRegistryMaintenanceStoreSummary = {
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -18,7 +18,7 @@ type PersistedSessionStore = {
changed: boolean;
};
export type SessionSkillPromptBlobProjection = {
type SessionSkillPromptBlobProjection = {
ref: SessionSkillPromptRef;
path: string | null;
prompt: string;
@@ -136,13 +136,6 @@ function readValidPromptBlob(storePath: string, ref: SessionSkillPromptRef): str
}
}
export function isSessionSkillPromptBlobReadable(
storePath: string,
ref: SessionSkillPromptRef,
): boolean {
return readValidPromptBlob(storePath, ref) !== null;
}
async function ensurePromptBlob(storePath: string, prompt: string): Promise<SessionSkillPromptRef> {
const ref = buildPromptRef(prompt);
const blobPath = resolveSessionSkillPromptBlobPath(storePath, ref.hash);
-378
View File
@@ -1,378 +0,0 @@
import { expectDefined } from "@openclaw/normalization-core";
// Session store caches share parsed stores, immutable snapshots, and serialized JSON.
import { parseStrictNonNegativeInteger } from "../../infra/parse-finite-number.js";
import { createExpiringMapCache, isCacheEnabled, resolveCacheTtlMs } from "../cache-utils.js";
import { clearSessionSkillPromptRefCache } from "./skill-prompt-blobs.js";
import type { SessionEntry, SessionSkillPromptRef } from "./types.js";
type DeepReadonly<T> = T extends (...args: never[]) => unknown
? T
: T extends readonly (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
export type SessionStoreSnapshotEntry = DeepReadonly<SessionEntry>;
type SessionStoreCacheEntry = {
store: Record<string, SessionEntry>;
ctimeNs?: bigint;
mtimeNs?: bigint;
sizeBytes?: number;
serialized?: string;
};
type SerializedSessionStoreCacheEntry = {
serialized: string;
sizeBytes: number;
promptRefs?: ReadonlyMap<string, SessionSkillPromptRef>;
};
const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s)
const DEFAULT_SESSION_STORE_SERIALIZED_CACHE_MAX_ENTRIES = 64;
const DEFAULT_SESSION_STORE_SERIALIZED_CACHE_MAX_BYTES = 64 * 1024 * 1024;
const LARGE_SESSION_STORE_STRING_MIN_CHARS = 512;
const LARGE_SESSION_STORE_STRING_MAX_INTERNED = 256;
const SESSION_STORE_CACHE = createExpiringMapCache<string, SessionStoreCacheEntry>({
ttlMs: getSessionStoreTtl,
});
const SESSION_STORE_CACHE_VERSION = new Map<string, number>();
const SESSION_STORE_SERIALIZED_CACHE = new Map<string, SerializedSessionStoreCacheEntry>();
const SESSION_STORE_STRING_INTERN_POOL = new Map<string, string>();
const SESSION_STORE_STRING_INTERN_STATS = {
stored: 0,
reused: 0,
skippedSmall: 0,
skippedFull: 0,
};
let sessionStoreSerializedCacheBytes = 0;
function parseNonNegativeInteger(value: string | undefined): number | null {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
return parseStrictNonNegativeInteger(trimmed) ?? null;
}
function getSerializedSessionStoreCacheMaxBytes(): number {
return (
parseNonNegativeInteger(process.env.OPENCLAW_SESSION_SERIALIZED_CACHE_MAX_BYTES) ??
DEFAULT_SESSION_STORE_SERIALIZED_CACHE_MAX_BYTES
);
}
function getSerializedSessionStoreCacheMaxEntries(): number {
return DEFAULT_SESSION_STORE_SERIALIZED_CACHE_MAX_ENTRIES;
}
function resetSessionStoreStringInternStats(): void {
SESSION_STORE_STRING_INTERN_STATS.stored = 0;
SESSION_STORE_STRING_INTERN_STATS.reused = 0;
SESSION_STORE_STRING_INTERN_STATS.skippedSmall = 0;
SESSION_STORE_STRING_INTERN_STATS.skippedFull = 0;
}
function internLargeSessionStoreString(value: string): string {
if (value.length < LARGE_SESSION_STORE_STRING_MIN_CHARS) {
SESSION_STORE_STRING_INTERN_STATS.skippedSmall += 1;
return value;
}
const interned = SESSION_STORE_STRING_INTERN_POOL.get(value);
if (interned !== undefined) {
SESSION_STORE_STRING_INTERN_STATS.reused += 1;
return interned;
}
if (SESSION_STORE_STRING_INTERN_POOL.size >= LARGE_SESSION_STORE_STRING_MAX_INTERNED) {
SESSION_STORE_STRING_INTERN_STATS.skippedFull += 1;
return value;
}
SESSION_STORE_STRING_INTERN_POOL.set(value, value);
SESSION_STORE_STRING_INTERN_STATS.stored += 1;
return value;
}
export function internSessionEntryLargeStrings(entry: SessionEntry): void {
const snapshot = entry.skillsSnapshot;
if (!snapshot?.prompt) {
return;
}
// The live session store repeatedly clones a small set of large skills prompts.
// Intern only that known high-duplication field so behavior and serialization stay unchanged.
snapshot.prompt = internLargeSessionStoreString(snapshot.prompt);
}
function internSessionStoreLargeStrings(store: Record<string, SessionEntry>): void {
for (const entry of Object.values(store)) {
internSessionEntryLargeStrings(entry);
}
}
function deepFreeze<T>(value: T, seen = new WeakSet<object>()): DeepReadonly<T> {
if (!value || typeof value !== "object") {
return value as DeepReadonly<T>;
}
const object = value as object;
if (seen.has(object)) {
return value as DeepReadonly<T>;
}
seen.add(object);
for (const child of Object.values(value as Record<string, unknown>)) {
deepFreeze(child, seen);
}
return Object.freeze(value) as DeepReadonly<T>;
}
export function cloneSessionStoreRecord(
store: Record<string, SessionEntry>,
serialized?: string,
): Record<string, SessionEntry> {
// When serialized JSON is already available, parse it instead of deep-cloning field-by-field.
const cloned =
serialized === undefined
? cloneJsonLikeValue(store)
: (JSON.parse(serialized) as Record<string, SessionEntry>);
internSessionStoreLargeStrings(cloned);
return cloned;
}
function cloneJsonLikeValue<T>(value: T): T {
if (!value || typeof value !== "object") {
return value;
}
if (Array.isArray(value)) {
const cloned: unknown[] = [];
cloned.length = value.length;
for (let index = 0; index < value.length; index += 1) {
if (!(index in value)) {
continue;
}
cloned[index] = cloneJsonLikeValue(value[index]);
}
return cloned as T;
}
const cloned: Record<string, unknown> = {};
for (const key in value as Record<string, unknown>) {
if (!Object.hasOwn(value, key)) {
continue;
}
const child = (value as Record<string, unknown>)[key];
if (child === undefined) {
continue;
}
const clonedChild = cloneJsonLikeValue(child);
if (key === "__proto__") {
// Preserve JSON object shape without letting `__proto__` mutate the clone prototype.
Object.defineProperty(cloned, key, {
value: clonedChild,
enumerable: true,
configurable: true,
writable: true,
});
} else {
cloned[key] = clonedChild;
}
}
return cloned as T;
}
export function cloneSessionStoreSnapshotEntry(entry: SessionEntry): SessionStoreSnapshotEntry {
return deepFreeze(
expectDefined(cloneSessionStoreRecord({ entry }).entry, "cloned session cache entry"),
);
}
function getSessionStoreTtl(): number {
return resolveCacheTtlMs({
envValue: process.env.OPENCLAW_SESSION_CACHE_TTL_MS,
defaultTtlMs: DEFAULT_SESSION_STORE_TTL_MS,
});
}
export function isSessionStoreCacheEnabled(): boolean {
return isCacheEnabled(getSessionStoreTtl());
}
function bumpSessionStoreCacheVersion(storePath: string): void {
SESSION_STORE_CACHE_VERSION.set(storePath, (SESSION_STORE_CACHE_VERSION.get(storePath) ?? 0) + 1);
}
export function getSessionStoreCacheVersion(storePath: string): number {
return SESSION_STORE_CACHE_VERSION.get(storePath) ?? 0;
}
export function clearSessionStoreCaches(): void {
SESSION_STORE_CACHE.clear();
SESSION_STORE_CACHE_VERSION.clear();
SESSION_STORE_SERIALIZED_CACHE.clear();
sessionStoreSerializedCacheBytes = 0;
SESSION_STORE_STRING_INTERN_POOL.clear();
clearSessionSkillPromptRefCache();
resetSessionStoreStringInternStats();
}
export function invalidateSessionStoreCache(storePath: string): void {
bumpSessionStoreCacheVersion(storePath);
SESSION_STORE_CACHE.delete(storePath);
deleteSerializedSessionStore(storePath);
}
function deleteSerializedSessionStore(storePath: string): void {
const cached = SESSION_STORE_SERIALIZED_CACHE.get(storePath);
if (!cached) {
return;
}
SESSION_STORE_SERIALIZED_CACHE.delete(storePath);
sessionStoreSerializedCacheBytes -= cached.sizeBytes;
}
function pruneSerializedSessionStoreCache(): void {
const maxEntries = getSerializedSessionStoreCacheMaxEntries();
const maxBytes = getSerializedSessionStoreCacheMaxBytes();
while (
SESSION_STORE_SERIALIZED_CACHE.size > 0 &&
(SESSION_STORE_SERIALIZED_CACHE.size > maxEntries ||
sessionStoreSerializedCacheBytes > maxBytes)
) {
// Map insertion order gives us a tiny LRU-ish eviction policy without extra bookkeeping.
const oldestKey = SESSION_STORE_SERIALIZED_CACHE.keys().next().value;
if (typeof oldestKey !== "string") {
break;
}
deleteSerializedSessionStore(oldestKey);
}
}
export function getSerializedSessionStore(storePath: string): string | undefined {
pruneSerializedSessionStoreCache();
return SESSION_STORE_SERIALIZED_CACHE.get(storePath)?.serialized;
}
export function getSerializedSessionStorePromptRefs(
storePath: string,
): ReadonlyMap<string, SessionSkillPromptRef> | undefined {
pruneSerializedSessionStoreCache();
return SESSION_STORE_SERIALIZED_CACHE.get(storePath)?.promptRefs;
}
export function setSerializedSessionStorePromptRefs(
storePath: string,
promptRefs: ReadonlyMap<string, SessionSkillPromptRef>,
): void {
pruneSerializedSessionStoreCache();
const cached = SESSION_STORE_SERIALIZED_CACHE.get(storePath);
if (!cached) {
return;
}
cached.promptRefs = promptRefs;
}
export function setSerializedSessionStore(
storePath: string,
serialized?: string,
sizeBytesHint?: number,
promptRefs?: ReadonlyMap<string, SessionSkillPromptRef>,
): void {
deleteSerializedSessionStore(storePath);
if (serialized === undefined) {
return;
}
const sizeBytes =
typeof sizeBytesHint === "number" && Number.isFinite(sizeBytesHint) && sizeBytesHint >= 0
? sizeBytesHint
: Buffer.byteLength(serialized, "utf8");
const maxEntries = getSerializedSessionStoreCacheMaxEntries();
const maxBytes = getSerializedSessionStoreCacheMaxBytes();
if (maxEntries <= 0 || maxBytes <= 0 || sizeBytes > maxBytes) {
return;
}
SESSION_STORE_SERIALIZED_CACHE.set(storePath, { serialized, sizeBytes, promptRefs });
sessionStoreSerializedCacheBytes += sizeBytes;
pruneSerializedSessionStoreCache();
}
export function dropSessionStoreObjectCache(storePath: string): void {
bumpSessionStoreCacheVersion(storePath);
SESSION_STORE_CACHE.delete(storePath);
}
export function readSessionStoreCache(params: {
storePath: string;
ctimeNs?: bigint;
mtimeNs?: bigint;
sizeBytes?: number;
clone?: boolean;
}): Record<string, SessionEntry> | null {
const cached = SESSION_STORE_CACHE.get(params.storePath);
if (!cached) {
return null;
}
if (
params.ctimeNs !== cached.ctimeNs ||
params.mtimeNs !== cached.mtimeNs ||
params.sizeBytes !== cached.sizeBytes
) {
invalidateSessionStoreCache(params.storePath);
return null;
}
if (params.clone === false) {
return cached.store;
}
return cloneSessionStoreRecord(cached.store, cached.serialized);
}
export function takeMutableSessionStoreCache(params: {
storePath: string;
ctimeNs?: bigint;
mtimeNs?: bigint;
sizeBytes?: number;
}): Record<string, SessionEntry> | null {
const cached = SESSION_STORE_CACHE.get(params.storePath);
if (!cached) {
return null;
}
if (
params.ctimeNs !== cached.ctimeNs ||
params.mtimeNs !== cached.mtimeNs ||
params.sizeBytes !== cached.sizeBytes
) {
invalidateSessionStoreCache(params.storePath);
return null;
}
SESSION_STORE_CACHE.delete(params.storePath);
return cached.store;
}
export function writeSessionStoreCache(params: {
storePath: string;
store: Record<string, SessionEntry>;
ctimeNs?: bigint;
mtimeNs?: bigint;
sizeBytes?: number;
serialized?: string;
serializedPromptRefs?: ReadonlyMap<string, SessionSkillPromptRef>;
cloneSerialized?: string;
takeOwnership?: boolean;
}): void {
bumpSessionStoreCacheVersion(params.storePath);
const store =
params.takeOwnership === true ? params.store : cloneSessionStoreRecord(params.store);
// Ownership is only taken for freshly parsed store objects that no caller will mutate afterward.
if (params.takeOwnership === true) {
internSessionStoreLargeStrings(store);
}
SESSION_STORE_CACHE.set(params.storePath, {
store,
ctimeNs: params.ctimeNs,
mtimeNs: params.mtimeNs,
sizeBytes: params.sizeBytes,
serialized: params.cloneSerialized,
});
setSerializedSessionStore(
params.storePath,
params.serialized,
params.sizeBytes,
params.serializedPromptRefs,
);
}
-540
View File
@@ -1,540 +0,0 @@
// Session store loading normalizes persisted records, migrations, maintenance, and caches.
import fs from "node:fs";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js";
import { isPluginJsonValue, type PluginJsonValue } from "../../plugins/host-hook-json.js";
import { normalizeSessionEntrySlotKey } from "../../plugins/session-entry-slot-keys.js";
import { resolveAgentHarnessSessionStoreError } from "../../sessions/agent-harness-session-key.js";
import {
normalizeDeliveryChannelRoute,
normalizeDeliveryContext,
normalizeSessionDeliveryFields,
} from "../../utils/delivery-context.shared.js";
import { getFileStatSnapshot } from "../cache-utils.js";
import { normalizeRestartRecoveryEntryFields } from "./restart-recovery-state.js";
import { hydrateSessionStoreSkillPromptRefs } from "./skill-prompt-blobs.js";
import {
cloneSessionStoreRecord,
cloneSessionStoreSnapshotEntry,
internSessionEntryLargeStrings,
isSessionStoreCacheEnabled,
readSessionStoreCache,
setSerializedSessionStore,
writeSessionStoreCache,
type SessionStoreSnapshotEntry,
} from "./store-cache.js";
import { normalizePersistedSessionEntryShape } from "./store-entry-shape.js";
import { resolveSessionStoreEntry } from "./store-entry.js";
import { collectSessionMaintenancePreserveKeysForStore } from "./store-maintenance-preserve.js";
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
import {
capEntryCount,
pruneStaleEntries,
pruneStaleModelRunEntries,
shouldRunModelRunPrune,
shouldRunSessionEntryMaintenance,
type ResolvedSessionMaintenanceConfig,
} from "./store-maintenance.js";
import { applySessionStoreMigrations } from "./store-migrations.js";
import { normalizeSessionRuntimeModelFields, type SessionEntry } from "./types.js";
type LoadSessionStoreOptions = {
skipCache?: boolean;
maintenanceConfig?: ResolvedSessionMaintenanceConfig;
runMaintenance?: boolean;
clone?: boolean;
hydrateSkillPromptRefs?: boolean;
};
type ReadSessionEntryOptions = {
hydrateSkillPromptRefs?: boolean;
};
const log = createSubsystemLogger("sessions/store");
function isSessionStoreRecord(value: unknown): value is Record<string, SessionEntry> {
return isRecord(value);
}
function normalizeOptionalFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function normalizeOptionalAttemptCount(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
}
function normalizeOptionalStringOrNull(value: unknown): string | null | undefined {
if (value === null || typeof value === "string") {
return value;
}
return undefined;
}
function normalizeRecordKey(value: string): string | undefined {
const key = value.trim();
return key.length > 0 ? key : undefined;
}
function normalizeOptionalDeliveryContext(
value: unknown,
): SessionEntry["pendingFinalDeliveryContext"] {
if (!isRecord(value)) {
return undefined;
}
const normalized = normalizeDeliveryContext({
channel: typeof value.channel === "string" ? value.channel : undefined,
to: typeof value.to === "string" ? value.to : undefined,
accountId: typeof value.accountId === "string" ? value.accountId : undefined,
threadId:
typeof value.threadId === "string" || typeof value.threadId === "number"
? value.threadId
: undefined,
});
return normalized?.channel && normalized.to ? normalized : undefined;
}
function sameDeliveryContext(
left: SessionEntry["pendingFinalDeliveryContext"],
right: SessionEntry["pendingFinalDeliveryContext"],
): boolean {
return (
(left?.channel ?? undefined) === (right?.channel ?? undefined) &&
(left?.to ?? undefined) === (right?.to ?? undefined) &&
(left?.accountId ?? undefined) === (right?.accountId ?? undefined) &&
(left?.threadId ?? undefined) === (right?.threadId ?? undefined)
);
}
function normalizePendingFinalDeliveryFields(entry: SessionEntry): SessionEntry {
let next = entry;
const assign = <K extends keyof SessionEntry>(key: K, value: SessionEntry[K] | undefined) => {
if (entry[key] === value) {
return;
}
if (next === entry) {
// Copy-on-write keeps unchanged entries referentially stable for cache reuse.
next = { ...entry };
}
if (value === undefined) {
delete next[key];
} else {
next[key] = value;
}
};
assign("pendingFinalDelivery", entry.pendingFinalDelivery === true ? true : undefined);
assign("pendingFinalDeliveryText", normalizeOptionalStringOrNull(entry.pendingFinalDeliveryText));
assign(
"pendingFinalDeliveryCreatedAt",
normalizeOptionalFiniteNumber(entry.pendingFinalDeliveryCreatedAt),
);
assign(
"pendingFinalDeliveryLastAttemptAt",
normalizeOptionalFiniteNumber(entry.pendingFinalDeliveryLastAttemptAt),
);
assign(
"pendingFinalDeliveryAttemptCount",
normalizeOptionalAttemptCount(entry.pendingFinalDeliveryAttemptCount),
);
assign(
"pendingFinalDeliveryLastError",
normalizeOptionalStringOrNull(entry.pendingFinalDeliveryLastError),
);
const pendingFinalDeliveryContext = normalizeOptionalDeliveryContext(
entry.pendingFinalDeliveryContext,
);
if (!sameDeliveryContext(entry.pendingFinalDeliveryContext, pendingFinalDeliveryContext)) {
assign("pendingFinalDeliveryContext", pendingFinalDeliveryContext);
}
assign(
"pendingFinalDeliveryIntentId",
normalizeOptionalStringOrNull(entry.pendingFinalDeliveryIntentId),
);
const restartRecoveryDeliveryContext = normalizeOptionalDeliveryContext(
entry.restartRecoveryDeliveryContext,
);
if (!sameDeliveryContext(entry.restartRecoveryDeliveryContext, restartRecoveryDeliveryContext)) {
assign("restartRecoveryDeliveryContext", restartRecoveryDeliveryContext);
}
normalizeRestartRecoveryEntryFields(entry, assign);
return next;
}
function normalizePluginExtensions(entry: SessionEntry): SessionEntry {
if (entry.pluginExtensions === undefined) {
return entry;
}
if (!isRecord(entry.pluginExtensions)) {
const next = { ...entry };
delete next.pluginExtensions;
return next;
}
let changed = false;
const normalizedExtensions: Record<string, Record<string, PluginJsonValue>> = {};
// Plugin state is an external boundary; only JSON-safe keyed records are persisted back.
for (const [rawPluginId, rawPluginState] of Object.entries(entry.pluginExtensions)) {
const pluginId = normalizeRecordKey(rawPluginId);
if (!pluginId || !isRecord(rawPluginState)) {
changed = true;
continue;
}
if (pluginId !== rawPluginId) {
changed = true;
}
const normalizedPluginState: Record<string, PluginJsonValue> = {};
for (const [rawNamespace, rawValue] of Object.entries(rawPluginState)) {
const namespace = normalizeRecordKey(rawNamespace);
if (!namespace || !isPluginJsonValue(rawValue)) {
changed = true;
continue;
}
if (namespace !== rawNamespace) {
changed = true;
}
normalizedPluginState[namespace] = rawValue;
}
if (Object.keys(normalizedPluginState).length === 0) {
changed = true;
continue;
}
normalizedExtensions[pluginId] = normalizedPluginState;
}
if (!changed) {
return entry;
}
const next = { ...entry };
if (Object.keys(normalizedExtensions).length > 0) {
next.pluginExtensions = normalizedExtensions;
} else {
delete next.pluginExtensions;
}
return next;
}
function normalizePluginExtensionSlotKeys(entry: SessionEntry): SessionEntry {
if (entry.pluginExtensionSlotKeys === undefined) {
return entry;
}
if (!isRecord(entry.pluginExtensionSlotKeys)) {
const next = { ...entry };
delete next.pluginExtensionSlotKeys;
return next;
}
let changed = false;
const normalizedSlotKeys: Record<string, Record<string, string>> = {};
for (const [rawPluginId, rawPluginSlots] of Object.entries(entry.pluginExtensionSlotKeys)) {
const pluginId = normalizeRecordKey(rawPluginId);
if (!pluginId || !isRecord(rawPluginSlots)) {
changed = true;
continue;
}
if (pluginId !== rawPluginId) {
changed = true;
}
const normalizedPluginSlots: Record<string, string> = {};
for (const [rawNamespace, rawSlotKey] of Object.entries(rawPluginSlots)) {
const namespace = normalizeRecordKey(rawNamespace);
const slotKey = normalizeSessionEntrySlotKey(rawSlotKey);
if (!namespace || !slotKey.ok) {
changed = true;
continue;
}
if (namespace !== rawNamespace || slotKey.key !== rawSlotKey) {
changed = true;
}
normalizedPluginSlots[namespace] = slotKey.key;
}
if (Object.keys(normalizedPluginSlots).length === 0) {
changed = true;
continue;
}
normalizedSlotKeys[pluginId] = normalizedPluginSlots;
}
if (!changed) {
return entry;
}
const next = { ...entry };
if (Object.keys(normalizedSlotKeys).length > 0) {
next.pluginExtensionSlotKeys = normalizedSlotKeys;
} else {
delete next.pluginExtensionSlotKeys;
}
return next;
}
function sameDeliveryChannelRoute(
left: ChannelRouteRef | undefined,
right: ChannelRouteRef | undefined,
): boolean {
return (
(left?.channel ?? undefined) === (right?.channel ?? undefined) &&
(left?.accountId ?? undefined) === (right?.accountId ?? undefined) &&
(left?.target?.to ?? undefined) === (right?.target?.to ?? undefined) &&
(left?.target?.rawTo ?? undefined) === (right?.target?.rawTo ?? undefined) &&
(left?.target?.chatType ?? undefined) === (right?.target?.chatType ?? undefined) &&
(left?.thread?.id ?? undefined) === (right?.thread?.id ?? undefined) &&
(left?.thread?.kind ?? undefined) === (right?.thread?.kind ?? undefined) &&
(left?.thread?.source ?? undefined) === (right?.thread?.source ?? undefined)
);
}
/**
* Rebuilds malformed/legacy delivery `route` state from the entry's delivery
* fields. Runs on file-era store loads and on the doctor SQLite import so the
* SQLite store only holds canonical delivery shapes; SQLite reads do no repair.
*/
export function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry {
const entryRoute = normalizeDeliveryChannelRoute(entry.route);
const normalized = normalizeSessionDeliveryFields({
route: entryRoute,
channel: entry.channel,
lastChannel: entry.lastChannel,
lastTo: entry.lastTo,
lastAccountId: entry.lastAccountId,
lastThreadId: entry.lastThreadId ?? entry.deliveryContext?.threadId ?? entry.origin?.threadId,
deliveryContext: entry.deliveryContext,
});
const nextDelivery = normalized.deliveryContext;
const sameDelivery =
(entry.deliveryContext?.channel ?? undefined) === nextDelivery?.channel &&
(entry.deliveryContext?.to ?? undefined) === nextDelivery?.to &&
(entry.deliveryContext?.accountId ?? undefined) === nextDelivery?.accountId &&
(entry.deliveryContext?.threadId ?? undefined) === nextDelivery?.threadId;
const sameLast =
sameDeliveryChannelRoute(entryRoute, normalized.route) &&
entry.lastChannel === normalized.lastChannel &&
entry.lastTo === normalized.lastTo &&
entry.lastAccountId === normalized.lastAccountId &&
entry.lastThreadId === normalized.lastThreadId;
if (sameDelivery && sameLast) {
return entry;
}
return {
...entry,
route: normalized.route,
deliveryContext: nextDelivery,
lastChannel: normalized.lastChannel,
lastTo: normalized.lastTo,
lastAccountId: normalized.lastAccountId,
lastThreadId: normalized.lastThreadId,
};
}
// resolvedSkills carries the full parsed Skill[] (including each SKILL.md body)
// and is only used as an in-turn cache by the runtime — see
// src/skills/runtime/embedded-run-entries.ts. Persisting it bloats
// sessions.json by orders of magnitude when many sessions are active. Strip
// it from every entry that flows through normalize, so neither the in-memory
// store reloaded from disk nor the JSON serialized back to disk carries it.
export function stripPersistedSkillsCache(entry: SessionEntry): SessionEntry {
const snapshot = entry.skillsSnapshot;
if (!snapshot || snapshot.resolvedSkills === undefined) {
return entry;
}
const { resolvedSkills: _drop, ...rest } = snapshot;
return { ...entry, skillsSnapshot: rest };
}
export function normalizeSessionStore(store: Record<string, SessionEntry>): boolean {
let changed = false;
for (const [key, entry] of Object.entries(store)) {
const modelSelectionLocked = isRecord(entry) && entry.modelSelectionLocked === true;
const shaped = normalizePersistedSessionEntryShape(entry);
if (!shaped) {
if (modelSelectionLocked) {
// Never normalize a protected row into absence. Writers must see the
// corruption and fail closed instead of persisting an implicit unlock.
throw new Error(`Invalid model-selection-locked session entry: ${key}`);
}
delete store[key];
changed = true;
continue;
}
const normalizedRuntimeFields = normalizeSessionRuntimeModelFields(shaped);
if (modelSelectionLocked && normalizedRuntimeFields !== shaped) {
// The persisted provider/model pair is part of the harness-owned runtime
// identity. Do not repair it before validating the durable lock.
throw new Error(`Invalid model-selection-locked session entry: ${key}`);
}
const normalized = stripPersistedSkillsCache(
normalizePluginExtensionSlotKeys(
normalizePluginExtensions(
normalizePendingFinalDeliveryFields(
normalizeSessionEntryDelivery(modelSelectionLocked ? shaped : normalizedRuntimeFields),
),
),
),
);
internSessionEntryLargeStrings(normalized);
if (normalized !== entry) {
store[key] = normalized;
changed = true;
}
}
const harnessStoreError = resolveAgentHarnessSessionStoreError(store);
if (harnessStoreError) {
throw new Error(harnessStoreError);
}
return changed;
}
export function loadSessionStore(
storePath: string,
opts: LoadSessionStoreOptions = {},
): Record<string, SessionEntry> {
const shouldHydrateSkillPromptRefs = opts.hydrateSkillPromptRefs !== false;
const canWriteSessionStoreCache = shouldHydrateSkillPromptRefs;
if (!opts.skipCache && isSessionStoreCacheEnabled()) {
const currentFileStat = getFileStatSnapshot(storePath);
const cached = readSessionStoreCache({
storePath,
...currentFileStat,
clone: opts.clone,
});
if (cached) {
return cached;
}
}
// Retry a few times because readers can briefly observe empty or
// transiently invalid content while another process is swapping the file.
let store: Record<string, SessionEntry> = {};
const fileStat = getFileStatSnapshot(storePath);
let serializedFromDisk: string | undefined;
const maxReadAttempts = 3;
const retryBuf = maxReadAttempts > 1 ? new Int32Array(new SharedArrayBuffer(4)) : undefined;
for (let attempt = 0; attempt < maxReadAttempts; attempt += 1) {
try {
const raw = fs.readFileSync(storePath, "utf-8");
if (raw.length === 0 && attempt < maxReadAttempts - 1) {
Atomics.wait(retryBuf!, 0, 0, 50);
continue;
}
const parsed = JSON.parse(raw);
if (isSessionStoreRecord(parsed)) {
store = parsed;
serializedFromDisk = raw;
}
// Cache with the stat observed before this read. If another process
// writes the file after readFileSync returns, a post-read stat could tag
// stale content as current and make future cache hits return old data.
break;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
const isPermanentReadError = code === "ENOENT" || code === "EACCES" || code === "EPERM";
if (isPermanentReadError) {
break;
}
if (attempt < maxReadAttempts - 1) {
Atomics.wait(retryBuf!, 0, 0, 50);
continue;
}
}
}
const hydratedPromptRefs = shouldHydrateSkillPromptRefs
? hydrateSessionStoreSkillPromptRefs({ storePath, store })
: false;
const migrated = applySessionStoreMigrations(store);
const normalized = normalizeSessionStore(store);
if (hydratedPromptRefs || migrated || normalized) {
// Any in-memory repair invalidates the original serialized bytes for future write projection.
serializedFromDisk = undefined;
}
if (opts.runMaintenance) {
const maintenance = opts.maintenanceConfig ?? resolveMaintenanceConfig();
const beforeCount = Object.keys(store).length;
let modelRunPruned = 0;
let pruned = 0;
let capped = 0;
if (maintenance.mode === "enforce") {
const preserveSessionKeys = collectSessionMaintenancePreserveKeysForStore({
storePath,
store,
});
if (
shouldRunModelRunPrune({
maintenance,
entryCount: beforeCount,
})
) {
modelRunPruned = pruneStaleModelRunEntries(store, maintenance.modelRunPruneAfterMs, {
log: false,
preserveKeys: preserveSessionKeys,
});
}
}
if (maintenance.mode === "enforce" && Object.keys(store).length > maintenance.maxEntries) {
const preserveSessionKeys = collectSessionMaintenancePreserveKeysForStore({
storePath,
store,
});
pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, {
log: false,
preserveKeys: preserveSessionKeys,
});
const countAfterPrune = Object.keys(store).length;
capped = shouldRunSessionEntryMaintenance({
entryCount: countAfterPrune,
maxEntries: maintenance.maxEntries,
})
? capEntryCount(store, maintenance.maxEntries, {
log: false,
preserveKeys: preserveSessionKeys,
})
: 0;
}
const afterCount = Object.keys(store).length;
if (modelRunPruned > 0 || pruned > 0 || capped > 0) {
serializedFromDisk = undefined;
log.info("applied load-time maintenance to session store", {
storePath,
before: beforeCount,
after: afterCount,
modelRunPruned,
pruned,
capped,
maxEntries: maintenance.maxEntries,
});
}
}
setSerializedSessionStore(storePath, serializedFromDisk);
if (!opts.skipCache && canWriteSessionStoreCache && isSessionStoreCacheEnabled()) {
writeSessionStoreCache({
storePath,
store,
...fileStat,
serialized: serializedFromDisk,
cloneSerialized: serializedFromDisk,
takeOwnership: serializedFromDisk !== undefined,
});
}
return opts.clone === false ? store : cloneSessionStoreRecord(store, serializedFromDisk);
}
export function readSessionEntry(
storePath: string,
sessionKey: string,
opts: ReadSessionEntryOptions = {},
): SessionStoreSnapshotEntry | undefined {
const store = loadSessionStore(storePath, {
clone: false,
...(opts.hydrateSkillPromptRefs === false ? { hydrateSkillPromptRefs: false } : {}),
});
const resolved = resolveSessionStoreEntry({
store,
sessionKey,
});
return resolved.existing ? cloneSessionStoreSnapshotEntry(resolved.existing) : undefined;
}
+3 -3
View File
@@ -4,15 +4,15 @@ import {
drainStoreWriterQueuesForTest,
type StoreWriterQueue,
} from "../../shared/store-writer-queue.js";
import { clearSessionStoreCaches } from "./store-cache.js";
import { clearSessionSkillPromptRefCache } from "./skill-prompt-blobs.js";
type SessionStoreWriterQueue = StoreWriterQueue;
export const WRITER_QUEUES = new Map<string, SessionStoreWriterQueue>();
/** Clears session store writer queues and cache for tests. */
/** Clears legacy session writer queues and prompt-blob caches for tests. */
export function clearSessionStoreCacheForTest(): void {
clearSessionStoreCaches();
clearSessionSkillPromptRefCache();
clearStoreWriterQueuesForTest(WRITER_QUEUES, "session store queue cleared for test");
}
+1 -1
View File
@@ -1,8 +1,8 @@
// Session store writer tests cover serialized session writes and cleanup.
import { afterEach, describe, expect, it } from "vitest";
import { createDeferred } from "../../test-utils/deferred.js";
import { clearSessionStoreCacheForTest } from "./store-writer-state.js";
import { runExclusiveSessionStoreWrite } from "./store-writer.js";
import { clearSessionStoreCacheForTest } from "./store.js";
describe("session store writer", () => {
afterEach(() => {
@@ -1,473 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../../sessions/agent-harness-session-key.js";
import {
clearSessionStoreCacheForTest,
loadSessionStore,
saveSessionStore,
updateSessionStore,
} from "./store.js";
import type { SessionEntry } from "./types.js";
describe("agent harness session store invariant", () => {
let tempDir: string;
let storePath: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-harness-store-"));
storePath = path.join(tempDir, "sessions.json");
});
afterEach(() => {
clearSessionStoreCacheForTest();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it.each([
{ modelSelectionLocked: true, sessionId: "native-session", updatedAt: 1 },
{
modelSelectionLocked: true,
agentHarnessId: "other",
sessionId: "native-session",
updatedAt: 1,
},
{
modelSelectionLocked: true,
agentHarnessId: "",
sessionId: "native-session",
updatedAt: 1,
},
] satisfies SessionEntry[])(
"rejects an invalid reserved row through public save",
async (entry) => {
const sessionKey = "agent:main:harness:codex:supervision:native-thread";
await expect(
saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true }),
).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE);
expect(fs.existsSync(storePath)).toBe(false);
},
);
it("loads and updates a pre-existing unlocked harness-prefixed session", async () => {
const sessionKey = "agent:main:harness:notes";
const entry: SessionEntry = {
agentHarnessId: "openclaw",
sessionId: "legacy-session",
updatedAt: 1,
};
fs.writeFileSync(storePath, JSON.stringify({ [sessionKey]: entry }), "utf-8");
expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ [sessionKey]: entry });
await updateSessionStore(
storePath,
(store) => {
store[sessionKey] = {
...expectDefined(store[sessionKey], "stored harness session"),
label: "Legacy notes",
};
},
{ skipMaintenance: true },
);
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual({
...entry,
label: "Legacy notes",
});
});
it("allows an ordinary legacy model lock to adopt a transcript id", async () => {
const sessionKey = "agent:main:legacy-model-lock";
const entry = {
modelSelectionLocked: true,
updatedAt: 1,
} as SessionEntry;
await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true });
await updateSessionStore(
storePath,
(store) => {
store[sessionKey] = {
...expectDefined(store[sessionKey], "stored legacy session"),
sessionId: "generated-session",
};
},
{ skipMaintenance: true },
);
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual({
...entry,
sessionId: "generated-session",
});
});
it("rejects a prefix-colliding harness owner through public save", async () => {
const sessionKey = "agent:main:harness:foo:bar:native-thread";
await expect(
saveSessionStore(
storePath,
{
[sessionKey]: {
agentHarnessId: "foo:bar",
modelSelectionLocked: true,
sessionId: "native-session",
updatedAt: 1,
},
},
{ skipMaintenance: true },
),
).rejects.toThrow(AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE);
expect(fs.existsSync(storePath)).toBe(false);
});
it("rejects removing or reassigning any durable model-selection lock", async () => {
const sessionKey = "agent:main:ordinary-locked";
const entry: SessionEntry = {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "locked-session",
updatedAt: 1,
};
await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true });
await expect(
updateSessionStore(
storePath,
(store) => {
delete store[sessionKey];
},
{ skipMaintenance: true },
),
).rejects.toThrow("Model-selection-locked sessions cannot be removed");
await expect(
updateSessionStore(
storePath,
(store) => {
store[sessionKey] = {
...expectDefined(store[sessionKey], "stored harness session"),
agentHarnessId: "other",
};
},
{ skipMaintenance: true },
),
).rejects.toThrow("Model-selection-locked sessions cannot be removed");
await expect(
updateSessionStore(
storePath,
(store) => {
store[sessionKey] = {
...expectDefined(store[sessionKey], "stored harness session"),
agentHarnessId: "codex-app-server",
};
},
{ skipMaintenance: true },
),
).rejects.toThrow("Agent harness-owned session identity is locked");
await expect(
updateSessionStore(
storePath,
(store) => {
store[sessionKey] = {
...expectDefined(store[sessionKey], "stored harness session"),
sessionId: "replacement-session",
};
},
{ skipMaintenance: true },
),
).rejects.toThrow("Agent harness-owned session identity is locked");
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(entry);
});
it("keeps a reserved harness session id immutable and exclusive", async () => {
const sessionKey = "agent:main:harness:codex:supervision:native-thread";
const entry: SessionEntry = {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "native-session",
updatedAt: 1,
};
await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true });
await expect(
updateSessionStore(
storePath,
(store) => {
store[sessionKey] = {
...expectDefined(store[sessionKey], "stored harness session"),
sessionId: "replacement-session",
};
},
{ skipMaintenance: true },
),
).rejects.toThrow("Agent harness-owned session identity is locked");
await expect(
updateSessionStore(
storePath,
(store) => {
store["agent:main:ordinary-alias"] = {
sessionId: "native-session",
updatedAt: 2,
};
},
{ skipMaintenance: true },
),
).rejects.toThrow("Agent harness-owned session identity is locked");
expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ [sessionKey]: entry });
});
it("keeps an ordinary-key harness lock exclusive by session id", async () => {
const sessionKey = "agent:main:ordinary-locked";
const entry: SessionEntry = {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "native-session",
updatedAt: 1,
};
await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true });
await expect(
updateSessionStore(
storePath,
(store) => {
store["agent:main:ordinary-alias"] = {
sessionId: "native-session",
updatedAt: 2,
};
},
{ skipMaintenance: true },
),
).rejects.toThrow("Agent harness-owned session identity is locked");
expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ [sessionKey]: entry });
});
it("rejects duplicate ids across newly-created reserved rows", async () => {
const entry: SessionEntry = {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "native-session",
updatedAt: 1,
};
await expect(
saveSessionStore(
storePath,
{
"agent:main:harness:codex:supervision:first": entry,
"agent:main:harness:codex:supervision:second": entry,
},
{ skipMaintenance: true },
),
).rejects.toThrow("Agent harness-owned session identity is locked");
});
it("requires every reserved row to own a durable session id", async () => {
await expect(
saveSessionStore(
storePath,
{
"agent:main:harness:codex:supervision:native-thread": {
agentHarnessId: "codex",
modelSelectionLocked: true,
updatedAt: 1,
} as SessionEntry,
},
{ skipMaintenance: true },
),
).rejects.toThrow("Agent harness-owned session identity is locked");
});
it("fails closed instead of normalizing a malformed reserved row away", async () => {
const sessionKey = "agent:main:harness:codex:supervision:native-thread";
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "../unsafe-session",
},
}),
"utf-8",
);
expect(() => loadSessionStore(storePath, { skipCache: true })).toThrow(
`Invalid model-selection-locked session entry: ${sessionKey}`,
);
await expect(
updateSessionStore(
storePath,
(store) => {
store["agent:main:ordinary"] = { sessionId: "ordinary", updatedAt: 1 };
},
{ skipMaintenance: true },
),
).rejects.toThrow(`Invalid model-selection-locked session entry: ${sessionKey}`);
expect(JSON.parse(fs.readFileSync(storePath, "utf-8"))).toHaveProperty(sessionKey);
});
it.each([
{
name: "trimmed identity",
entry: {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: " native-session ",
updatedAt: 1,
},
},
{
name: "trimmed runtime pair",
entry: {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "native-session",
updatedAt: 1,
modelProvider: " openai ",
model: " gpt-5.4 ",
},
},
{
name: "orphan runtime provider",
entry: {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "native-session",
updatedAt: 1,
modelProvider: "openai",
},
},
])("fails closed instead of normalizing a locked $name on cold load", ({ entry }) => {
const sessionKey = "agent:main:harness:codex:supervision:native-thread";
const serialized = JSON.stringify({ [sessionKey]: entry });
fs.writeFileSync(storePath, serialized, "utf-8");
expect(() => loadSessionStore(storePath, { skipCache: true })).toThrow(
`Invalid model-selection-locked session entry: ${sessionKey}`,
);
expect(fs.readFileSync(storePath, "utf-8")).toBe(serialized);
});
it("keeps canonical locked identity and runtime fields stable on cold load", () => {
const sessionKey = "agent:main:harness:codex:supervision:native-thread";
const entry: SessionEntry = {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "native-session",
updatedAt: 1,
modelProvider: "openai",
model: "gpt-5.4",
};
const serialized = JSON.stringify({ [sessionKey]: entry });
fs.writeFileSync(storePath, serialized, "utf-8");
expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ [sessionKey]: entry });
expect(fs.readFileSync(storePath, "utf-8")).toBe(serialized);
});
it("retains unlocked harness-prefix compatibility normalization on cold load", () => {
const sessionKey = "agent:main:harness:notes";
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: {
agentHarnessId: "openclaw",
sessionId: " legacy-session ",
updatedAt: 1,
modelProvider: " openai ",
model: " gpt-5.4 ",
},
}),
"utf-8",
);
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual({
agentHarnessId: "openclaw",
sessionId: "legacy-session",
updatedAt: 1,
modelProvider: "openai",
model: "gpt-5.4",
});
});
it("fails closed on a cold-load alias of a reserved transcript identity", () => {
const sessionKey = "agent:main:harness:codex:supervision:native-thread";
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "native-session",
},
"agent:main:ordinary-alias": {
sessionId: "native-session",
},
}),
"utf-8",
);
expect(() => loadSessionStore(storePath, { skipCache: true })).toThrow(
"Agent harness-owned session identity is locked",
);
});
it("cannot poison the cache by mutating a locked row in a skipped write", async () => {
const sessionKey = "agent:main:harness:codex:supervision:native-thread";
const entry: SessionEntry = {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "native-session",
updatedAt: 1,
};
await saveSessionStore(storePath, { [sessionKey]: entry }, { skipMaintenance: true });
await expect(
updateSessionStore(
storePath,
(store) => {
delete store[sessionKey];
return 0;
},
{ skipMaintenance: true, skipSaveWhenResult: (result) => result === 0 },
),
).rejects.toThrow("Model-selection-locked sessions cannot be removed");
await updateSessionStore(
storePath,
(store) => {
store["agent:main:ordinary"] = { sessionId: "ordinary-session", updatedAt: 2 };
},
{ skipMaintenance: true },
);
expect(loadSessionStore(storePath, { skipCache: true })).toEqual({
[sessionKey]: entry,
"agent:main:ordinary": { sessionId: "ordinary-session", updatedAt: 2 },
});
});
it("does not treat reserved harness keys as relocatable aliases", async () => {
const sourceKey = "agent:main:harness:codex:supervision:source-thread";
const targetKey = "agent:main:harness:codex:supervision:other-thread";
const entry: SessionEntry = {
agentHarnessId: "codex",
modelSelectionLocked: true,
sessionId: "native-session",
updatedAt: 1,
};
await saveSessionStore(storePath, { [sourceKey]: entry }, { skipMaintenance: true });
await expect(
saveSessionStore(storePath, { [targetKey]: entry }, { skipMaintenance: true }),
).rejects.toThrow("Model-selection-locked sessions cannot be removed");
expect(loadSessionStore(storePath, { skipCache: true })[sourceKey]).toEqual(entry);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,366 +0,0 @@
// Store session key tests cover session key normalization through the accessor.
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { MsgContext } from "../../auto-reply/templating.js";
import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js";
import {
applySessionEntryLifecycleMutation,
listSessionEntries,
recordInboundSessionMeta,
updateSessionLastRoute,
} from "./session-accessor.js";
import type { InternalSessionEntry as SessionEntry } from "./types.js";
// Materializes the SQLite-backed session store as a keyed object so key
// normalization/migration assertions keep matching the old JSON-store shape.
function loadStore(storePath: string): Record<string, SessionEntry> {
return Object.fromEntries(
listSessionEntries({ storePath }).map(({ sessionKey, entry }) => [sessionKey, entry]),
);
}
// Seeds pre-existing rows under their exact (possibly legacy mixed-case) keys.
// replaceSessionEntry canonicalizes keys on write, so a lifecycle upsert is the
// only way to persist a legacy-keyed row the accessor should later migrate.
async function seedStore(storePath: string, entries: Record<string, SessionEntry>): Promise<void> {
await applySessionEntryLifecycleMutation({
storePath,
upserts: Object.entries(entries).map(([sessionKey, entry]) => ({ sessionKey, entry })),
skipMaintenance: true,
});
}
const CANONICAL_KEY = "agent:main:webchat:dm:mixed-user";
const MIXED_CASE_KEY = "Agent:Main:WebChat:DM:MiXeD-User";
const SIGNAL_GROUP_ID = "VWATodkf2hc8zdOS76q9Tb0+5Bi522E03qLdaQ/9ypg=";
const SIGNAL_GROUP_KEY = `agent:main:signal:group:${SIGNAL_GROUP_ID}`;
const LEGACY_SIGNAL_GROUP_KEY = SIGNAL_GROUP_KEY.toLowerCase();
function createInboundContext(): MsgContext {
return {
Provider: "webchat",
Surface: "webchat",
ChatType: "direct",
From: "WebChat:User-1",
To: "webchat:agent",
SessionKey: MIXED_CASE_KEY,
OriginatingTo: "webchat:user-1",
};
}
function createSignalGroupContext(): MsgContext {
return {
Provider: "signal",
Surface: "signal",
ChatType: "group",
From: `signal:group:${SIGNAL_GROUP_ID}`,
To: `signal:group:${SIGNAL_GROUP_ID}`,
SessionKey: SIGNAL_GROUP_KEY,
OriginatingTo: `signal:group:${SIGNAL_GROUP_ID}`,
};
}
function createRecoveryEntry(sessionId: string): SessionEntry {
return {
sessionId,
updatedAt: 1,
abortedLastRun: true,
restartRecoveryRuns: [
{ runId: "initial-wedged-run", lifecycleGeneration: "gen-1" },
{ runId: "recovery-run-1", lifecycleGeneration: "gen-2" },
],
mainRestartRecovery: {
cycleId: "cycle-1",
revision: 3,
chargedAttempts: 2,
},
subagentRecovery: {
automaticAttempts: 2,
lastAttemptAt: 3,
wedgedAt: 4,
wedgedReason: "automatic_attempt_budget_exceeded",
},
};
}
function expectRecoveryMarkers(entry: SessionEntry | undefined): void {
expect(entry).toMatchObject({
abortedLastRun: true,
restartRecoveryRuns: [
{ runId: "initial-wedged-run", lifecycleGeneration: "gen-1" },
{ runId: "recovery-run-1", lifecycleGeneration: "gen-2" },
],
mainRestartRecovery: {
cycleId: "cycle-1",
revision: 3,
chargedAttempts: 2,
},
subagentRecovery: {
automaticAttempts: 2,
lastAttemptAt: 3,
wedgedAt: 4,
wedgedReason: "automatic_attempt_budget_exceeded",
},
});
}
describe("session store key normalization", () => {
const suiteRootTracker = createSuiteTempRootTracker({
prefix: "openclaw-session-key-normalize-",
});
let tempDir = "";
let storePath = "";
beforeAll(async () => {
await suiteRootTracker.setup();
});
beforeEach(async () => {
tempDir = await suiteRootTracker.make("case");
storePath = path.join(tempDir, "sessions.json");
});
afterAll(async () => {
await suiteRootTracker.cleanup();
});
it("records inbound metadata under a canonical lowercase key", async () => {
await recordInboundSessionMeta({
storePath,
sessionKey: MIXED_CASE_KEY,
ctx: createInboundContext(),
});
const store = loadStore(storePath);
expect(Object.keys(store)).toEqual([CANONICAL_KEY]);
expect(store[CANONICAL_KEY]?.origin?.provider).toBe("webchat");
});
it("does not create a duplicate mixed-case key when last route is updated", async () => {
await recordInboundSessionMeta({
storePath,
sessionKey: CANONICAL_KEY,
ctx: createInboundContext(),
});
await updateSessionLastRoute({
storePath,
sessionKey: MIXED_CASE_KEY,
channel: "webchat",
to: "webchat:user-1",
});
const store = loadStore(storePath);
expect(Object.keys(store)).toEqual([CANONICAL_KEY]);
expect(store[CANONICAL_KEY]?.lastChannel).toBe("webchat");
expect(store[CANONICAL_KEY]?.lastTo).toBe("webchat:user-1");
expect(store[CANONICAL_KEY]?.route).toEqual({
channel: "webchat",
target: { to: "webchat:user-1" },
});
});
it("migrates legacy mixed-case entries to the canonical key on update", async () => {
await seedStore(storePath, {
[MIXED_CASE_KEY]: {
sessionId: "legacy-session",
updatedAt: 1,
chatType: "direct",
channel: "webchat",
},
});
await updateSessionLastRoute({
storePath,
sessionKey: CANONICAL_KEY,
channel: "webchat",
to: "webchat:user-2",
});
const store = loadStore(storePath);
expect(store[CANONICAL_KEY]?.sessionId).toBe("legacy-session");
expect(store[MIXED_CASE_KEY]).toBeUndefined();
});
it("preserves ACP metadata when inbound metadata normalizes a legacy key", async () => {
await seedStore(storePath, {
[CANONICAL_KEY]: {
sessionId: "canonical-session",
updatedAt: 2,
},
[MIXED_CASE_KEY]: {
sessionId: "legacy-session",
updatedAt: 1,
acp: {
backend: "codex",
agent: "main",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: 1,
},
},
});
// A real inbound context yields a non-empty metadata patch, which is what
// triggers the accessor to collapse the legacy mixed-case alias onto the
// canonical row (the SQLite writer canonicalizes aliases as part of the
// patch write rather than as a separate empty-write pass).
await recordInboundSessionMeta({
storePath,
sessionKey: CANONICAL_KEY,
ctx: createInboundContext(),
});
const store = loadStore(storePath);
expect(Object.keys(store)).toEqual([CANONICAL_KEY]);
expect(store[CANONICAL_KEY]?.sessionId).toBe("canonical-session");
expect(store[CANONICAL_KEY]?.acp).toBeUndefined();
});
it("preserves updatedAt when recording inbound metadata for an existing session", async () => {
const existingUpdatedAt = Date.now();
await seedStore(storePath, {
[CANONICAL_KEY]: {
sessionId: "existing-session",
updatedAt: existingUpdatedAt,
chatType: "direct",
channel: "webchat",
origin: {
provider: "webchat",
chatType: "direct",
from: "WebChat:User-1",
to: "webchat:user-1",
},
},
});
await recordInboundSessionMeta({
storePath,
sessionKey: CANONICAL_KEY,
ctx: createInboundContext(),
});
const store = loadStore(storePath);
expect(store[CANONICAL_KEY]?.sessionId).toBe("existing-session");
expect(store[CANONICAL_KEY]?.updatedAt).toBe(existingUpdatedAt);
expect(store[CANONICAL_KEY]?.origin?.provider).toBe("webchat");
});
it("preserves recovery markers when recording inbound metadata", async () => {
await seedStore(storePath, {
[CANONICAL_KEY]: createRecoveryEntry("recovered-session"),
});
await recordInboundSessionMeta({
storePath,
sessionKey: MIXED_CASE_KEY,
ctx: createInboundContext(),
});
const store = loadStore(storePath);
expectRecoveryMarkers(store[CANONICAL_KEY]);
expect(store[CANONICAL_KEY]?.origin?.provider).toBe("webchat");
});
it("preserves recovery markers when updating the last route", async () => {
await seedStore(storePath, {
[CANONICAL_KEY]: createRecoveryEntry("route-session"),
});
await updateSessionLastRoute({
storePath,
sessionKey: CANONICAL_KEY,
channel: "webchat",
to: "webchat:user-1",
});
const store = loadStore(storePath);
expectRecoveryMarkers(store[CANONICAL_KEY]);
expect(store[CANONICAL_KEY]?.lastTo).toBe("webchat:user-1");
});
it("records Signal group metadata under the mixed-case opaque group id", async () => {
await recordInboundSessionMeta({
storePath,
sessionKey: `Agent:Main:Signal:Group:${SIGNAL_GROUP_ID}`,
ctx: createSignalGroupContext(),
});
const store = loadStore(storePath);
expect(Object.keys(store)).toEqual([SIGNAL_GROUP_KEY]);
expect(store[SIGNAL_GROUP_KEY]?.groupId).toBe(SIGNAL_GROUP_ID);
expect(store[SIGNAL_GROUP_KEY]?.origin?.to).toBe(`signal:group:${SIGNAL_GROUP_ID}`);
});
it("migrates legacy lowercase Signal group keys to the mixed-case canonical key", async () => {
await seedStore(storePath, {
[LEGACY_SIGNAL_GROUP_KEY]: {
sessionId: "legacy-signal-session",
updatedAt: 1,
chatType: "group",
channel: "signal",
groupId: SIGNAL_GROUP_ID.toLowerCase(),
deliveryContext: {
channel: "signal",
to: `signal:group:${SIGNAL_GROUP_ID}`,
},
},
});
await recordInboundSessionMeta({
storePath,
sessionKey: SIGNAL_GROUP_KEY,
ctx: createSignalGroupContext(),
});
const store = loadStore(storePath);
expect(Object.keys(store)).toEqual([SIGNAL_GROUP_KEY]);
expect(store[SIGNAL_GROUP_KEY]?.sessionId).toBe("legacy-signal-session");
expect(store[SIGNAL_GROUP_KEY]?.groupId).toBe(SIGNAL_GROUP_ID);
expect(store[LEGACY_SIGNAL_GROUP_KEY]).toBeUndefined();
});
it("stores canonical route metadata and derives legacy delivery fields", async () => {
await updateSessionLastRoute({
storePath,
sessionKey: CANONICAL_KEY,
route: {
channel: "slack",
accountId: "work",
target: { to: "channel:C123", rawTo: "slack://C123", chatType: "channel" },
thread: { id: "177000.123", kind: "thread", source: "target" },
},
deliveryContext: {
channel: "discord",
to: "channel:old",
threadId: "old-thread",
},
});
const store = loadStore(storePath);
expect(store[CANONICAL_KEY]?.route).toEqual({
channel: "slack",
accountId: "work",
target: { to: "channel:C123", rawTo: "slack://C123", chatType: "channel" },
thread: { id: "177000.123", kind: "thread", source: "target" },
});
expect(store[CANONICAL_KEY]?.deliveryContext).toEqual({
channel: "slack",
to: "channel:C123",
accountId: "work",
threadId: "177000.123",
});
expect(store[CANONICAL_KEY]?.lastChannel).toBe("slack");
expect(store[CANONICAL_KEY]?.lastTo).toBe("channel:C123");
expect(store[CANONICAL_KEY]?.lastAccountId).toBe("work");
expect(store[CANONICAL_KEY]?.lastThreadId).toBe("177000.123");
});
// NOTE: the file-store test "normalizes malformed persisted route metadata on
// load" was removed with the SQLite flip. It asserted file-store load-time
// repair of a legacy malformed `route` string (store-load.ts
// normalizeSessionEntryDelivery). The SQLite runtime reads canonical shapes
// only and does not repair legacy `route` slots on read; that legacy-data
// repair is owned by doctor/migration, not steady-state runtime reads.
});
@@ -1,908 +0,0 @@
// SQLite session lifecycle operations own entry mutation.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js";
import { onInternalSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js";
import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import { readSessionArchiveContentSync } from "./archive-compression.js";
import {
applySessionEntryLifecycleMutation,
cleanupSessionLifecycleArtifacts,
deleteSessionEntryLifecycle,
listSessionEntries,
loadTranscriptEvents,
loadSessionEntry,
replaceSessionEntry,
resetSessionEntryLifecycle,
} from "./session-accessor.js";
import { replaceSqliteTranscriptEvents } from "./session-accessor.sqlite.js";
import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js";
import { searchSessionTranscripts } from "./session-transcript-search.js";
import type { SessionEntry } from "./types.js";
type TestTranscriptEvent = Parameters<typeof replaceSqliteTranscriptEvents>[1][number];
describe("session store lifecycle mutations", () => {
let tempDir: string;
let storePath: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-lifecycle-mutation-"));
storePath = path.join(tempDir, "agents", "main", "sessions", "sessions.json");
});
afterEach(() => {
closeOpenClawAgentDatabasesForTest();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("resets the live entry while keeping previous SQLite history searchable", async () => {
const now = Date.now();
await replaceSessionEntry(
{ sessionKey: "agent:main:room", storePath },
{
compactionCheckpoints: [
{
checkpointId: "checkpoint-1",
createdAt: now,
postCompaction: { sessionId: "post-compaction-session" },
preCompaction: { sessionId: "pre-compaction-session" },
reason: "manual",
sessionId: "checkpoint-session",
sessionKey: "agent:main:room",
},
],
sessionId: "old-session",
updatedAt: now,
usageFamilySessionIds: ["old-session", "usage-family-session"],
},
);
for (const sessionId of [
"old-session",
"usage-family-session",
"checkpoint-session",
"pre-compaction-session",
"post-compaction-session",
]) {
await replaceSqliteTranscriptEvents({ sessionKey: "agent:main:room", sessionId, storePath }, [
sessionId === "old-session"
? createSearchableTranscriptEvent(sessionId, "foreverneedle before reset")
: createTranscriptEvent(sessionId, `before reset ${sessionId}`),
]);
}
const transcriptUpdates = recordTranscriptUpdateFiles();
let callbackTranscriptEvents: TestTranscriptEvent[] = [];
const result = await resetSessionEntryLifecycle({
storePath,
target: {
canonicalKey: "agent:main:room",
storeKeys: ["agent:main:room", "Agent:Main:Room"],
},
buildNextEntry: (): SessionEntry => ({
sessionId: "next-session",
updatedAt: now + 1,
systemSent: false,
abortedLastRun: false,
}),
afterEntryMutation: async () => {
callbackTranscriptEvents = await loadTranscriptEvents({
sessionKey: "agent:main:room",
sessionId: "old-session",
storePath,
});
},
});
transcriptUpdates.unsubscribe();
const stored = loadSessionEntry({ sessionKey: "agent:main:room", storePath });
expect(stored?.sessionId).toBe("next-session");
expect(result.previousSessionId).toBe("old-session");
expect(result.archivedTranscripts).toEqual([]);
expect(transcriptUpdates.files).toEqual([]);
expect(callbackTranscriptEvents).toEqual([
createSearchableTranscriptEvent("old-session", "foreverneedle before reset"),
]);
expect(listSessionEntries({ storePath })).toEqual([
{
sessionKey: "agent:main:room",
entry: expect.objectContaining({ sessionId: "next-session" }),
},
]);
expect(readArchiveNames(path.dirname(storePath), "old-session.jsonl.reset.")).toEqual([]);
expect(
searchSessionTranscripts({
agentId: "main",
env: { ...process.env, OPENCLAW_STATE_DIR: tempDir },
query: "foreverneedle",
sessionKeys: ["agent:main:room"],
}).hits,
).toEqual([
expect.objectContaining({
sessionId: "old-session",
sessionKey: "agent:main:room",
}),
]);
await expect(
loadTranscriptEvents({ sessionKey: "agent:main:room", sessionId: "old-session", storePath }),
).resolves.toHaveLength(1);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:room",
sessionId: "usage-family-session",
storePath,
}),
).resolves.toHaveLength(1);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:room",
sessionId: "pre-compaction-session",
storePath,
}),
).resolves.toHaveLength(1);
});
it("keeps old SQLite rows when a post-reset callback fails", async () => {
const now = Date.now();
await replaceSessionEntry(
{ sessionKey: "agent:main:callback-failure", storePath },
{
sessionId: "callback-old-session",
updatedAt: now,
},
);
await replaceSqliteTranscriptEvents(
{
sessionKey: "agent:main:callback-failure",
sessionId: "callback-old-session",
storePath,
},
[createTranscriptEvent("callback-old-session", "before callback failure")],
);
await expect(
resetSessionEntryLifecycle({
storePath,
target: {
canonicalKey: "agent:main:callback-failure",
storeKeys: ["agent:main:callback-failure"],
},
buildNextEntry: (): SessionEntry => ({
sessionId: "callback-next-session",
updatedAt: now + 1,
}),
afterEntryMutation: () => {
throw new Error("callback failed");
},
}),
).rejects.toThrow("callback failed");
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:callback-failure",
sessionId: "callback-old-session",
storePath,
}),
).resolves.toHaveLength(1);
expect(
readArchiveNames(path.dirname(storePath), "callback-old-session.jsonl.reset."),
).toHaveLength(0);
});
it("explicit delete archives and removes every retained reset generation", async () => {
const sessionKey = "agent:main:delete-history";
const sessionIds = ["delete-history-one", "delete-history-two", "delete-history-three"];
await replaceSessionEntry(
{ sessionKey, storePath },
{ sessionId: "delete-history-one", updatedAt: 1 },
);
for (const [index, sessionId] of sessionIds.entries()) {
await replaceSqliteTranscriptEvents({ sessionId, sessionKey, storePath }, [
createSearchableTranscriptEvent(sessionId, `deleteforever generation ${index + 1}`),
]);
const nextSessionId = sessionIds[index + 1];
if (nextSessionId) {
await resetSessionEntryLifecycle({
storePath,
target: { canonicalKey: sessionKey, storeKeys: [sessionKey] },
buildNextEntry: () => ({ sessionId: nextSessionId, updatedAt: index + 2 }),
});
}
}
expect(
searchSessionTranscripts({
agentId: "main",
env: { ...process.env, OPENCLAW_STATE_DIR: tempDir },
query: "deleteforever",
sessionKeys: [sessionKey],
}).hits,
).toHaveLength(3);
const result = await deleteSessionEntryLifecycle({
archiveTranscript: true,
storePath,
target: { canonicalKey: sessionKey, storeKeys: [sessionKey] },
});
expect(result.deleted).toBe(true);
expect(result.archivedTranscripts).toHaveLength(3);
expect(listSessionEntries({ storePath })).toEqual([]);
for (const sessionId of sessionIds) {
await expect(loadTranscriptEvents({ sessionId, sessionKey, storePath })).resolves.toEqual([]);
expect(readArchiveNames(path.dirname(storePath), `${sessionId}.jsonl.deleted.`)).toHaveLength(
1,
);
}
expect(
searchSessionTranscripts({
agentId: "main",
env: { ...process.env, OPENCLAW_STATE_DIR: tempDir },
query: "deleteforever",
sessionKeys: [sessionKey],
}).hits,
).toEqual([]);
});
it("explicit delete aborts while admitted work owns a retained generation", async () => {
const sessionKey = "agent:main:delete-admitted-history";
await replaceSessionEntry({ sessionKey, storePath }, { sessionId: "admit-old", updatedAt: 1 });
await replaceSqliteTranscriptEvents({ sessionId: "admit-old", sessionKey, storePath }, [
createSearchableTranscriptEvent("admit-old", "admitted generation"),
]);
await resetSessionEntryLifecycle({
storePath,
target: { canonicalKey: sessionKey, storeKeys: [sessionKey] },
buildNextEntry: () => ({ sessionId: "admit-live", updatedAt: 2 }),
});
const admission = await beginSessionWorkAdmission({
scope: storePath,
identities: ["admit-old"],
assertAllowed: () => {},
});
try {
await expect(
deleteSessionEntryLifecycle({
archiveTranscript: true,
storePath,
target: { canonicalKey: sessionKey, storeKeys: [sessionKey] },
}),
).rejects.toThrow(/work is in flight/);
expect(listSessionEntries({ storePath })).toHaveLength(1);
await expect(
loadTranscriptEvents({ sessionId: "admit-old", sessionKey, storePath }),
).resolves.toHaveLength(1);
} finally {
admission.release();
}
const result = await deleteSessionEntryLifecycle({
archiveTranscript: true,
storePath,
target: { canonicalKey: sessionKey, storeKeys: [sessionKey] },
});
expect(result.deleted).toBe(true);
// Only admit-old carries transcript events; admit-live never wrote any.
expect(result.archivedTranscripts).toHaveLength(1);
expect(readArchiveNames(path.dirname(storePath), "admit-old.jsonl.deleted.")).toHaveLength(1);
expect(listSessionEntries({ storePath })).toEqual([]);
});
it("deletes an entry from SQLite while archiving unreferenced transcript rows", async () => {
const now = Date.now();
await replaceSessionEntry(
{ sessionKey: "agent:main:keep", storePath },
{
sessionId: "keep-session",
updatedAt: now,
},
);
await replaceSessionEntry(
{ sessionKey: "agent:main:delete", storePath },
{
sessionId: "delete-session",
updatedAt: now - 1,
usageFamilySessionIds: ["delete-session", "delete-ancestor-session"],
},
);
for (const sessionId of ["delete-session", "delete-ancestor-session"]) {
await replaceSqliteTranscriptEvents(
{ sessionKey: "agent:main:delete", sessionId, storePath },
[createTranscriptEvent(sessionId, `before delete ${sessionId}`)],
);
}
const transcriptUpdates = recordTranscriptUpdateFiles();
const result = await deleteSessionEntryLifecycle({
archiveTranscript: true,
storePath,
target: {
canonicalKey: "agent:main:delete",
storeKeys: ["agent:main:delete"],
},
});
transcriptUpdates.unsubscribe();
expect(result.deleted).toBe(true);
expect(result.deletedSessionId).toBe("delete-session");
expect(result.archivedTranscripts).toHaveLength(2);
expect(result.archivedTranscripts.map((transcript) => transcript.archivedPath)).toEqual(
expect.arrayContaining([
expect.stringContaining("delete-session.jsonl.deleted."),
expect.stringContaining("delete-ancestor-session.jsonl.deleted."),
]),
);
expect(transcriptUpdates.files).toContain(result.archivedTranscripts[0]?.archivedPath);
expect(readArchiveLinesForSession(result, "delete-session")).toEqual([
createTranscriptEventLine("delete-session", "before delete delete-session"),
]);
expect(readArchiveLinesForSession(result, "delete-ancestor-session")).toEqual([
createTranscriptEventLine("delete-ancestor-session", "before delete delete-ancestor-session"),
]);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:delete",
sessionId: "delete-session",
storePath,
}),
).resolves.toEqual([]);
expect(loadSessionEntry({ sessionKey: "agent:main:delete", storePath })).toBeUndefined();
expect(loadSessionEntry({ sessionKey: "agent:main:keep", storePath })?.sessionId).toBe(
"keep-session",
);
});
it("deletes transcript search state with archived session rows", async () => {
const sessionId = "delete-indexed-session";
await replaceSessionEntry(
{ sessionKey: "agent:main:delete-indexed", storePath },
{ sessionId, updatedAt: Date.now() },
);
await replaceSqliteTranscriptEvents(
{ sessionKey: "agent:main:delete-indexed", sessionId, storePath },
[
{
type: "message",
id: "delete-indexed-message",
parentId: null,
message: {
role: "user",
content: [{ type: "text", text: "remove this searchable transcript" }],
},
timestamp: Date.now(),
} as unknown as TestTranscriptEvent,
],
);
const database = openLifecycleTestDatabase(storePath);
const db = getNodeSqliteKysely<OpenClawAgentKyselyDatabase>(database.db);
const readSearchState = () => ({
fts: executeSqliteQuerySync(
database.db,
db
.selectFrom("session_transcript_fts")
.select("session_id")
.where("session_id", "=", sessionId),
).rows,
watermarks: executeSqliteQuerySync(
database.db,
db
.selectFrom("session_transcript_index_state")
.select("session_id")
.where("session_id", "=", sessionId),
).rows,
});
expect(readSearchState()).toEqual({
fts: [{ session_id: sessionId }],
watermarks: [{ session_id: sessionId }],
});
const result = await deleteSessionEntryLifecycle({
archiveTranscript: true,
storePath,
target: {
canonicalKey: "agent:main:delete-indexed",
storeKeys: ["agent:main:delete-indexed"],
},
});
expect(result.deleted).toBe(true);
expect(readSearchState()).toEqual({ fts: [], watermarks: [] });
});
it("fsyncs SQLite transcript archives through their writable descriptor before deletion", async () => {
const now = Date.now();
await replaceSessionEntry(
{ sessionKey: "agent:main:durable-delete", storePath },
{
sessionId: "durable-delete-session",
updatedAt: now,
},
);
await replaceSqliteTranscriptEvents(
{ sessionKey: "agent:main:durable-delete", sessionId: "durable-delete-session", storePath },
[createTranscriptEvent("durable-delete-session", "durable archive first")],
);
const originalRenameSync = fs.renameSync;
const entryObservedDuringArchiveRename: boolean[] = [];
const openSpy = vi.spyOn(fs, "openSync");
const fsyncSpy = vi.spyOn(fs, "fsyncSync");
const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((...args) => {
const archivePath = String(args[1]);
if (archivePath.includes("durable-delete-session.jsonl.deleted.")) {
entryObservedDuringArchiveRename.push(
loadSessionEntry({ sessionKey: "agent:main:durable-delete", storePath })?.sessionId ===
"durable-delete-session",
);
}
return originalRenameSync(...args);
});
try {
const result = await deleteSessionEntryLifecycle({
archiveTranscript: true,
storePath,
target: {
canonicalKey: "agent:main:durable-delete",
storeKeys: ["agent:main:durable-delete"],
},
});
expect(result.deleted).toBe(true);
expect(result.archivedTranscripts).toHaveLength(1);
expect(entryObservedDuringArchiveRename).toEqual([true]);
const archiveTempOpenIndexes = openSpy.mock.calls.flatMap((args, index) =>
String(args[0]).includes("durable-delete-session.jsonl.deleted.") && args[1] === "wx"
? [index]
: [],
);
expect(archiveTempOpenIndexes).toHaveLength(1);
const archiveTempOpenIndex = archiveTempOpenIndexes[0] ?? -1;
expect(openSpy.mock.calls[archiveTempOpenIndex]?.[1]).toBe("wx");
expect(fsyncSpy).toHaveBeenCalledWith(openSpy.mock.results[archiveTempOpenIndex]?.value);
} finally {
renameSpy.mockRestore();
fsyncSpy.mockRestore();
openSpy.mockRestore();
}
});
it("probes duplicate SQLite transcript archives before deleting entry rows", async () => {
const now = Date.now();
await replaceSessionEntry(
{ sessionKey: "agent:main:duplicate-archive", storePath },
{
sessionId: "duplicate-archive-session",
updatedAt: now,
},
);
await replaceSqliteTranscriptEvents(
{
sessionKey: "agent:main:duplicate-archive",
sessionId: "duplicate-archive-session",
storePath,
},
[createTranscriptEvent("duplicate-archive-session", "reuse archive")],
);
const archivePath = path.join(
path.dirname(storePath),
"duplicate-archive-session.jsonl.deleted.2026-01-01T00-00-00.000Z",
);
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(
archivePath,
`${createTranscriptEventLine("duplicate-archive-session", "reuse archive")}\n`,
"utf-8",
);
const originalReaddirSync = fs.readdirSync;
const entryObservedDuringDuplicateProbe: boolean[] = [];
const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation((...args) => {
const dirPath = String(args[0]);
if (dirPath === path.dirname(storePath)) {
entryObservedDuringDuplicateProbe.push(
loadSessionEntry({ sessionKey: "agent:main:duplicate-archive", storePath })?.sessionId ===
"duplicate-archive-session",
);
}
return originalReaddirSync(...args);
});
try {
const result = await deleteSessionEntryLifecycle({
archiveTranscript: true,
storePath,
target: {
canonicalKey: "agent:main:duplicate-archive",
storeKeys: ["agent:main:duplicate-archive"],
},
});
expect(result.deleted).toBe(true);
expect(result.archivedTranscripts).toEqual([
{
archivedPath: archivePath,
sourcePath: path.join(path.dirname(storePath), "duplicate-archive-session.jsonl"),
},
]);
expect(entryObservedDuringDuplicateProbe).toEqual([true]);
} finally {
readdirSpy.mockRestore();
}
});
it("deletes a SQLite entry without deleting transcripts when archiveTranscript is false", async () => {
const now = Date.now();
await replaceSessionEntry(
{ sessionKey: "agent:main:delete-entry-only", storePath },
{
sessionId: "entry-only-session",
updatedAt: now,
},
);
await replaceSqliteTranscriptEvents(
{ sessionKey: "agent:main:delete-entry-only", sessionId: "entry-only-session", storePath },
[createTranscriptEvent("entry-only-session", "preserve transcript rows")],
);
const transcriptUpdates = recordTranscriptUpdateFiles();
const result = await deleteSessionEntryLifecycle({
archiveTranscript: false,
storePath,
target: {
canonicalKey: "agent:main:delete-entry-only",
storeKeys: ["agent:main:delete-entry-only"],
},
});
transcriptUpdates.unsubscribe();
expect(result.deleted).toBe(true);
expect(result.archivedTranscripts).toEqual([]);
expect(transcriptUpdates.files).toEqual([]);
expect(
loadSessionEntry({ sessionKey: "agent:main:delete-entry-only", storePath }),
).toBeUndefined();
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:delete-entry-only",
sessionId: "entry-only-session",
storePath,
}),
).resolves.toEqual([createTranscriptEvent("entry-only-session", "preserve transcript rows")]);
});
it("deletes SQLite transcript rows for non-archived lifecycle removals", async () => {
const now = Date.now();
const entry: SessionEntry = {
sessionId: "lifecycle-remove-no-archive-session",
updatedAt: now,
};
await replaceSessionEntry({ sessionKey: "agent:main:no-archive-removal", storePath }, entry);
await replaceSqliteTranscriptEvents(
{
sessionKey: "agent:main:no-archive-removal",
sessionId: "lifecycle-remove-no-archive-session",
storePath,
},
[createTranscriptEvent("lifecycle-remove-no-archive-session", "remove rows without archive")],
);
const result = await applySessionEntryLifecycleMutation({
storePath,
removals: [
{
sessionKey: "agent:main:no-archive-removal",
expectedEntry: entry,
archiveRemovedTranscript: false,
},
],
maintenanceOverride: { mode: "enforce" },
});
expect(result.removedSessionKeys).toEqual(["agent:main:no-archive-removal"]);
expect(result.archivedTranscriptDirectories).toEqual([]);
expect(
readArchiveNames(
path.dirname(storePath),
"lifecycle-remove-no-archive-session.jsonl.deleted.",
),
).toEqual([]);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:no-archive-removal",
sessionId: "lifecycle-remove-no-archive-session",
storePath,
}),
).resolves.toEqual([]);
});
it("archives shared SQLite transcript rows when any lifecycle removal requests archive", async () => {
const now = Date.now();
const entry: SessionEntry = {
sessionId: "mixed-archive-shared-session",
updatedAt: now,
};
await replaceSessionEntry({ sessionKey: "agent:main:mixed-archive-a", storePath }, entry);
await replaceSessionEntry({ sessionKey: "agent:main:mixed-archive-b", storePath }, entry);
await replaceSqliteTranscriptEvents(
{
sessionKey: "agent:main:mixed-archive-a",
sessionId: "mixed-archive-shared-session",
storePath,
},
[createTranscriptEvent("mixed-archive-shared-session", "shared mixed archive")],
);
const result = await applySessionEntryLifecycleMutation({
storePath,
removals: [
{
sessionKey: "agent:main:mixed-archive-a",
expectedEntry: entry,
archiveRemovedTranscript: false,
},
{
sessionKey: "agent:main:mixed-archive-b",
expectedEntry: entry,
archiveRemovedTranscript: true,
},
],
skipMaintenance: true,
});
expect(result.removedSessionKeys).toEqual([
"agent:main:mixed-archive-a",
"agent:main:mixed-archive-b",
]);
expect(result.archivedTranscriptDirectories).toEqual([path.dirname(storePath)]);
const archiveNames = readArchiveNames(
path.dirname(storePath),
"mixed-archive-shared-session.jsonl.deleted.",
);
expect(archiveNames).toHaveLength(1);
expect(readArchiveLines(path.join(path.dirname(storePath), archiveNames[0] ?? ""))).toEqual([
createTranscriptEventLine("mixed-archive-shared-session", "shared mixed archive"),
]);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:mixed-archive-a",
sessionId: "mixed-archive-shared-session",
storePath,
}),
).resolves.toEqual([]);
});
it("forced maintenance preserves raw SQLite transcript-only rows", async () => {
await replaceSqliteTranscriptEvents(
{
sessionKey: "agent:main:raw-maintenance",
sessionId: "raw-maintenance-session",
storePath,
},
[createTranscriptEvent("raw-maintenance-session", "raw transcript-only row")],
);
const result = await applySessionEntryLifecycleMutation({
storePath,
activeSessionKey: "agent:main:raw-maintenance",
maintenanceOverride: { mode: "enforce" },
});
expect(result.archivedTranscriptDirectories).toEqual([]);
expect(
readArchiveNames(path.dirname(storePath), "raw-maintenance-session.jsonl.deleted."),
).toEqual([]);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:raw-maintenance",
sessionId: "raw-maintenance-session",
storePath,
}),
).resolves.toEqual([
createTranscriptEvent("raw-maintenance-session", "raw transcript-only row"),
]);
});
it("preserves shared SQLite transcript rows until the final session reference is deleted", async () => {
const now = Date.now();
await replaceSessionEntry(
{ sessionKey: "agent:main:first", storePath },
{
sessionId: "shared-session",
updatedAt: now,
},
);
await replaceSessionEntry(
{ sessionKey: "agent:main:second", storePath },
{
sessionId: "shared-session",
updatedAt: now - 1,
},
);
await replaceSqliteTranscriptEvents(
{ sessionKey: "agent:main:first", sessionId: "shared-session", storePath },
[createTranscriptEvent("shared-session", "shared transcript")],
);
const first = await deleteSessionEntryLifecycle({
archiveTranscript: true,
storePath,
target: {
canonicalKey: "agent:main:first",
storeKeys: ["agent:main:first"],
},
});
expect(first.deleted).toBe(true);
expect(first.archivedTranscripts).toEqual([]);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:second",
sessionId: "shared-session",
storePath,
}),
).resolves.toEqual([createTranscriptEvent("shared-session", "shared transcript")]);
const second = await deleteSessionEntryLifecycle({
archiveTranscript: true,
storePath,
target: {
canonicalKey: "agent:main:second",
storeKeys: ["agent:main:second"],
},
});
expect(second.deleted).toBe(true);
expect(second.archivedTranscripts).toHaveLength(1);
expect(readArchiveLines(second.archivedTranscripts[0]?.archivedPath)).toEqual([
createTranscriptEventLine("shared-session", "shared transcript"),
]);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:second",
sessionId: "shared-session",
storePath,
}),
).resolves.toEqual([]);
});
it("preserves raw SQLite entry references during lifecycle cleanup", async () => {
const sessionId = "raw-shared-session";
await replaceSessionEntry(
{ sessionKey: "agent:main:cleanup-target", storePath },
{ sessionId, updatedAt: Date.now() },
);
await replaceSessionEntry(
{ sessionKey: "agent:main:protected-raw-reference", storePath },
{ sessionId, updatedAt: Date.now() },
);
await replaceSqliteTranscriptEvents(
{ sessionKey: "agent:main:cleanup-target", sessionId, storePath },
[createTranscriptEvent(sessionId, "cleanup-marker shared transcript")],
);
const database = openLifecycleTestDatabase(storePath);
const db = getNodeSqliteKysely<OpenClawAgentKyselyDatabase>(database.db);
executeSqliteQuerySync(
database.db,
db
.updateTable("session_entries")
.set({ entry_json: "{not valid json" })
.where("session_key", "=", "agent:main:protected-raw-reference"),
);
const result = await cleanupSessionLifecycleArtifacts({
storePath,
sessionKeySegmentPrefix: "cleanup-target",
transcriptContentMarker: "cleanup-marker",
orphanTranscriptMinAgeMs: 0,
nowMs: Date.now() + 1,
});
expect(result).toEqual({ archivedTranscriptArtifacts: 0, removedEntries: 1 });
expect(
executeSqliteQueryTakeFirstSync(
database.db,
db
.selectFrom("session_entries")
.select("session_key")
.where("session_key", "=", "agent:main:cleanup-target"),
),
).toBeUndefined();
expect(
executeSqliteQueryTakeFirstSync(
database.db,
db
.selectFrom("session_entries")
.select(["entry_json", "session_id"])
.where("session_key", "=", "agent:main:protected-raw-reference"),
),
).toEqual({
entry_json: "{not valid json",
session_id: sessionId,
});
expect(
executeSqliteQueryTakeFirstSync(
database.db,
db.selectFrom("sessions").select("session_id").where("session_id", "=", sessionId),
)?.session_id,
).toBe(sessionId);
});
});
function createTranscriptEvent(sessionId: string, content: string): TestTranscriptEvent {
return JSON.parse(createTranscriptEventLine(sessionId, content)) as TestTranscriptEvent;
}
function createSearchableTranscriptEvent(sessionId: string, content: string): TestTranscriptEvent {
return {
id: `message-${sessionId}`,
message: { role: "user", content },
timestamp: "2026-07-18T00:00:00.000Z",
type: "message",
} as TestTranscriptEvent;
}
function createTranscriptEventLine(sessionId: string, content: string): string {
return JSON.stringify({
type: "session",
id: sessionId,
content,
});
}
function readArchiveLines(archivePath: string | undefined): string[] {
expect(archivePath).toBeTruthy();
return readSessionArchiveContentSync(archivePath ?? "")
.trim()
.split("\n");
}
function readArchiveNames(archiveDirectory: string, prefix: string): string[] {
if (!fs.existsSync(archiveDirectory)) {
return [];
}
return fs.readdirSync(archiveDirectory).filter((file) => file.startsWith(prefix));
}
function readArchiveLinesForSession(
result: { archivedTranscripts: Array<{ archivedPath: string }> },
sessionId: string,
): string[] {
return readArchiveLines(
result.archivedTranscripts.find((transcript) =>
transcript.archivedPath.includes(`${sessionId}.jsonl.`),
)?.archivedPath,
);
}
function recordTranscriptUpdateFiles(): { files: string[]; unsubscribe: () => void } {
const files: string[] = [];
return {
files,
unsubscribe: onInternalSessionTranscriptUpdate((update) => {
if (update.sessionFile) {
files.push(update.sessionFile);
}
}),
};
}
function openLifecycleTestDatabase(storePath: string) {
const target = resolveSqliteTargetFromSessionStorePath(storePath);
if (!target.path) {
throw new Error(`Could not resolve SQLite database path for ${storePath}`);
}
return openOpenClawAgentDatabase({
agentId: target.agentId ?? "main",
path: target.path,
});
}
@@ -1,441 +0,0 @@
// Session store skill stripping tests cover omitting skill payloads from persisted state.
import type { MakeDirectoryOptions, Mode, PathLike } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { Skill } from "../../skills/loading/skill-contract.js";
import { createCanonicalFixtureSkill } from "../../skills/test-support/test-helpers.js";
import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js";
import type { SessionEntry, SessionSkillSnapshot } from "./types.js";
vi.mock("../config.js", async () => ({
...(await vi.importActual<typeof import("../config.js")>("../config.js")),
getRuntimeConfig: vi.fn().mockReturnValue({}),
}));
import {
clearSessionStoreCacheForTest,
loadSessionStore,
saveSessionStore,
updateSessionStore,
} from "./store.js";
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-skills-strip-" });
function makeFixtureSkill(name: string, bodySize = 3000): Skill {
// 3KB body simulates a realistic SKILL.md.
const source = `# ${name}\n\n${"x".repeat(bodySize)}`;
return createCanonicalFixtureSkill({
name,
description: `${name} skill description`,
filePath: `/skills/${name}/SKILL.md`,
baseDir: `/skills/${name}`,
source,
});
}
function makeSnapshot(skillCount: number): SessionSkillSnapshot {
const resolved = Array.from({ length: skillCount }, (_, i) => makeFixtureSkill(`skill-${i}`));
return {
prompt: "<available_skills>...</available_skills>",
skills: resolved.map((s) => ({ name: s.name })),
skillFilter: undefined,
resolvedSkills: resolved,
version: 1,
};
}
function makeSnapshotWithPrompt(prompt: string): SessionSkillSnapshot {
return {
...makeSnapshot(2),
prompt,
};
}
function makeEntry(sessionId: string, snapshot?: SessionSkillSnapshot): SessionEntry {
return {
sessionId,
updatedAt: Date.now(),
skillsSnapshot: snapshot,
};
}
describe("session store strips resolvedSkills from persistence", () => {
let testDir: string;
let storePath: string;
let savedCacheTtl: string | undefined;
beforeAll(async () => {
await suiteRootTracker.setup();
});
afterAll(async () => {
await suiteRootTracker.cleanup();
});
beforeEach(async () => {
testDir = await suiteRootTracker.make("case");
storePath = path.join(testDir, "sessions.json");
savedCacheTtl = process.env.OPENCLAW_SESSION_CACHE_TTL_MS;
process.env.OPENCLAW_SESSION_CACHE_TTL_MS = "0";
clearSessionStoreCacheForTest();
});
afterEach(() => {
clearSessionStoreCacheForTest();
if (savedCacheTtl === undefined) {
delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS;
} else {
process.env.OPENCLAW_SESSION_CACHE_TTL_MS = savedCacheTtl;
}
});
it("does not write resolvedSkills to disk", async () => {
const store = {
"agent:main:test:1": makeEntry("session-1", makeSnapshot(5)),
};
await saveSessionStore(storePath, store, { skipMaintenance: true });
const raw = await fs.readFile(storePath, "utf-8");
expect(raw).not.toContain("resolvedSkills");
expect(raw).not.toContain("xxxxx"); // none of the skill source bodies leaked
const parsed = JSON.parse(raw) as Record<string, SessionEntry>;
expect(parsed["agent:main:test:1"]?.skillsSnapshot?.resolvedSkills).toBeUndefined();
});
it("preserves prompt, skills, skillFilter, and version on roundtrip", async () => {
const snapshot = makeSnapshot(3);
snapshot.skillFilter = ["skill-0"];
const store = {
"agent:main:test:1": makeEntry("session-1", snapshot),
};
await saveSessionStore(storePath, store, { skipMaintenance: true });
const loaded = loadSessionStore(storePath, { skipCache: true });
const persistedSnapshot = loaded["agent:main:test:1"]?.skillsSnapshot;
expect(persistedSnapshot?.prompt).toBe(snapshot.prompt);
expect(persistedSnapshot?.skills).toEqual(snapshot.skills);
expect(persistedSnapshot?.skillFilter).toEqual(["skill-0"]);
expect(persistedSnapshot?.version).toBe(1);
expect(persistedSnapshot?.resolvedSkills).toBeUndefined();
});
it("strips resolvedSkills from a legacy sessions.json on load", async () => {
// Hand-craft a pre-fix file with embedded resolvedSkills.
const legacy = {
"agent:main:test:1": makeEntry("session-1", makeSnapshot(4)),
};
await fs.mkdir(path.dirname(storePath), { recursive: true });
const rawLegacy = JSON.stringify(legacy, null, 2);
expect(rawLegacy).toContain("resolvedSkills");
await fs.writeFile(storePath, rawLegacy, "utf-8");
const loaded = loadSessionStore(storePath, { skipCache: true });
expect(loaded["agent:main:test:1"]?.skillsSnapshot?.resolvedSkills).toBeUndefined();
expect(loaded["agent:main:test:1"]?.skillsSnapshot?.prompt).toBe(
legacy["agent:main:test:1"].skillsSnapshot?.prompt,
);
// Saving the loaded record should rewrite the file in stripped form.
await saveSessionStore(storePath, loaded, { skipMaintenance: true });
const rawAfter = await fs.readFile(storePath, "utf-8");
expect(rawAfter).not.toContain("resolvedSkills");
});
it("strips resolvedSkills written via updateSessionStore mutator", async () => {
// Simulate the production hot path where ensureSkillSnapshot puts a
// freshly-built snapshot (with resolvedSkills) into the store via mutator.
await updateSessionStore(
storePath,
(store) => {
store["agent:main:test:1"] = makeEntry("session-1", makeSnapshot(6));
},
{ skipMaintenance: true },
);
const raw = await fs.readFile(storePath, "utf-8");
expect(raw).not.toContain("resolvedSkills");
const reloaded = loadSessionStore(storePath, { skipCache: true });
expect(reloaded["agent:main:test:1"]?.skillsSnapshot?.resolvedSkills).toBeUndefined();
expect(reloaded["agent:main:test:1"]?.skillsSnapshot?.skills).toHaveLength(6);
});
it("keeps the on-disk file small with many sessions and skills", async () => {
const SESSION_COUNT = 100;
const SKILLS_PER_SESSION = 50;
const store: Record<string, SessionEntry> = {};
for (let i = 0; i < SESSION_COUNT; i += 1) {
store[`agent:main:scale:${i}`] = makeEntry(`session-${i}`, makeSnapshot(SKILLS_PER_SESSION));
}
await saveSessionStore(storePath, store, { skipMaintenance: true });
const stat = await fs.stat(storePath);
// Pre-fix: ~SESSION_COUNT * SKILLS_PER_SESSION * ~3KB ≈ 15MB.
// Post-fix: only the lightweight `skills` array + prompt per entry.
// Conservative budget that comfortably covers metadata growth.
expect(stat.size).toBeLessThan(2 * 1024 * 1024);
});
it("stores duplicate large skills prompts as one content-addressed blob", async () => {
const prompt = `<available_skills>\n${"skill prompt body\n".repeat(200)}</available_skills>`;
await saveSessionStore(
storePath,
{
"agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)),
"agent:main:test:2": makeEntry("session-2", makeSnapshotWithPrompt(prompt)),
},
{ skipMaintenance: true },
);
const parsed = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
SessionEntry
>;
const firstRef = parsed["agent:main:test:1"]?.skillsSnapshot?.promptRef;
const secondRef = parsed["agent:main:test:2"]?.skillsSnapshot?.promptRef;
expect(firstRef).toBeDefined();
expect(secondRef).toEqual(firstRef);
expect(parsed["agent:main:test:1"]?.skillsSnapshot?.prompt).toBeUndefined();
if (!firstRef) {
throw new Error("expected prompt ref");
}
const blobPath = path.join(
testDir,
"skills-prompts",
"sha256",
firstRef.hash.slice(0, 2),
`${firstRef.hash}.txt`,
);
expect(await fs.readFile(blobPath, "utf-8")).toBe(prompt);
});
it("hydrates content-addressed skills prompt blobs on load", async () => {
const prompt = `<available_skills>\n${"persisted prompt\n".repeat(200)}</available_skills>`;
await saveSessionStore(
storePath,
{
"agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)),
},
{ skipMaintenance: true },
);
const loaded = loadSessionStore(storePath, { skipCache: true });
expect(loaded["agent:main:test:1"]?.skillsSnapshot?.prompt).toBe(prompt);
expect(loaded["agent:main:test:1"]?.skillsSnapshot?.promptRef).toBeUndefined();
expect(loaded["agent:main:test:1"]?.skillsSnapshot?.resolvedSkills).toBeUndefined();
});
it("rewrites a verified prompt blob when cleanup removed it in the same process", async () => {
const prompt = `<available_skills>\n${"rewritten prompt\n".repeat(200)}</available_skills>`;
const store = {
"agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)),
};
await saveSessionStore(storePath, store, { skipMaintenance: true });
const raw = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<string, SessionEntry>;
const hash = raw["agent:main:test:1"]?.skillsSnapshot?.promptRef?.hash;
if (!hash) {
throw new Error("expected prompt ref");
}
const blobPath = path.join(
testDir,
"skills-prompts",
"sha256",
hash.slice(0, 2),
`${hash}.txt`,
);
await fs.rm(blobPath);
await saveSessionStore(storePath, store, { skipMaintenance: true });
expect(await fs.readFile(blobPath, "utf-8")).toBe(prompt);
});
it("refreshes reused prompt blob mtimes before committing prompt refs", async () => {
const prompt = `<available_skills>\n${"refreshed prompt\n".repeat(200)}</available_skills>`;
const store = {
"agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)),
};
await saveSessionStore(storePath, store, { skipMaintenance: true });
const raw = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<string, SessionEntry>;
const hash = raw["agent:main:test:1"]?.skillsSnapshot?.promptRef?.hash;
if (!hash) {
throw new Error("expected prompt ref");
}
const blobPath = path.join(
testDir,
"skills-prompts",
"sha256",
hash.slice(0, 2),
`${hash}.txt`,
);
const oldTime = new Date(Date.now() - 10 * 60 * 1000);
await fs.utimes(blobPath, oldTime, oldTime);
await saveSessionStore(storePath, store, { skipMaintenance: true });
const refreshed = await fs.stat(blobPath);
expect(refreshed.mtimeMs).toBeGreaterThan(oldTime.getTime());
expect(await fs.readFile(blobPath, "utf-8")).toBe(prompt);
});
it("rewrites prompt blobs when the session dir is recreated before store commit", async () => {
const prompt = `<available_skills>\n${"recreated dir prompt\n".repeat(200)}</available_skills>`;
const store = {
"agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)),
};
const realMkdir = fs.mkdir.bind(fs);
let storeDirMkdirs = 0;
const mkdirSpy = vi
.spyOn(fs, "mkdir")
.mockImplementation(
async (dirPath: PathLike, options?: MakeDirectoryOptions | Mode | null) => {
if (typeof dirPath === "string" && path.resolve(dirPath) === path.resolve(testDir)) {
storeDirMkdirs += 1;
if (storeDirMkdirs === 2) {
await fs.rm(testDir, { recursive: true, force: true });
}
}
return await realMkdir(dirPath, options ?? undefined);
},
);
try {
await saveSessionStore(storePath, store, { skipMaintenance: true });
} finally {
mkdirSpy.mockRestore();
}
expect(storeDirMkdirs).toBeGreaterThanOrEqual(2);
const raw = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<string, SessionEntry>;
const hash = raw["agent:main:test:1"]?.skillsSnapshot?.promptRef?.hash;
if (!hash) {
throw new Error("expected prompt ref");
}
const blobPath = path.join(
testDir,
"skills-prompts",
"sha256",
hash.slice(0, 2),
`${hash}.txt`,
);
expect(await fs.readFile(blobPath, "utf-8")).toBe(prompt);
expect(
loadSessionStore(storePath, { skipCache: true })["agent:main:test:1"]?.skillsSnapshot?.prompt,
).toBe(prompt);
});
it("keeps cache clones hydrated when disk JSON uses prompt refs", async () => {
process.env.OPENCLAW_SESSION_CACHE_TTL_MS = "45000";
clearSessionStoreCacheForTest();
const prompt = `<available_skills>\n${"cached prompt\n".repeat(200)}</available_skills>`;
await saveSessionStore(
storePath,
{
"agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)),
},
{ skipMaintenance: true },
);
const loaded = loadSessionStore(storePath);
expect(loaded["agent:main:test:1"]?.skillsSnapshot?.prompt).toBe(prompt);
expect(loaded["agent:main:test:1"]?.skillsSnapshot?.promptRef).toBeUndefined();
});
it("can skip prompt ref hydration for metadata-only reads", async () => {
const prompt = `<available_skills>\n${"metadata-only prompt\n".repeat(200)}</available_skills>`;
await saveSessionStore(
storePath,
{
"agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)),
},
{ skipMaintenance: true },
);
const loaded = loadSessionStore(storePath, {
hydrateSkillPromptRefs: false,
skipCache: true,
});
const snapshot = loaded["agent:main:test:1"]?.skillsSnapshot;
expect(snapshot?.prompt).toBeUndefined();
expect(snapshot?.promptRef?.hash).toMatch(/^[a-f0-9]{64}$/);
});
it("does not cache unhydrated prompt refs for later full reads", async () => {
process.env.OPENCLAW_SESSION_CACHE_TTL_MS = "45000";
clearSessionStoreCacheForTest();
const prompt = `<available_skills>\n${"cache-safe prompt\n".repeat(200)}</available_skills>`;
await saveSessionStore(
storePath,
{
"agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)),
},
{ skipMaintenance: true },
);
clearSessionStoreCacheForTest();
const metadataOnly = loadSessionStore(storePath, {
hydrateSkillPromptRefs: false,
});
const fullRead = loadSessionStore(storePath);
expect(metadataOnly["agent:main:test:1"]?.skillsSnapshot?.prompt).toBeUndefined();
expect(fullRead["agent:main:test:1"]?.skillsSnapshot?.prompt).toBe(prompt);
expect(fullRead["agent:main:test:1"]?.skillsSnapshot?.promptRef).toBeUndefined();
});
it("keeps small skills prompts inline", async () => {
const prompt = "short prompt";
await saveSessionStore(
storePath,
{
"agent:main:test:1": makeEntry("session-1", makeSnapshotWithPrompt(prompt)),
},
{ skipMaintenance: true },
);
const raw = await fs.readFile(storePath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, SessionEntry>;
expect(parsed["agent:main:test:1"]?.skillsSnapshot?.prompt).toBe(prompt);
expect(parsed["agent:main:test:1"]?.skillsSnapshot?.promptRef).toBeUndefined();
});
it("drops stale prompt refs so missing blobs rebuild on the next turn", async () => {
await fs.mkdir(testDir, { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:test:1": {
sessionId: "session-1",
updatedAt: Date.now(),
skillsSnapshot: {
promptRef: {
version: 1,
algorithm: "sha256",
hash: "a".repeat(64),
bytes: 123,
},
skills: [{ name: "demo" }],
version: 1,
},
},
},
null,
2,
),
"utf-8",
);
const loaded = loadSessionStore(storePath, { skipCache: true });
expect(loaded["agent:main:test:1"]?.skillsSnapshot).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -30,7 +30,7 @@ import {
} from "./session-accessor.js";
import type { SessionTranscriptTurnLifecyclePatch } from "./session-transcript-turn-lifecycle.types.js";
import { parseSqliteSessionFileMarker, type SqliteSessionFileMarker } from "./sqlite-marker.js";
import { resolveSessionStoreEntry } from "./store.js";
import { resolveSessionStoreEntry } from "./store-entry.js";
import {
applyBeforeMessageWriteToAssistant,
type AssistantBeforeMessageWrite,
+3 -4
View File
@@ -792,10 +792,9 @@ export type SessionSkillSnapshot = {
/**
* Runtime-only, never persisted. Carries the full parsed Skill[] (including
* each SKILL.md body) so the embedded runner can skip a workspace skill
* scan within a turn. Stripped from sessions.json on every read and write
* via normalizeSessionStore see store-load.ts. On a cold session resume
* this is undefined and src/skills/runtime/embedded-run-entries.ts
* rebuilds it by reloading skill entries from disk.
* scan within a turn. Persistence projections strip it before committing
* session state. On a cold session resume this is undefined and
* src/skills/runtime/embedded-run-entries.ts rebuilds it from disk.
*/
resolvedSkills?: Skill[];
version?: number;
@@ -18,7 +18,7 @@ import {
getRuntimeConfig,
} from "../config/config.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { loadSessionStore } from "../config/sessions/store.js";
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isTruthyEnvValue } from "../infra/env.js";
@@ -219,7 +219,11 @@ async function waitForSessionEntry(params: {
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: "codex" });
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const entry = loadSessionStore(storePath)[params.sessionKey];
const entry = loadSessionEntry({
agentId: "codex",
storePath,
sessionKey: params.sessionKey,
});
if (entry) {
return entry;
}
+1 -1
View File
@@ -12,7 +12,7 @@ import {
writeConfigFile,
} from "../config/config.js";
import { resetConfigOverrides, setConfigOverride } from "../config/runtime-overrides.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resetAgentEventsForTest } from "../infra/agent-events.js";
@@ -12,7 +12,7 @@ import {
validateApprovalResolveResult,
} from "../../packages/gateway-protocol/src/index.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
@@ -105,17 +105,6 @@ vi.mock("../../config/sessions.js", async () => {
};
});
vi.mock("../../config/sessions/store.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions/store.js")>(
"../../config/sessions/store.js",
);
return {
...actual,
updateSessionStore: (...args: Parameters<typeof actual.updateSessionStore>) =>
mocks.updateSessionStore(...args),
};
});
vi.mock("../../config/sessions/session-accessor.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions/session-accessor.js")>(
"../../config/sessions/session-accessor.js",
@@ -13,7 +13,7 @@ import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { GATEWAY_CLIENT_CAPS } from "../../../packages/gateway-protocol/src/client-info.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../../config/config.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store-writer-state.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
import { APPROVALS_SCOPE } from "../method-scopes.js";
import { startGatewayServer } from "../server.js";
@@ -11,7 +11,7 @@ import {
listSessionMembers,
removeSessionMember,
} from "../../config/sessions/session-sharing-store.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store-writer-state.js";
import {
closeOpenClawAgentDatabasesForTest,
openOpenClawAgentDatabase,
@@ -5,7 +5,7 @@ import path from "node:path";
import { Agent, getGlobalDispatcher, setGlobalDispatcher } from "undici";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import { resetAgentEventsForTest } from "../infra/agent-events.js";
import { PROXY_ENV_KEYS } from "../infra/net/proxy-env.js";
import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js";
@@ -22,7 +22,6 @@ import {
withTranscriptWriteLock,
} from "../config/sessions/session-accessor.js";
import { waitForSessionTranscriptIndexReconcile } from "../config/sessions/session-transcript-reconcile.js";
import { invalidateSessionStoreCache } from "../config/sessions/store-cache.js";
import type { AgentModelConfig } from "../config/types.agents-shared.js";
import { rotateAgentEventLifecycleGeneration } from "../infra/agent-events.js";
import { onDiagnosticEvent, type DiagnosticPayloadLargeEvent } from "../infra/diagnostic-events.js";
@@ -3722,86 +3721,6 @@ describe("gateway server chat", () => {
}
});
test("chat.send keeps distinct sends independent when a session ID appears during the first turn", async () => {
const sessionDir = autoCleanupTempDirs.make("openclaw-gw-");
const dispatchRelease = createDeferred();
try {
const storePath = path.join(sessionDir, "sessions.json");
testState.sessionStorePath = storePath;
await writeSessionStore({ entries: {} });
const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = [];
const context = createDirectChatContext();
dispatchInboundMessageMock.mockImplementation(async () => dispatchRelease.promise);
const { chatHandlers } = await import("./server-methods/chat.js");
const callSend = (id: string, idempotencyKey: string) => {
const params = {
sessionKey: "main",
message: "create this session once",
idempotencyKey,
};
return expectDefined(
chatHandlers["chat.send"],
'chatHandlers["chat.send"] test invariant',
)({
req: { type: "req", id, method: "chat.send", params },
params,
client: null,
isWebchatConnect: () => false,
respond: ((ok, payload, error) => {
responses.push({ id, ok, payload, error });
}) as RespondFn,
context,
});
};
const first = Promise.resolve(callSend("first", "idem-new-session-a"));
await waitForFast(() => {
expect(responses[0]).toEqual({
id: "first",
ok: true,
payload: expect.objectContaining({
runId: "idem-new-session-a",
status: "started",
}),
error: undefined,
});
}, FAST_WAIT_OPTS);
await fs.writeFile(
storePath,
JSON.stringify({
main: {
sessionId: "sess-created-during-run",
updatedAt: Date.now(),
},
}),
"utf8",
);
invalidateSessionStoreCache(storePath);
const duplicate = Promise.resolve(callSend("duplicate", "idem-new-session-b"));
await waitForFast(() => {
expect(responses.at(-1)).toEqual({
id: "duplicate",
ok: true,
payload: expect.objectContaining({ runId: "idem-new-session-b", status: "started" }),
error: undefined,
});
}, FAST_WAIT_OPTS);
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
dispatchRelease.resolve();
await Promise.all([first, duplicate]);
await waitForFast(() => {
expect(context.removeChatRun).toHaveBeenCalledTimes(2);
}, FAST_WAIT_OPTS);
} finally {
dispatchRelease.resolve();
dispatchInboundMessageMock.mockReset();
testState.sessionStorePath = undefined;
clearConfigCache();
}
});
test("chat.send can suppress command interpretation for slash-prefixed system turns", async () => {
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
try {
@@ -3,12 +3,12 @@
*/
import path from "node:path";
import { expect, test } from "vitest";
import { clearSessionStoreCacheForTest } from "../config/sessions.js";
import {
applySessionEntryLifecycleMutation,
listSessionEntries,
loadSessionEntry,
} from "../config/sessions/session-accessor.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { createToolSummaryPreviewTranscriptLines } from "./session-preview.test-helpers.js";
import { rpcReq, testState, writeSessionStore } from "./test-helpers.js";
@@ -10,11 +10,6 @@ const importCases = [
importPath: "../auto-reply/reply/session.js",
scope: "reply-session",
},
{
label: "session store module",
importPath: "../config/sessions/store.js",
scope: "session-store",
},
] as const;
describe("session archive runtime import guards", () => {
+1 -5
View File
@@ -79,7 +79,6 @@ import { resolveAgentModelFallbackValues } from "../config/model-input.js";
import {
buildGroupDisplayName,
buildGroupDisplayTitle,
getSessionStoreCacheVersion,
isConfiguredSessionStoreAgentId,
isTerminalSessionStatus,
resolveAllAgentSessionStoreTargetsSync,
@@ -419,7 +418,6 @@ type SessionListRowContextProvider = () => SessionListRowContext;
type SingleRowChildSessionCandidateCacheEntry = {
store: Record<string, SessionEntry>;
storeVersion: number;
childSessionCandidatesByParentKey: Map<string, string[]>;
};
@@ -485,15 +483,13 @@ function getSingleRowChildSessionCandidates(params: {
if (!params.store) {
return new Map();
}
const storeVersion = getSessionStoreCacheVersion(params.storePath);
const cached = singleRowChildSessionCandidateCache.get(params.storePath);
if (cached && cached.store === params.store && cached.storeVersion === storeVersion) {
if (cached?.store === params.store) {
return cached.childSessionCandidatesByParentKey;
}
const childSessionCandidatesByParentKey = buildStoreChildSessionCandidateIndex(params.store);
rememberSingleRowChildSessionCandidateCacheEntry(params.storePath, {
store: params.store,
storeVersion,
childSessionCandidatesByParentKey,
});
return childSessionCandidatesByParentKey;
+1 -1
View File
@@ -7,7 +7,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
import { WebSocket } from "ws";
import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/index.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import {
type DeviceIdentity,
loadOrCreateDeviceIdentity,
-18
View File
@@ -10,7 +10,6 @@ import {
type GetReplyFromConfigFn,
getGatewayTestHoistedState,
agentDiscoveryMock,
sessionStoreSaveDelayMs,
testTailnetIPv4,
testTailscaleWhois,
type RunBtwSideQuestionFn,
@@ -171,23 +170,6 @@ vi.mock("../infra/tailscale.js", async () => {
};
});
vi.mock("../config/sessions.js", async () => {
const actual =
await vi.importActual<typeof import("../config/sessions.js")>("../config/sessions.js");
return {
...actual,
saveSessionStore: vi.fn(async (storePath: string, store: unknown) => {
const delay = sessionStoreSaveDelayMs.value;
if (delay > 0) {
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
}
return actual.saveSessionStore(storePath, store as never);
}),
};
});
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
const { createGatewayConfigModuleMock } = await import("./test-helpers.config-runtime.js");
@@ -51,7 +51,6 @@ type GatewayTestHoistedState = {
runBtwSideQuestion: Mock<RunBtwSideQuestionFn>;
dispatchInboundMessage: Mock<DispatchInboundMessageFn>;
testIsNixMode: { value: boolean };
sessionStoreSaveDelayMs: { value: number };
embeddedRunMock: {
activeIds: Set<string>;
abortCalls: string[];
@@ -104,7 +103,6 @@ const gatewayTestHoisted = vi.hoisted(() => {
runBtwSideQuestion: vi.fn().mockResolvedValue(undefined),
dispatchInboundMessage: vi.fn(),
testIsNixMode: { value: false },
sessionStoreSaveDelayMs: { value: 0 },
embeddedRunMock: {
activeIds: new Set<string>(),
abortCalls: [],
@@ -169,7 +167,6 @@ export const mockGetReplyFromConfigOnce = (impl: GetReplyFromConfigFn) => {
export const sendWhatsAppMock = gatewayTestHoisted.sendWhatsAppMock;
export const testState = gatewayTestHoisted.testState;
export const testIsNixMode = gatewayTestHoisted.testIsNixMode;
export const sessionStoreSaveDelayMs = gatewayTestHoisted.sessionStoreSaveDelayMs;
export const embeddedRunMock = gatewayTestHoisted.embeddedRunMock;
export const testConfigRoot = resolveGlobalSingleton(GATEWAY_TEST_CONFIG_ROOT_KEY, () => ({
+2 -8
View File
@@ -10,16 +10,13 @@ import { afterAll, afterEach, beforeAll, beforeEach, expect, vi } from "vitest";
import { WebSocket } from "ws";
import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/index.js";
import { parseConfigJson5, resetConfigRuntimeState } from "../config/config.js";
import {
clearSessionStoreCacheForTest,
resolveMainSessionKeyFromConfig,
type SessionEntry,
} from "../config/sessions.js";
import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js";
import {
applySessionEntryLifecycleMutation,
listSessionEntries,
} from "../config/sessions/session-accessor.js";
import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import { resetAgentEventsForTest } from "../infra/agent-events.js";
import {
loadOrCreateDeviceIdentity,
@@ -68,7 +65,6 @@ import {
getReplyFromConfig,
agentDiscoveryMock,
sendWhatsAppMock,
sessionStoreSaveDelayMs,
setTestConfigRoot,
testIsNixMode,
testTailscaleWhois,
@@ -370,7 +366,6 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
invalidateSessionSharingSnapshot();
resetTestPluginRegistry();
clearGatewaySubagentRuntime();
sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined;
testTailscaleWhois.value = null;
testState.gatewayBind = DEFAULT_GATEWAY_TEST_BIND;
@@ -470,7 +465,6 @@ async function resetGatewayTestRuntimeOnly() {
invalidateSessionSharingSnapshot();
resetTestPluginRegistry();
clearGatewaySubagentRuntime();
sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined;
testTailscaleWhois.value = null;
testState.gatewayBind = DEFAULT_GATEWAY_TEST_BIND;
+2
View File
@@ -30,6 +30,7 @@ export let ensurePortAvailable: LibraryExports["ensurePortAvailable"];
export let getReplyFromConfig: LibraryExports["getReplyFromConfig"];
export let handlePortError: LibraryExports["handlePortError"];
export let loadConfig: LibraryExports["loadConfig"];
/** @deprecated Use SQLite-backed session APIs. Scheduled for removal after 2026-10-12. */
export let loadSessionStore: LibraryExports["loadSessionStore"];
export let monitorWebChannel: LibraryExports["monitorWebChannel"];
export let normalizeE164: LibraryExports["normalizeE164"];
@@ -39,6 +40,7 @@ export let resolveSessionKey: LibraryExports["resolveSessionKey"];
export let resolveStorePath: LibraryExports["resolveStorePath"];
export let runCommandWithTimeout: LibraryExports["runCommandWithTimeout"];
export let runExec: LibraryExports["runExec"];
/** @deprecated Use SQLite-backed session APIs. Scheduled for removal after 2026-10-12. */
export let saveSessionStore: LibraryExports["saveSessionStore"];
export let waitForever: LibraryExports["waitForever"];
@@ -0,0 +1,120 @@
import type { MakeDirectoryOptions, Mode, PathLike } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { expect, it, vi } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
loadLegacySessionStore,
saveLegacySessionStore,
} from "./state-migrations.legacy-session-store.js";
it("stages prompt blobs after a recreated session directory", async () => {
await withTempDir({ prefix: "openclaw-legacy-session-store-" }, async (root) => {
const storeDir = path.join(root, "sessions");
const storePath = path.join(storeDir, "sessions.json");
const sessionKey = "agent:main:main";
const prompt = `<available_skills>\n${"recreated dir prompt\n".repeat(200)}</available_skills>`;
const realMkdir = fs.mkdir.bind(fs);
let storeDirMkdirs = 0;
const mkdirSpy = vi
.spyOn(fs, "mkdir")
.mockImplementation(
async (dirPath: PathLike, options?: MakeDirectoryOptions | Mode | null) => {
if (typeof dirPath === "string" && path.resolve(dirPath) === path.resolve(storeDir)) {
storeDirMkdirs += 1;
if (storeDirMkdirs === 2) {
await fs.rm(storeDir, { force: true, recursive: true });
}
}
return await realMkdir(dirPath, options ?? undefined);
},
);
try {
await saveLegacySessionStore(
storePath,
{
[sessionKey]: {
sessionId: "session-1",
updatedAt: 1,
skillsSnapshot: {
prompt,
skills: [{ name: "demo" }],
version: 1,
},
},
},
{ skipMaintenance: true },
);
} finally {
mkdirSpy.mockRestore();
}
expect(storeDirMkdirs).toBeGreaterThanOrEqual(2);
expect(loadLegacySessionStore(storePath)[sessionKey]?.skillsSnapshot?.prompt).toBe(prompt);
});
});
it("normalizes file-era rows and drops malformed entries", async () => {
await withTempDir({ prefix: "openclaw-legacy-session-normalize-" }, async (root) => {
const storePath = path.join(root, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify({
malformed: null,
"agent:main:main": {
sessionId: " session-1 ",
updatedAt: 1,
provider: "slack",
lastProvider: "telegram",
pendingFinalDeliveryAttemptCount: -1,
pluginExtensions: {
" demo ": {
" valid ": { ok: true },
invalid: undefined,
},
},
},
}),
);
const store = loadLegacySessionStore(storePath);
expect(store.malformed).toBeUndefined();
expect(store["agent:main:main"]).toMatchObject({
sessionId: "session-1",
channel: "slack",
lastChannel: "telegram",
pluginExtensions: { demo: { valid: { ok: true } } },
});
expect(store["agent:main:main"]?.pendingFinalDeliveryAttemptCount).toBeUndefined();
});
});
it("normalizes compatibility writes before persistence", async () => {
await withTempDir({ prefix: "openclaw-legacy-session-write-" }, async (root) => {
const storePath = path.join(root, "sessions.json");
const store = {
malformed: null,
"agent:main:main": {
sessionId: " session-1 ",
updatedAt: 1,
provider: "slack",
pendingFinalDeliveryAttemptCount: -1,
},
} as unknown as Parameters<typeof saveLegacySessionStore>[1];
await saveLegacySessionStore(storePath, store, { skipMaintenance: true });
const persisted = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
string,
Record<string, unknown>
>;
expect(persisted.malformed).toBeUndefined();
expect(persisted["agent:main:main"]).toMatchObject({
sessionId: "session-1",
channel: "slack",
});
expect(persisted["agent:main:main"]?.pendingFinalDeliveryAttemptCount).toBeUndefined();
});
});
@@ -0,0 +1,576 @@
// Doctor-only reader and writer for retired sessions.json stores.
import fs from "node:fs";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeRestartRecoveryEntryFields } from "../config/sessions/restart-recovery-state.js";
import {
ensureSessionStorePromptBlobsForPersistence,
hydrateSessionStoreSkillPromptRefs,
projectSessionStoreForPersistence,
} from "../config/sessions/skill-prompt-blobs.js";
import { normalizePersistedSessionEntryShape } from "../config/sessions/store-entry-shape.js";
import {
applyFileBackedSessionStoreMaintenance,
type SessionMaintenanceApplyReport,
} from "../config/sessions/store-maintenance-operations.js";
import { collectSessionMaintenancePreserveKeysForStore } from "../config/sessions/store-maintenance-preserve.js";
import { resolveMaintenanceConfig } from "../config/sessions/store-maintenance-runtime.js";
import {
capEntryCount,
pruneStaleEntries,
pruneStaleModelRunEntries,
shouldRunModelRunPrune,
shouldRunSessionEntryMaintenance,
type ResolvedSessionMaintenanceConfig,
type SessionMaintenanceWarning,
} from "../config/sessions/store-maintenance.js";
import { applySessionStoreMigrations } from "../config/sessions/store-migrations.js";
import { runExclusiveSessionStoreWrite } from "../config/sessions/store-writer.js";
import { normalizeSessionRuntimeModelFields, type SessionEntry } from "../config/sessions/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { ChannelRouteRef } from "../plugin-sdk/channel-route.js";
import { isPluginJsonValue, type PluginJsonValue } from "../plugins/host-hook-json.js";
import { normalizeSessionEntrySlotKey } from "../plugins/session-entry-slot-keys.js";
import {
isValidAgentHarnessSessionStoreEntry,
resolveAgentHarnessSessionStoreError,
resolveAgentHarnessSessionStoreTransitionError,
} from "../sessions/agent-harness-session-key.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import {
normalizeDeliveryChannelRoute,
normalizeDeliveryContext,
normalizeSessionDeliveryFields,
} from "../utils/delivery-context.shared.js";
import { writeTextAtomic } from "./json-files.js";
import { readSessionStoreJson5 } from "./state-migrations.fs.js";
export type LegacySessionStoreLoadOptions = {
skipCache?: boolean;
maintenanceConfig?: ResolvedSessionMaintenanceConfig;
runMaintenance?: boolean;
clone?: boolean;
hydrateSkillPromptRefs?: boolean;
};
export type LegacySessionStoreSaveOptions = {
skipMaintenance?: boolean;
skipSerializeForUnchangedStore?: boolean;
takeCacheOwnership?: boolean;
activeSessionKey?: string;
onWarn?: (warning: SessionMaintenanceWarning) => void | Promise<void>;
onMaintenanceApplied?: (report: SessionMaintenanceApplyReport) => void | Promise<void>;
maintenanceOverride?: Partial<ResolvedSessionMaintenanceConfig>;
maintenanceConfig?: ResolvedSessionMaintenanceConfig;
singleEntryPersistence?: { sessionKey: string; entry: SessionEntry };
requireWriteSuccess?: boolean;
};
type LegacySessionStoreUpdateOptions<T> = LegacySessionStoreSaveOptions & {
reentrant?: boolean;
skipSaveWhenResult?: (result: T) => boolean;
resolveSingleEntryPersistence?: (
result: T,
) => { sessionKey: string; entry: SessionEntry } | null | undefined;
};
const log = createSubsystemLogger("sessions/legacy-importer");
const loadSessionArchiveRuntime = createLazyRuntimeModule(
() => import("../gateway/session-archive.runtime.js"),
);
const loadTrajectoryCleanupRuntime = createLazyRuntimeModule(
() => import("../trajectory/cleanup.js"),
);
function normalizeOptionalFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function normalizeOptionalAttemptCount(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
}
function normalizeOptionalStringOrNull(value: unknown): string | null | undefined {
return value === null || typeof value === "string" ? value : undefined;
}
function normalizeRecordKey(value: string): string | undefined {
const key = value.trim();
return key.length > 0 ? key : undefined;
}
function normalizeOptionalDeliveryContext(
value: unknown,
): SessionEntry["pendingFinalDeliveryContext"] {
if (!isRecord(value)) {
return undefined;
}
const normalized = normalizeDeliveryContext({
channel: typeof value.channel === "string" ? value.channel : undefined,
to: typeof value.to === "string" ? value.to : undefined,
accountId: typeof value.accountId === "string" ? value.accountId : undefined,
threadId:
typeof value.threadId === "string" || typeof value.threadId === "number"
? value.threadId
: undefined,
});
return normalized?.channel && normalized.to ? normalized : undefined;
}
function sameDeliveryContext(
left: SessionEntry["pendingFinalDeliveryContext"],
right: SessionEntry["pendingFinalDeliveryContext"],
): boolean {
return (
(left?.channel ?? undefined) === (right?.channel ?? undefined) &&
(left?.to ?? undefined) === (right?.to ?? undefined) &&
(left?.accountId ?? undefined) === (right?.accountId ?? undefined) &&
(left?.threadId ?? undefined) === (right?.threadId ?? undefined)
);
}
function normalizePendingFinalDeliveryFields(entry: SessionEntry): SessionEntry {
let next = entry;
const assign = <K extends keyof SessionEntry>(key: K, value: SessionEntry[K] | undefined) => {
if (entry[key] === value) {
return;
}
if (next === entry) {
next = { ...entry };
}
if (value === undefined) {
delete next[key];
} else {
next[key] = value;
}
};
assign("pendingFinalDelivery", entry.pendingFinalDelivery === true ? true : undefined);
assign("pendingFinalDeliveryText", normalizeOptionalStringOrNull(entry.pendingFinalDeliveryText));
assign(
"pendingFinalDeliveryCreatedAt",
normalizeOptionalFiniteNumber(entry.pendingFinalDeliveryCreatedAt),
);
assign(
"pendingFinalDeliveryLastAttemptAt",
normalizeOptionalFiniteNumber(entry.pendingFinalDeliveryLastAttemptAt),
);
assign(
"pendingFinalDeliveryAttemptCount",
normalizeOptionalAttemptCount(entry.pendingFinalDeliveryAttemptCount),
);
assign(
"pendingFinalDeliveryLastError",
normalizeOptionalStringOrNull(entry.pendingFinalDeliveryLastError),
);
const pendingContext = normalizeOptionalDeliveryContext(entry.pendingFinalDeliveryContext);
if (!sameDeliveryContext(entry.pendingFinalDeliveryContext, pendingContext)) {
assign("pendingFinalDeliveryContext", pendingContext);
}
assign(
"pendingFinalDeliveryIntentId",
normalizeOptionalStringOrNull(entry.pendingFinalDeliveryIntentId),
);
const restartContext = normalizeOptionalDeliveryContext(entry.restartRecoveryDeliveryContext);
if (!sameDeliveryContext(entry.restartRecoveryDeliveryContext, restartContext)) {
assign("restartRecoveryDeliveryContext", restartContext);
}
normalizeRestartRecoveryEntryFields(entry, assign);
return next;
}
function normalizePluginExtensions(entry: SessionEntry): SessionEntry {
if (entry.pluginExtensions === undefined) {
return entry;
}
if (!isRecord(entry.pluginExtensions)) {
const next = { ...entry };
delete next.pluginExtensions;
return next;
}
let changed = false;
const normalizedExtensions: Record<string, Record<string, PluginJsonValue>> = {};
for (const [rawPluginId, rawPluginState] of Object.entries(entry.pluginExtensions)) {
const pluginId = normalizeRecordKey(rawPluginId);
if (!pluginId || !isRecord(rawPluginState)) {
changed = true;
continue;
}
changed ||= pluginId !== rawPluginId;
const normalizedPluginState: Record<string, PluginJsonValue> = {};
for (const [rawNamespace, rawValue] of Object.entries(rawPluginState)) {
const namespace = normalizeRecordKey(rawNamespace);
if (!namespace || !isPluginJsonValue(rawValue)) {
changed = true;
continue;
}
changed ||= namespace !== rawNamespace;
normalizedPluginState[namespace] = rawValue;
}
if (Object.keys(normalizedPluginState).length === 0) {
changed = true;
continue;
}
normalizedExtensions[pluginId] = normalizedPluginState;
}
if (!changed) {
return entry;
}
const next = { ...entry };
if (Object.keys(normalizedExtensions).length > 0) {
next.pluginExtensions = normalizedExtensions;
} else {
delete next.pluginExtensions;
}
return next;
}
function normalizePluginExtensionSlotKeys(entry: SessionEntry): SessionEntry {
if (entry.pluginExtensionSlotKeys === undefined) {
return entry;
}
if (!isRecord(entry.pluginExtensionSlotKeys)) {
const next = { ...entry };
delete next.pluginExtensionSlotKeys;
return next;
}
let changed = false;
const normalizedSlotKeys: Record<string, Record<string, string>> = {};
for (const [rawPluginId, rawPluginSlots] of Object.entries(entry.pluginExtensionSlotKeys)) {
const pluginId = normalizeRecordKey(rawPluginId);
if (!pluginId || !isRecord(rawPluginSlots)) {
changed = true;
continue;
}
changed ||= pluginId !== rawPluginId;
const normalizedPluginSlots: Record<string, string> = {};
for (const [rawNamespace, rawSlotKey] of Object.entries(rawPluginSlots)) {
const namespace = normalizeRecordKey(rawNamespace);
const slotKey = normalizeSessionEntrySlotKey(rawSlotKey);
if (!namespace || !slotKey.ok) {
changed = true;
continue;
}
changed ||= namespace !== rawNamespace || slotKey.key !== rawSlotKey;
normalizedPluginSlots[namespace] = slotKey.key;
}
if (Object.keys(normalizedPluginSlots).length === 0) {
changed = true;
continue;
}
normalizedSlotKeys[pluginId] = normalizedPluginSlots;
}
if (!changed) {
return entry;
}
const next = { ...entry };
if (Object.keys(normalizedSlotKeys).length > 0) {
next.pluginExtensionSlotKeys = normalizedSlotKeys;
} else {
delete next.pluginExtensionSlotKeys;
}
return next;
}
function stripPersistedSkillsCache(entry: SessionEntry): SessionEntry {
const snapshot = entry.skillsSnapshot;
if (!snapshot || snapshot.resolvedSkills === undefined) {
return entry;
}
const { resolvedSkills: _drop, ...rest } = snapshot;
return { ...entry, skillsSnapshot: rest };
}
function normalizeLegacySessionStore(store: Record<string, SessionEntry>): void {
applySessionStoreMigrations(store);
for (const [key, entry] of Object.entries(store)) {
const modelSelectionLocked = isRecord(entry) && entry.modelSelectionLocked === true;
const shaped = normalizePersistedSessionEntryShape(entry);
if (!shaped) {
if (modelSelectionLocked) {
throw new Error(`Invalid model-selection-locked session entry: ${key}`);
}
delete store[key];
continue;
}
const runtimeFields = normalizeSessionRuntimeModelFields(shaped);
if (modelSelectionLocked && runtimeFields !== shaped) {
throw new Error(`Invalid model-selection-locked session entry: ${key}`);
}
store[key] = stripPersistedSkillsCache(
normalizePluginExtensionSlotKeys(
normalizePluginExtensions(
normalizePendingFinalDeliveryFields(
normalizeLegacySessionEntryDelivery(modelSelectionLocked ? shaped : runtimeFields),
),
),
),
);
}
const harnessError = resolveAgentHarnessSessionStoreError(store);
if (harnessError) {
throw new Error(harnessError);
}
}
export function loadLegacySessionStore(
storePath: string,
options: LegacySessionStoreLoadOptions = {},
): Record<string, SessionEntry> {
const { store } = readSessionStoreJson5(storePath);
if (options.hydrateSkillPromptRefs !== false) {
hydrateSessionStoreSkillPromptRefs({ storePath, store });
}
const sessionStore = store as Record<string, SessionEntry>;
normalizeLegacySessionStore(sessionStore);
if (options.runMaintenance) {
const maintenance = options.maintenanceConfig ?? resolveMaintenanceConfig();
const beforeCount = Object.keys(sessionStore).length;
if (maintenance.mode === "enforce") {
const preserveSessionKeys = collectSessionMaintenancePreserveKeysForStore({
storePath,
store: sessionStore,
});
if (shouldRunModelRunPrune({ maintenance, entryCount: beforeCount })) {
pruneStaleModelRunEntries(sessionStore, maintenance.modelRunPruneAfterMs, {
log: false,
preserveKeys: preserveSessionKeys,
});
}
if (Object.keys(sessionStore).length > maintenance.maxEntries) {
pruneStaleEntries(sessionStore, maintenance.pruneAfterMs, {
log: false,
preserveKeys: preserveSessionKeys,
});
if (
shouldRunSessionEntryMaintenance({
entryCount: Object.keys(sessionStore).length,
maxEntries: maintenance.maxEntries,
})
) {
capEntryCount(sessionStore, maintenance.maxEntries, {
log: false,
preserveKeys: preserveSessionKeys,
});
}
}
}
}
return sessionStore;
}
function snapshotLockedEntries(
store: Record<string, SessionEntry>,
): ReadonlyMap<string, SessionEntry> {
return new Map(
Object.entries(store).flatMap(([sessionKey, entry]) =>
isValidAgentHarnessSessionStoreEntry(sessionKey, entry)
? [[sessionKey, structuredClone(entry)] as const]
: [],
),
);
}
function assertLegacySessionStoreWriteIsValid(params: {
lockedEntriesBefore: ReadonlyMap<string, SessionEntry>;
store: Record<string, SessionEntry>;
}): void {
const transitionError = resolveAgentHarnessSessionStoreTransitionError({
before: params.lockedEntriesBefore,
store: params.store,
});
if (transitionError) {
throw new Error(transitionError);
}
const storeError = resolveAgentHarnessSessionStoreError(params.store);
if (storeError) {
throw new Error(storeError);
}
}
function stripRuntimeOnlySkillState(
store: Record<string, SessionEntry>,
): Record<string, SessionEntry> {
return Object.fromEntries(
Object.entries(store).map(([sessionKey, entry]) => [
sessionKey,
stripPersistedSkillsCache(entry),
]),
);
}
async function archiveRemovedSessionTranscripts(params: {
removedSessionFiles: Iterable<[string, string | undefined]>;
referencedSessionIds: ReadonlySet<string>;
storePath: string;
reason: "deleted";
restrictToStoreDir: true;
}): Promise<Set<string>> {
const { archiveSessionTranscripts } = await loadSessionArchiveRuntime();
const archivedDirs = new Set<string>();
for (const [sessionId, sessionFile] of params.removedSessionFiles) {
if (params.referencedSessionIds.has(sessionId)) {
continue;
}
const archived = archiveSessionTranscripts({
sessionId,
storePath: params.storePath,
sessionFile,
reason: params.reason,
restrictToStoreDir: params.restrictToStoreDir,
});
for (const archivedPath of archived) {
archivedDirs.add(path.dirname(archivedPath));
}
}
return archivedDirs;
}
async function persistLegacySessionStore(
storePath: string,
store: Record<string, SessionEntry>,
): Promise<void> {
const persisted = projectSessionStoreForPersistence({
storePath,
store: stripRuntimeOnlySkillState(store),
});
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
await writeTextAtomic(storePath, JSON.stringify(persisted.store, null, 2), {
beforeRename: async () => {
await ensureSessionStorePromptBlobsForPersistence({
storePath,
promptBlobs: persisted.promptBlobs.values(),
});
},
durable: true,
mode: 0o600,
tempPrefix: path.basename(storePath),
trailingNewline: true,
});
}
async function writeLegacySessionStoreUnlocked(
storePath: string,
store: Record<string, SessionEntry>,
lockedEntriesBefore: ReadonlyMap<string, SessionEntry>,
options: LegacySessionStoreSaveOptions,
): Promise<void> {
normalizeLegacySessionStore(store);
assertLegacySessionStoreWriteIsValid({ lockedEntriesBefore, store });
if (!options.skipMaintenance) {
await applyFileBackedSessionStoreMaintenance({
storePath,
store,
activeSessionKey: options.activeSessionKey,
onWarn: options.onWarn,
onMaintenanceApplied: options.onMaintenanceApplied,
maintenanceOverride: options.maintenanceOverride,
maintenanceConfig: options.maintenanceConfig,
log,
commitReducedStore: () => persistLegacySessionStore(storePath, store),
artifacts: {
archiveRemovedSessionTranscripts,
removeRemovedSessionTrajectoryArtifacts: async (params) => {
const { removeRemovedSessionTrajectoryArtifacts } = await loadTrajectoryCleanupRuntime();
await removeRemovedSessionTrajectoryArtifacts(params);
},
cleanupArchivedSessionTranscripts: async (params) => {
const { cleanupArchivedSessionTranscripts } = await loadSessionArchiveRuntime();
await cleanupArchivedSessionTranscripts(params);
},
},
});
}
assertLegacySessionStoreWriteIsValid({ lockedEntriesBefore, store });
await persistLegacySessionStore(storePath, store);
}
export async function saveLegacySessionStore(
storePath: string,
store: Record<string, SessionEntry>,
options: LegacySessionStoreSaveOptions = {},
): Promise<void> {
await runExclusiveSessionStoreWrite(storePath, async () => {
const currentStore = loadLegacySessionStore(storePath);
await writeLegacySessionStoreUnlocked(
storePath,
store,
snapshotLockedEntries(currentStore),
options,
);
});
}
export async function updateLegacySessionStore<T>(
storePath: string,
mutator: (store: Record<string, SessionEntry>) => Promise<T> | T,
options: LegacySessionStoreUpdateOptions<T> = {},
): Promise<T> {
return await runExclusiveSessionStoreWrite(
storePath,
async () => {
const store = loadLegacySessionStore(storePath);
const lockedEntriesBefore = snapshotLockedEntries(store);
const result = await mutator(store);
if (!options.skipSaveWhenResult?.(result)) {
await writeLegacySessionStoreUnlocked(storePath, store, lockedEntriesBefore, options);
}
return result;
},
{ reentrant: options.reentrant },
);
}
function sameDeliveryChannelRoute(
left: ChannelRouteRef | undefined,
right: ChannelRouteRef | undefined,
): boolean {
return (
(left?.channel ?? undefined) === (right?.channel ?? undefined) &&
(left?.accountId ?? undefined) === (right?.accountId ?? undefined) &&
(left?.target?.to ?? undefined) === (right?.target?.to ?? undefined) &&
(left?.target?.rawTo ?? undefined) === (right?.target?.rawTo ?? undefined) &&
(left?.target?.chatType ?? undefined) === (right?.target?.chatType ?? undefined) &&
(left?.thread?.id ?? undefined) === (right?.thread?.id ?? undefined) &&
(left?.thread?.kind ?? undefined) === (right?.thread?.kind ?? undefined) &&
(left?.thread?.source ?? undefined) === (right?.thread?.source ?? undefined)
);
}
/** Canonicalizes file-era delivery fields before doctor imports a row into SQLite. */
export function normalizeLegacySessionEntryDelivery(entry: SessionEntry): SessionEntry {
const entryRoute = normalizeDeliveryChannelRoute(entry.route);
const normalized = normalizeSessionDeliveryFields({
route: entryRoute,
channel: entry.channel,
lastChannel: entry.lastChannel,
lastTo: entry.lastTo,
lastAccountId: entry.lastAccountId,
lastThreadId: entry.lastThreadId ?? entry.deliveryContext?.threadId ?? entry.origin?.threadId,
deliveryContext: entry.deliveryContext,
});
const nextDelivery = normalized.deliveryContext;
const sameDelivery =
(entry.deliveryContext?.channel ?? undefined) === nextDelivery?.channel &&
(entry.deliveryContext?.to ?? undefined) === nextDelivery?.to &&
(entry.deliveryContext?.accountId ?? undefined) === nextDelivery?.accountId &&
(entry.deliveryContext?.threadId ?? undefined) === nextDelivery?.threadId;
const sameLast =
sameDeliveryChannelRoute(entryRoute, normalized.route) &&
entry.lastChannel === normalized.lastChannel &&
entry.lastTo === normalized.lastTo &&
entry.lastAccountId === normalized.lastAccountId &&
entry.lastThreadId === normalized.lastThreadId;
if (sameDelivery && sameLast) {
return entry;
}
return {
...entry,
route: normalized.route,
deliveryContext: nextDelivery,
lastChannel: normalized.lastChannel,
lastTo: normalized.lastTo,
lastAccountId: normalized.lastAccountId,
lastThreadId: normalized.lastThreadId,
};
}
+3 -3
View File
@@ -5,7 +5,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
import { writeAcpSessionMetaForMigration } from "../acp/runtime/session-meta.js";
import { resolveStateDir } from "../config/paths.js";
import type { SessionEntry } from "../config/sessions.js";
import { saveSessionStore } from "../config/sessions.js";
import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
import { resolveAgentsDirFromSessionStorePath } from "../config/sessions/paths.js";
import { normalizePersistedSessionEntryShape } from "../config/sessions/store-entry-shape.js";
@@ -40,6 +39,7 @@ import {
safeReadDir,
type SessionEntryLike,
} from "./state-migrations.fs.js";
import { saveLegacySessionStore } from "./state-migrations.legacy-session-store.js";
import {
getLegacySessionSurfaces,
isLegacyGroupKey,
@@ -1334,9 +1334,9 @@ export async function saveSessionStoreStrict(
storePath: string,
store: Record<string, SessionEntry>,
): Promise<void> {
await saveSessionStore(storePath, store, {
skipMaintenance: true,
await saveLegacySessionStore(storePath, store, {
requireWriteSuccess: true,
skipMaintenance: true,
});
}
+4 -7
View File
@@ -6,7 +6,6 @@ import { DatabaseSync } from "node:sqlite";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { readAcpSessionMetaForEntry } from "../acp/runtime/session-meta.js";
import type { OpenClawConfig } from "../config/config.js";
import * as sessionStore from "../config/sessions.js";
import { loadNodeHostConfig } from "../node-host/config.js";
import { readChannelPairingStateSnapshot } from "../pairing/pairing-store-sqlite.test-helpers.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
@@ -38,6 +37,7 @@ import {
resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations,
} from "./state-migrations.js";
import * as sessionStore from "./state-migrations.legacy-session-store.js";
import { loadVoiceWakeRoutingConfig, setVoiceWakeRoutingConfig } from "./voicewake-routing.js";
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "./voicewake.js";
@@ -993,17 +993,14 @@ describe("state migrations", () => {
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
const realSaveSessionStore = sessionStore.saveSessionStore;
const realSaveSessionStore = sessionStore.saveLegacySessionStore;
let sawRequiredWrite = false;
const saveSpy = vi
.spyOn(sessionStore, "saveSessionStore")
.spyOn(sessionStore, "saveLegacySessionStore")
.mockImplementation(async (storePath, store, options) => {
sawRequiredWrite ||= options?.requireWriteSuccess === true;
if (storePath === targetStorePath) {
if (options?.requireWriteSuccess) {
throw new Error("simulated alias write failure");
}
return;
throw new Error("simulated alias write failure");
}
await realSaveSessionStore(storePath, store, options);
});
+27 -1
View File
@@ -1,7 +1,10 @@
// Tests library entrypoint exports and package boundary behavior.
import { readFileSync } from "node:fs";
import fs, { readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it } from "vitest";
import { loadSessionStore, saveSessionStore } from "./library.js";
const libraryPath = new URL("./library.ts", import.meta.url);
const lazyRuntimeSpecifiers = [
@@ -39,4 +42,27 @@ describe("library module imports", () => {
);
}
});
it("keeps the deprecated root session-store wrappers uncached", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-library-session-store-"));
const storePath = path.join(dir, "sessions.json");
try {
await saveSessionStore(
storePath,
{
"agent:main:main": { sessionId: "first", updatedAt: Date.now() },
},
{ skipMaintenance: true },
);
expect(loadSessionStore(storePath)["agent:main:main"]?.sessionId).toBe("first");
fs.writeFileSync(
storePath,
JSON.stringify({ "agent:main:main": { sessionId: "second", updatedAt: 2 } }),
);
expect(loadSessionStore(storePath)["agent:main:main"]?.sessionId).toBe("second");
} finally {
fs.rmSync(dir, { force: true, recursive: true });
}
});
});
+28 -3
View File
@@ -7,7 +7,6 @@ import { waitForever } from "./cli/wait.js";
import { loadConfig } from "./config/config.js";
import { resolveStorePath } from "./config/sessions/paths.js";
import { deriveSessionKey, resolveSessionKey } from "./config/sessions/session-key.js";
import { loadSessionStore, saveSessionStore } from "./config/sessions/store.js";
import type { ensureBinary as ensureBinaryRuntime } from "./infra/binaries.js";
import {
describePortOwner,
@@ -15,6 +14,12 @@ import {
handlePortError,
PortInUseError,
} from "./infra/ports.js";
import {
loadLegacySessionStore,
saveLegacySessionStore,
type LegacySessionStoreLoadOptions,
type LegacySessionStoreSaveOptions,
} from "./infra/state-migrations.legacy-session-store.js";
import type { monitorWebChannel as monitorWebChannelRuntime } from "./plugins/runtime/runtime-web-channel-plugin.js";
import type {
runCommandWithTimeout as runCommandWithTimeoutRuntime,
@@ -50,6 +55,28 @@ export const runCommandWithTimeout: RunCommandWithTimeout = async (...args) =>
export const monitorWebChannel: MonitorWebChannel = async (...args) =>
(await loadWebChannelRuntime()).monitorWebChannel(...args);
/**
* @deprecated Legacy sessions.json compatibility for package-root consumers.
* Use SQLite-backed session APIs. Remove after 2026-10-12, once the v2026.7.x
* upgrade window no longer requires the legacy doctor importer.
*/
export function loadSessionStore(storePath: string, options?: LegacySessionStoreLoadOptions) {
return loadLegacySessionStore(storePath, options);
}
/**
* @deprecated Legacy sessions.json compatibility for package-root consumers.
* Use SQLite-backed session APIs. Remove after 2026-10-12, once the v2026.7.x
* upgrade window no longer requires the legacy doctor importer.
*/
export async function saveSessionStore(
storePath: string,
store: Parameters<typeof saveLegacySessionStore>[1],
options?: LegacySessionStoreSaveOptions,
): Promise<void> {
await saveLegacySessionStore(storePath, store, options);
}
export {
applyTemplate,
createDefaultDeps,
@@ -58,11 +85,9 @@ export {
ensurePortAvailable,
handlePortError,
loadConfig,
loadSessionStore,
normalizeE164,
PortInUseError,
resolveSessionKey,
resolveStorePath,
saveSessionStore,
waitForever,
};
+1 -1
View File
@@ -133,7 +133,7 @@ export type {
TtsPersonaFallbackPolicy,
TtsProvider,
} from "../config/types.js";
export { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
export { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
// SDK-facing names are a shipped plugin contract; internals route through the
// session accessor so the storage backend can change beneath them.
export {
+2 -2
View File
@@ -31,7 +31,7 @@ import {
} from "../config/sessions/sqlite-marker.js";
import { resolveSessionStoreEntry as resolveSessionStoreEntryFromStore } from "../config/sessions/store-entry.js";
import { normalizeResolvedMaintenanceConfigInput } from "../config/sessions/store-maintenance.js";
import type { ResolvedSessionMaintenanceConfigInput } from "../config/sessions/store.js";
import type { ResolvedSessionMaintenanceConfigInput } from "../config/sessions/store-maintenance.js";
import type {
AmbientTranscriptWatermark,
InternalSessionEntry,
@@ -582,7 +582,7 @@ export {
export { resolveSessionKey } from "../config/sessions/session-key.js";
export { resolveGroupSessionKey } from "../config/sessions/group.js";
export { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
export { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
export { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
export { isValidAgentHarnessSessionStoreEntry } from "../sessions/agent-harness-session-key.js";
// SDK-facing names are a shipped plugin contract; internals route through the
// session accessor so the storage backend can change beneath them.
@@ -6,10 +6,10 @@ import {
registerTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadSessionStore, updateSessionStore } from "../../config/sessions.js";
import { withTempConfig } from "../../gateway/test-temp-config.js";
import { emitAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { loadSessionStore, updateSessionStore } from "../../plugin-sdk/session-store-runtime.js";
import { runPluginHostCleanup } from "../host-hook-cleanup.js";
import {
clearPluginHostRuntimeState,
+1 -1
View File
@@ -31,7 +31,7 @@ import {
updateSessionEntry,
} from "../../config/sessions/session-accessor.js";
import { normalizeResolvedMaintenanceConfigInput } from "../../config/sessions/store-maintenance.js";
import type { ResolvedSessionMaintenanceConfigInput } from "../../config/sessions/store.js";
import type { ResolvedSessionMaintenanceConfigInput } from "../../config/sessions/store-maintenance.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import {
beginSessionWorkAdmission,
+1 -1
View File
@@ -133,7 +133,7 @@ type RuntimeCreateSessionEntryParams = RuntimeCreateSessionEntryBaseParams &
);
type RuntimeSessionStoreEntryPatchParams = RuntimeSessionStoreReadParams & {
fallbackEntry?: RuntimeSessionEntry;
maintenanceConfig?: import("../../config/sessions/store.js").ResolvedSessionMaintenanceConfigInput;
maintenanceConfig?: import("../../config/sessions/store-maintenance.js").ResolvedSessionMaintenanceConfigInput;
preserveActivity?: boolean;
replaceEntry?: boolean;
update: (
+1 -4
View File
@@ -5,10 +5,7 @@ const { detectChangedScope } = await import("../../scripts/ci-changed-scope.mjs"
describe("detectChangedScope Windows routing", () => {
it("routes SQLite transcript archive changes to Windows", () => {
for (const archivePath of [
"src/config/sessions/session-accessor.sqlite-archive.ts",
"src/config/sessions/store.session-lifecycle-mutation.test.ts",
]) {
for (const archivePath of ["src/config/sessions/session-accessor.sqlite-archive.ts"]) {
expect(detectChangedScope([archivePath]), archivePath).toMatchObject({
runNode: true,
runWindows: true,

Some files were not shown because too many files have changed in this diff Show More