mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
committed by
GitHub
parent
54f8f61167
commit
c440ae3e86
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ?? {});
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, () => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user