Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
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;
}
¤ Dauer der Verarbeitung: 0.23 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|