import fs from "node:fs" ;
import path from "node:path" ;
import { parseByteSize } from "../../cli/parse-bytes.js" ;
import { parseDurationMs } from "../../cli/parse-duration.js" ;
import { createSubsystemLogger } from "../../logging/subsystem.js" ;
import { normalizeStringifiedOptionalString } from "../../shared/string-coerce.js" ;
import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js" ;
import type { SessionEntry } from "./types.js" ;
const log = createSubsystemLogger("sessions/store" );
const DEFAULT_SESSION_PRUNE_AFTER_MS = 30 * 24 * 60 * 60 * 1000 ;
const DEFAULT_SESSION_MAX_ENTRIES = 500 ;
const DEFAULT_SESSION_ROTATE_BYTES = 10 _485 _760 ; // 10 MB
const DEFAULT_SESSION_MAINTENANCE_MODE: SessionMaintenanceMode = "enforce" ;
const DEFAULT_SESSION_DISK_BUDGET_HIGH_WATER_RATIO = 0 .8 ;
export type SessionMaintenanceWarning = {
activeSessionKey: string;
activeUpdatedAt?: number;
totalEntries: number;
pruneAfterMs: number;
maxEntries: number;
wouldPrune: boolean ;
wouldCap: boolean ;
};
export type ResolvedSessionMaintenanceConfig = {
mode: SessionMaintenanceMode;
pruneAfterMs: number;
maxEntries: number;
rotateBytes: number;
resetArchiveRetentionMs: number | null ;
maxDiskBytes: number | null ;
highWaterBytes: number | null ;
};
function resolvePruneAfterMs(maintenance?: SessionMaintenanceConfig): number {
const raw = maintenance?.pruneAfter ?? maintenance?.pruneDays;
const normalized = normalizeStringifiedOptionalString(raw);
if (!normalized) {
return DEFAULT_SESSION_PRUNE_AFTER_MS;
}
try {
return parseDurationMs(normalized, { defaultUnit: "d" });
} catch {
return DEFAULT_SESSION_PRUNE_AFTER_MS;
}
}
function resolveRotateBytes(maintenance?: SessionMaintenanceConfig): number {
const raw = maintenance?.rotateBytes;
const normalized = normalizeStringifiedOptionalString(raw);
if (!normalized) {
return DEFAULT_SESSION_ROTATE_BYTES;
}
try {
return parseByteSize(normalized, { defaultUnit: "b" });
} catch {
return DEFAULT_SESSION_ROTATE_BYTES;
}
}
function resolveResetArchiveRetentionMs(
maintenance: SessionMaintenanceConfig | undefined,
pruneAfterMs: number,
): number | null {
const raw = maintenance?.resetArchiveRetention;
if (raw === false ) {
return null ;
}
const normalized = normalizeStringifiedOptionalString(raw);
if (!normalized) {
return pruneAfterMs;
}
try {
return parseDurationMs(normalized, { defaultUnit: "d" });
} catch {
return pruneAfterMs;
}
}
function resolveMaxDiskBytes(maintenance?: SessionMaintenanceConfig): number | null {
const raw = maintenance?.maxDiskBytes;
const normalized = normalizeStringifiedOptionalString(raw);
if (!normalized) {
return null ;
}
try {
return parseByteSize(normalized, { defaultUnit: "b" });
} catch {
return null ;
}
}
function resolveHighWaterBytes(
maintenance: SessionMaintenanceConfig | undefined,
maxDiskBytes: number | null ,
): number | null {
const computeDefault = () => {
if (maxDiskBytes == null ) {
return null ;
}
if (maxDiskBytes <= 0 ) {
return 0 ;
}
return Math.max(
1 ,
Math.min(
maxDiskBytes,
Math.floor(maxDiskBytes * DEFAULT_SESSION_DISK_BUDGET_HIGH_WATER_RATIO),
),
);
};
if (maxDiskBytes == null ) {
return null ;
}
const raw = maintenance?.highWaterBytes;
const normalized = normalizeStringifiedOptionalString(raw);
if (!normalized) {
return computeDefault();
}
try {
const parsed = parseByteSize(normalized, { defaultUnit: "b" });
return Math.min(parsed, maxDiskBytes);
} catch {
return computeDefault();
}
}
/**
* Resolve maintenance settings from openclaw . json ( ` session . maintenance ` ) .
* Falls back to built - in defaults when config is missing or unset .
*/
export function resolveMaintenanceConfigFromInput(
maintenance?: SessionMaintenanceConfig,
): ResolvedSessionMaintenanceConfig {
const pruneAfterMs = resolvePruneAfterMs(maintenance);
const maxDiskBytes = resolveMaxDiskBytes(maintenance);
return {
mode: maintenance?.mode ?? DEFAULT_SESSION_MAINTENANCE_MODE,
pruneAfterMs,
maxEntries: maintenance?.maxEntries ?? DEFAULT_SESSION_MAX_ENTRIES,
rotateBytes: resolveRotateBytes(maintenance),
resetArchiveRetentionMs: resolveResetArchiveRetentionMs(maintenance, pruneAfterMs),
maxDiskBytes,
highWaterBytes: resolveHighWaterBytes(maintenance, maxDiskBytes),
};
}
/**
* Remove entries whose ` updatedAt ` is older than the configured threshold .
* Entries without ` updatedAt ` are kept ( cannot determine staleness ) .
* Mutates ` store ` in - place .
*/
export function pruneStaleEntries(
store: Record<string, SessionEntry>,
overrideMaxAgeMs?: number,
opts: {
log?: boolean ;
onPruned?: (params: { key: string; entry: SessionEntry }) => void ;
preserveKeys?: ReadonlySet<string>;
} = {},
): number {
const maxAgeMs = overrideMaxAgeMs ?? resolveMaintenanceConfigFromInput().pruneAfterMs;
const cutoffMs = Date.now() - maxAgeMs;
let pruned = 0 ;
for (const [key, entry] of Object.entries(store)) {
if (opts.preserveKeys?.has(key)) {
continue ;
}
if (entry?.updatedAt != null && entry.updatedAt < cutoffMs) {
opts.onPruned?.({ key, entry });
delete store[key];
pruned++;
}
}
if (pruned > 0 && opts.log !== false ) {
log.info("pruned stale session entries" , { pruned, maxAgeMs });
}
return pruned;
}
function getEntryUpdatedAt(entry?: SessionEntry): number {
return entry?.updatedAt ?? Number.NEGATIVE_INFINITY;
}
export function getActiveSessionMaintenanceWarning(params: {
store: Record<string, SessionEntry>;
activeSessionKey: string;
pruneAfterMs: number;
maxEntries: number;
nowMs?: number;
}): SessionMaintenanceWarning | null {
const activeSessionKey = params.activeSessionKey.trim();
if (!activeSessionKey) {
return null ;
}
const activeEntry = params.store[activeSessionKey];
if (!activeEntry) {
return null ;
}
const now = params.nowMs ?? Date.now();
const cutoffMs = now - params.pruneAfterMs;
const wouldPrune = activeEntry.updatedAt != null ? activeEntry.updatedAt < cutoffMs : false ;
const keys = Object.keys(params.store);
const wouldCap = wouldCapActiveSession({
store: params.store,
keys,
activeEntry,
activeSessionKey,
maxEntries: params.maxEntries,
});
if (!wouldPrune && !wouldCap) {
return null ;
}
return {
activeSessionKey,
activeUpdatedAt: activeEntry.updatedAt,
totalEntries: keys.length,
pruneAfterMs: params.pruneAfterMs,
maxEntries: params.maxEntries,
wouldPrune,
wouldCap,
};
}
function wouldCapActiveSession(params: {
store: Record<string, SessionEntry>;
keys: string[];
activeEntry: SessionEntry;
activeSessionKey: string;
maxEntries: number;
}): boolean {
if (params.keys.length <= params.maxEntries) {
return false ;
}
if (params.maxEntries <= 0 ) {
return true ;
}
const activeUpdatedAt = getEntryUpdatedAt(params.activeEntry);
let newerOrTieBeforeActive = 0 ;
let seenActive = false ;
for (const key of params.keys) {
if (key === params.activeSessionKey) {
seenActive = true ;
continue ;
}
const entryUpdatedAt = getEntryUpdatedAt(params.store[key]);
if (entryUpdatedAt > activeUpdatedAt || (!seenActive && entryUpdatedAt === activeUpdatedAt)) {
newerOrTieBeforeActive++;
if (newerOrTieBeforeActive >= params.maxEntries) {
return true ;
}
}
}
return false ;
}
/**
* Cap the store to the N most recently updated entries .
* Entries without ` updatedAt ` are sorted last ( removed first when over limit ) .
* Mutates ` store ` in - place .
*/
export function capEntryCount(
store: Record<string, SessionEntry>,
overrideMax?: number,
opts: {
log?: boolean ;
onCapped?: (params: { key: string; entry: SessionEntry }) => void ;
preserveKeys?: ReadonlySet<string>;
} = {},
): number {
const maxEntries = overrideMax ?? resolveMaintenanceConfigFromInput().maxEntries;
const preservedCount = opts.preserveKeys
? Object.keys(store).filter((key) => opts.preserveKeys?.has(key)).length
: 0 ;
const maxRemovableEntries = Math.max(0 , maxEntries - preservedCount);
const keys = Object.keys(store).filter((key) => !opts.preserveKeys?.has(key));
if (keys.length <= maxRemovableEntries) {
return 0 ;
}
// Sort by updatedAt descending; entries without updatedAt go to the end (removed first).
const sorted = keys.toSorted((a, b) => {
const aTime = getEntryUpdatedAt(store[a]);
const bTime = getEntryUpdatedAt(store[b]);
return bTime - aTime;
});
const toRemove = sorted.slice(maxRemovableEntries);
for (const key of toRemove) {
const entry = store[key];
if (entry) {
opts.onCapped?.({ key, entry });
}
delete store[key];
}
if (opts.log !== false ) {
log.info("capped session entry count" , { removed: toRemove.length, maxEntries });
}
return toRemove.length;
}
async function getSessionFileSize(storePath: string): Promise<number | null > {
try {
const stat = await fs.promises.stat(storePath);
return stat.size;
} catch {
return null ;
}
}
/**
* Rotate the sessions file if it exceeds the configured size threshold .
* Copies the current file to ` sessions . json . bak . { timestamp } ` and cleans up
* old rotation backups , keeping only the 3 most recent ` . bak . * ` files .
*/
export async function rotateSessionFile(
storePath: string,
overrideBytes?: number,
): Promise<boolean > {
const maxBytes = overrideBytes ?? resolveMaintenanceConfigFromInput().rotateBytes;
// Check current file size (file may not exist yet).
const fileSize = await getSessionFileSize(storePath);
if (fileSize == null ) {
return false ;
}
if (fileSize <= maxBytes) {
return false ;
}
// Keep the live store authoritative until the caller's later atomic write succeeds.
// A rename would remove sessions.json and create a crash window where startup sees
// an empty store; a copy gives us a backup without changing the live file.
const backupPath = `${storePath}.bak.${Date.now()}`;
try {
await fs.promises.copyFile(storePath, backupPath);
log.info("backed up session store file before rotation" , {
backupPath: path.basename(backupPath),
sizeBytes: fileSize,
});
} catch (err) {
// If backup creation fails (e.g. file disappeared), skip rotation backup only.
log.warn("session store rotation backup failed" , { err });
return false ;
}
// Clean up old backups — keep only the 3 most recent .bak.* files.
try {
const dir = path.dirname(storePath);
const baseName = path.basename(storePath);
const files = await fs.promises.readdir(dir);
const backups = files
.filter((f) => f.startsWith(`${baseName}.bak.`))
.toSorted()
.toReversed();
const maxBackups = 3 ;
if (backups.length > maxBackups) {
const toDelete = backups.slice(maxBackups);
for (const old of toDelete) {
await fs.promises.unlink(path.join(dir, old)).catch (() => undefined);
}
log.info("cleaned up old session store backups" , { deleted: toDelete.length });
}
} catch {
// Best-effort cleanup; don't fail the write.
}
return true ;
}
Messung V0.5 in Prozent C=97 H=97 G=96
¤ Dauer der Verarbeitung: 0.7 Sekunden
¤
*© Formatika GbR, Deutschland