|
1 | 1 | // Session store facade coordinates reads, writes, maintenance, delivery metadata, and exports. |
2 | 2 | import fs from "node:fs"; |
3 | 3 | import path from "node:path"; |
| 4 | +import { |
| 5 | + normalizeLowercaseStringOrEmpty, |
| 6 | + normalizeOptionalString, |
| 7 | +} from "@openclaw/normalization-core/string-coerce"; |
4 | 8 | import type { MsgContext } from "../../auto-reply/templating.js"; |
5 | 9 | import { writeTextAtomic } from "../../infra/json-files.js"; |
6 | 10 | import { createSubsystemLogger } from "../../logging/subsystem.js"; |
@@ -86,6 +90,25 @@ export type { |
86 | 90 | } from "./store-cache.js"; |
87 | 91 | export { normalizeStoreSessionKey, resolveSessionStoreEntry } from "./store-entry.js"; |
88 | 92 |
|
| 93 | +export type SessionEntryPatchProjectionSnapshot = { |
| 94 | + entries: ReadonlyArray<{ sessionKey: string; entry: SessionEntry }>; |
| 95 | +}; |
| 96 | + |
| 97 | +export type SessionEntryPatchProjectionTarget = { |
| 98 | + candidateKeys?: readonly string[]; |
| 99 | + primaryKey: string; |
| 100 | +}; |
| 101 | + |
| 102 | +export type SessionEntryPatchProjectionContext = SessionEntryPatchProjectionSnapshot & |
| 103 | + SessionEntryPatchProjectionTarget & { |
| 104 | + existingEntry?: SessionEntry; |
| 105 | + }; |
| 106 | + |
| 107 | +export type SessionEntryPatchProjectionFailure = { ok: false }; |
| 108 | + |
| 109 | +export type SessionEntryPatchProjectionResult<TFailure extends SessionEntryPatchProjectionFailure> = |
| 110 | + { ok: true; entry: SessionEntry } | TFailure; |
| 111 | + |
89 | 112 | const log = createSubsystemLogger("sessions/store"); |
90 | 113 | let sessionArchiveRuntimePromise: Promise< |
91 | 114 | typeof import("../../gateway/session-archive.runtime.js") |
@@ -926,6 +949,129 @@ export async function updateSessionStore<T>( |
926 | 949 | }); |
927 | 950 | } |
928 | 951 |
|
| 952 | +function cloneSessionEntryProjectionSnapshot( |
| 953 | + store: Record<string, SessionEntry>, |
| 954 | +): SessionEntryPatchProjectionSnapshot { |
| 955 | + return { |
| 956 | + entries: Object.entries(store).map(([sessionKey, entry]) => ({ |
| 957 | + sessionKey, |
| 958 | + entry: cloneSessionEntry(entry), |
| 959 | + })), |
| 960 | + }; |
| 961 | +} |
| 962 | + |
| 963 | +function resolveFreshestProjectedEntry(params: { |
| 964 | + store: Record<string, SessionEntry>; |
| 965 | + candidateKeys: readonly string[]; |
| 966 | +}): SessionEntry | undefined { |
| 967 | + let freshest: SessionEntry | undefined; |
| 968 | + const keys = new Set<string>(); |
| 969 | + for (const candidateKey of params.candidateKeys) { |
| 970 | + const trimmed = normalizeOptionalString(candidateKey) ?? ""; |
| 971 | + if (!trimmed) { |
| 972 | + continue; |
| 973 | + } |
| 974 | + keys.add(trimmed); |
| 975 | + for (const match of findSessionStoreKeysIgnoreCase(params.store, trimmed)) { |
| 976 | + keys.add(match); |
| 977 | + } |
| 978 | + } |
| 979 | + for (const key of keys) { |
| 980 | + const entry = params.store[key]; |
| 981 | + if (!entry) { |
| 982 | + continue; |
| 983 | + } |
| 984 | + if (!freshest || (entry.updatedAt ?? 0) > (freshest.updatedAt ?? 0)) { |
| 985 | + freshest = entry; |
| 986 | + } |
| 987 | + } |
| 988 | + return freshest; |
| 989 | +} |
| 990 | + |
| 991 | +function findSessionStoreKeysIgnoreCase( |
| 992 | + store: Record<string, SessionEntry>, |
| 993 | + targetKey: string, |
| 994 | +): string[] { |
| 995 | + const lowered = normalizeLowercaseStringOrEmpty(targetKey); |
| 996 | + return Object.keys(store).filter((key) => normalizeLowercaseStringOrEmpty(key) === lowered); |
| 997 | +} |
| 998 | + |
| 999 | +function migrateSessionEntryProjectionTarget(params: { |
| 1000 | + store: Record<string, SessionEntry>; |
| 1001 | + target: SessionEntryPatchProjectionTarget; |
| 1002 | +}): void { |
| 1003 | + const candidateKeys = params.target.candidateKeys ?? [params.target.primaryKey]; |
| 1004 | + const freshest = resolveFreshestProjectedEntry({ |
| 1005 | + store: params.store, |
| 1006 | + candidateKeys, |
| 1007 | + }); |
| 1008 | + const currentPrimary = params.store[params.target.primaryKey]; |
| 1009 | + if ( |
| 1010 | + freshest && |
| 1011 | + (!currentPrimary || (freshest.updatedAt ?? 0) > (currentPrimary.updatedAt ?? 0)) |
| 1012 | + ) { |
| 1013 | + params.store[params.target.primaryKey] = freshest; |
| 1014 | + } |
| 1015 | + |
| 1016 | + const keysToDelete = new Set<string>(); |
| 1017 | + for (const candidateKey of candidateKeys) { |
| 1018 | + const trimmed = normalizeOptionalString(candidateKey) ?? ""; |
| 1019 | + if (!trimmed) { |
| 1020 | + continue; |
| 1021 | + } |
| 1022 | + if (trimmed !== params.target.primaryKey) { |
| 1023 | + keysToDelete.add(trimmed); |
| 1024 | + } |
| 1025 | + for (const match of findSessionStoreKeysIgnoreCase(params.store, trimmed)) { |
| 1026 | + if (match !== params.target.primaryKey) { |
| 1027 | + keysToDelete.add(match); |
| 1028 | + } |
| 1029 | + } |
| 1030 | + } |
| 1031 | + for (const key of keysToDelete) { |
| 1032 | + delete params.store[key]; |
| 1033 | + } |
| 1034 | +} |
| 1035 | + |
| 1036 | +/** |
| 1037 | + * Applies a storage-neutral entry projection inside the session-store writer. |
| 1038 | + * The projection receives a cloned snapshot and returns the replacement entry; |
| 1039 | + * it cannot mutate the backing whole store. |
| 1040 | + */ |
| 1041 | +export async function applySessionEntryPatchProjection< |
| 1042 | + TFailure extends SessionEntryPatchProjectionFailure, |
| 1043 | +>(params: { |
| 1044 | + storePath: string; |
| 1045 | + resolveTarget: ( |
| 1046 | + snapshot: SessionEntryPatchProjectionSnapshot, |
| 1047 | + ) => SessionEntryPatchProjectionTarget; |
| 1048 | + project: ( |
| 1049 | + context: SessionEntryPatchProjectionContext, |
| 1050 | + ) => |
| 1051 | + | Promise<SessionEntryPatchProjectionResult<TFailure>> |
| 1052 | + | SessionEntryPatchProjectionResult<TFailure>; |
| 1053 | +}): Promise<SessionEntryPatchProjectionResult<TFailure>> { |
| 1054 | + return await runExclusiveSessionStoreWrite(params.storePath, async () => { |
| 1055 | + const store = loadMutableSessionStoreForWriter(params.storePath); |
| 1056 | + const target = params.resolveTarget(cloneSessionEntryProjectionSnapshot(store)); |
| 1057 | + migrateSessionEntryProjectionTarget({ store, target }); |
| 1058 | + const projected = await params.project({ |
| 1059 | + ...target, |
| 1060 | + entries: cloneSessionEntryProjectionSnapshot(store).entries, |
| 1061 | + ...(store[target.primaryKey] |
| 1062 | + ? { existingEntry: cloneSessionEntry(store[target.primaryKey]) } |
| 1063 | + : {}), |
| 1064 | + }); |
| 1065 | + if (projected.ok) { |
| 1066 | + store[target.primaryKey] = cloneSessionEntry(projected.entry); |
| 1067 | + } |
| 1068 | + await saveSessionStoreUnlocked(params.storePath, store, { |
| 1069 | + activeSessionKey: target.primaryKey, |
| 1070 | + }); |
| 1071 | + return projected.ok ? { ...projected, entry: cloneSessionEntry(projected.entry) } : projected; |
| 1072 | + }); |
| 1073 | +} |
| 1074 | + |
929 | 1075 | async function archiveUnreferencedLifecycleTranscriptArtifacts(params: { |
930 | 1076 | storePath: string; |
931 | 1077 | transcriptContentMarker: string; |
|
0 commit comments