mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 01:11:38 +00:00
* refactor(sessions): migrate runtime storage to sqlite * test(sessions): fix sqlite CI regressions * test(sessions): align remaining sqlite fixtures * fix(codex): require sqlite trajectory recorder * test(sessions): align orphan recovery sqlite fixture * test(sessions): align sqlite rebase fixtures * fix(sessions): finish current-main integration of the sqlite flip Resolve the whole-store SDK removal across its owner boundary: drop the loadSessionStore re-export and the registry whole-store wrappers, wire hasTrackedActiveSessionRun into gateway chat, complete the preserveLockedHarnessIds cleanup contract, flip the codex thread-history import to storePath targets, and port remaining main-side tests from file-store helpers to session accessor reads. * chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain bump gets its own review, and regenerate docs_map, the plugin SDK API baseline, and the export-surface ratchet for the merged tree. * feat(sessions): keep archived transcripts by default with zstd cold storage Codex-style retention: deleting or resetting a session archives its transcript as a zstd-compressed JSONL artifact (plain when the runtime lacks node:zlib zstd) and keeps it until the disk budget evicts oldest first. resetArchiveRetention now governs both deleted and reset archives and defaults to keep; maxDiskBytes defaults to 2gb so retention stays bounded, with archives evicted before live sessions. The cron reaper follows the same knob instead of deleting archives on its own timer. * fix(state): converge agent DB migration lineages and bound database growth Merge coherence: run both structure-gated legacy memory-schema repairs (flip-lineage drop, main-lineage identity rebuild) before the flip migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all converge, and hoist foreign_keys=OFF outside the schema transaction where the pragma was silently ignored and the v1 sessions rebuild cascade-deleted session_entries. Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL maintenance releases freed pages in bounded passes (never a blocking full VACUUM), and doctor reports state/agent DB bloat from freelist stats. * fix(codex): resolve the store path for thread-history import via the SDK The supervision catalog passed the legacy sessionFile locator to the storePath-targeted transcript mirror; resolve the agent store path with the session-store SDK helper instead of a runtime-object seam so test fakes and headless callers need no extra surface. Drop the obsolete missing-session-id preprocessing case: sessions rows are NOT NULL on session_id and upsert repairs id-less patches at write time. * fix(sessions): fail safe on malformed disk-budget config and doctor stat errors A malformed explicit maxDiskBytes disables the budget instead of falling back to the destructive 2gb default the user never chose, and the doctor bloat check skips databases whose paths stat-fail instead of aborting doctor. * fix(sessions): complete sqlite conflict translations * test(sqlite): align hardening checks with maintenance * test(sessions): inspect compressed transcript archives * fix(tests): await session seeds and drop unused helpers flagged by CI lint The five unawaited writeSessionStoreSeed calls raced their SQLite seeds against the assertions, failing compact shards; the bloat probe drops a useless initializer and the merged tests drop now-unused helpers. * test(sessions): type legacy proof events directly * test(sessions): align hardening contracts * perf(sessions): read usage transcript sizes from SQL aggregates Usage/cost scans walked every session and materialized every transcript event just to re-stringify it for a byte estimate — the #86718 stall class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes in SQLite without loading a single row. * fix(sessions): re-root foreign-root transcript paths onto the current sessions dir Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry absolute sessionFile paths from the old root; the containment fallback kept those foreign paths, so migration read (and would archive) files in the original root and reported local copies missing. Re-root the canonical agents/<id>/sessions suffix onto the current dir when the file exists there; genuine cross-root layouts still fall through unchanged. * test(agents): seed harness admission through sqlite * fix(sqlite): close agent db on pragma setup failure * fix(doctor): compact and retrofit incremental auto-vacuum after session import The migration is the sanctioned offline window: post-import compact reclaims import churn and applies auto_vacuum=INCREMENTAL to databases created before the fresh-DB pragma existed, so runtime maintenance can release pages in bounded passes on every install. --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
// Configures SQLite WAL and related pragmas for local stores.
|
|
import childProcess from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js";
|
|
|
|
// WAL maintenance configures SQLite write-ahead logging and schedules bounded
|
|
// checkpoints so state databases do not accumulate unbounded WAL files.
|
|
export const DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000;
|
|
export const DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS = 30 * 60 * 1000;
|
|
/**
|
|
* @deprecated Use DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS.
|
|
* Periodic checkpoints default to PASSIVE.
|
|
*/
|
|
export const DEFAULT_SQLITE_WAL_TRUNCATE_INTERVAL_MS = DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS;
|
|
// 512 pages (~2MB at 4KB pages) per periodic pass keeps page release strictly
|
|
// bounded so maintenance can never behave like a blocking full VACUUM.
|
|
const INCREMENTAL_VACUUM_MAX_PAGES_PER_PASS = 512;
|
|
const LINUX_NFS_SUPER_MAGIC = 0x6969;
|
|
const LINUX_SMB_SUPER_MAGIC = 0x517b;
|
|
const LINUX_CIFS_SUPER_MAGIC = 0xff534d42;
|
|
const LINUX_SMB2_SUPER_MAGIC = 0xfe534d42;
|
|
const PROC_MOUNTINFO_PATH = "/proc/self/mountinfo";
|
|
const NETWORK_FILESYSTEM_TYPES = new Set(["cifs", "smbfs", "smb2", "smb3"]);
|
|
|
|
type IntervalHandle = ReturnType<typeof setInterval> & {
|
|
unref?: () => void;
|
|
};
|
|
|
|
type SqliteWalCheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE";
|
|
type SqliteFilesystemJournalPolicy = "rollback" | "unsupported" | "wal";
|
|
type MountEntry = { mountPoint: string; fsType: string; source?: string };
|
|
|
|
export type SqliteWalMaintenance = {
|
|
checkpoint: () => boolean;
|
|
close: () => boolean;
|
|
};
|
|
|
|
/** Options controlling WAL autocheckpoint and periodic checkpoint behavior. */
|
|
export type SqliteWalMaintenanceOptions = {
|
|
autoCheckpointPages?: number;
|
|
checkpointIntervalMs?: number;
|
|
checkpointMode?: SqliteWalCheckpointMode;
|
|
databaseLabel?: string;
|
|
databasePath?: string;
|
|
onCheckpointError?: (error: unknown) => void;
|
|
};
|
|
|
|
export type SqliteConnectionPragmaOptions = SqliteWalMaintenanceOptions & {
|
|
busyTimeoutMs?: number;
|
|
foreignKeys?: boolean;
|
|
synchronous?: "NORMAL";
|
|
};
|
|
|
|
function normalizeNonNegativeInteger(value: number, label: string): number {
|
|
if (!Number.isInteger(value) || value < 0) {
|
|
throw new Error(`${label} must be a non-negative integer`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function findExistingVolumePaths(
|
|
targetPath: string,
|
|
): { canonicalPath: string; originalPath: string } | null {
|
|
let current = path.resolve(targetPath);
|
|
while (true) {
|
|
let stats: ReturnType<typeof fs.statSync>;
|
|
try {
|
|
stats = fs.statSync(current);
|
|
} catch {
|
|
const parent = path.dirname(current);
|
|
if (parent === current) {
|
|
return null;
|
|
}
|
|
current = parent;
|
|
continue;
|
|
}
|
|
const existingPath = fs.realpathSync(current);
|
|
return {
|
|
canonicalPath: stats.isDirectory() ? existingPath : path.dirname(existingPath),
|
|
originalPath: stats.isDirectory() ? current : path.dirname(current),
|
|
};
|
|
}
|
|
}
|
|
|
|
function decodeMountPath(value: string): string {
|
|
return value.replace(/\\([0-7]{3})/g, (_match, octal: string) =>
|
|
String.fromCharCode(Number.parseInt(octal, 8)),
|
|
);
|
|
}
|
|
|
|
function parseProcMountInfoEntries(contents: string): MountEntry[] {
|
|
const entries: MountEntry[] = [];
|
|
for (const line of contents.split("\n")) {
|
|
const separator = line.indexOf(" - ");
|
|
if (separator === -1) {
|
|
continue;
|
|
}
|
|
const fields = line.slice(0, separator).split(" ");
|
|
const suffixFields = line.slice(separator + 3).split(" ");
|
|
const mountPoint = fields[4];
|
|
const fsType = suffixFields[0];
|
|
if (mountPoint && fsType) {
|
|
entries.push({
|
|
mountPoint: decodeMountPath(mountPoint),
|
|
fsType,
|
|
...(suffixFields[1] ? { source: decodeMountPath(suffixFields[1]) } : {}),
|
|
});
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function parseMountCommandEntries(contents: string): MountEntry[] {
|
|
const entries: MountEntry[] = [];
|
|
for (const line of contents.split("\n")) {
|
|
const linuxMatch = /^(.+) on (.+) type ([^,\s)]+) \(/.exec(line);
|
|
if (linuxMatch) {
|
|
entries.push({ source: linuxMatch[1], mountPoint: linuxMatch[2], fsType: linuxMatch[3] });
|
|
continue;
|
|
}
|
|
const bsdMatch = /^(.+) on (.+) \(([^,\s)]+)/.exec(line);
|
|
if (bsdMatch) {
|
|
entries.push({ source: bsdMatch[1], mountPoint: bsdMatch[2], fsType: bsdMatch[3] });
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function readMountEntries(): MountEntry[] {
|
|
try {
|
|
return parseProcMountInfoEntries(fs.readFileSync(PROC_MOUNTINFO_PATH, "utf8"));
|
|
} catch {
|
|
// macOS/BSD expose filesystem type names in `mount` output instead of
|
|
// Linux superblock magic, so keep this fallback for named filesystem types.
|
|
}
|
|
try {
|
|
return parseMountCommandEntries(String(childProcess.execFileSync("mount", [])));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function isPathWithinMount(targetPath: string, mountPoint: string): boolean {
|
|
const resolvedTarget = path.resolve(targetPath);
|
|
const resolvedMountPoint = path.resolve(mountPoint);
|
|
return (
|
|
resolvedTarget === resolvedMountPoint ||
|
|
resolvedMountPoint === path.parse(resolvedMountPoint).root ||
|
|
resolvedTarget.startsWith(`${resolvedMountPoint}${path.sep}`)
|
|
);
|
|
}
|
|
|
|
function isSshfsMountSource(source: string | undefined): boolean {
|
|
if (!source) {
|
|
return false;
|
|
}
|
|
const normalized = source.toLowerCase();
|
|
return (
|
|
normalized === "sshfs" ||
|
|
normalized.startsWith("sshfs#") ||
|
|
normalized.startsWith("sshfs@") ||
|
|
/^(?:[^/\s:]+@)?[^/\s:]+:.*/u.test(source)
|
|
);
|
|
}
|
|
|
|
function resolveMountTypeJournalPolicy(entry: MountEntry): SqliteFilesystemJournalPolicy {
|
|
const normalized = entry.fsType.toLowerCase();
|
|
if (normalized.startsWith("nfs") || NETWORK_FILESYSTEM_TYPES.has(normalized)) {
|
|
return "rollback";
|
|
}
|
|
if (normalized === "fuse.sshfs") {
|
|
return "unsupported";
|
|
}
|
|
if ((normalized === "macfuse" || normalized === "osxfuse") && isSshfsMountSource(entry.source)) {
|
|
return "unsupported";
|
|
}
|
|
return "wal";
|
|
}
|
|
|
|
function resolveMountEntryJournalPolicy(
|
|
targetPath: string,
|
|
mountEntries: MountEntry[],
|
|
): SqliteFilesystemJournalPolicy {
|
|
const mountEntry = mountEntries
|
|
.filter((entry) => isPathWithinMount(targetPath, entry.mountPoint))
|
|
.toSorted((a, b) => b.mountPoint.length - a.mountPoint.length)[0];
|
|
return mountEntry ? resolveMountTypeJournalPolicy(mountEntry) : "wal";
|
|
}
|
|
|
|
function combineMountEntryJournalPolicies(
|
|
targetPaths: readonly string[],
|
|
): SqliteFilesystemJournalPolicy {
|
|
const mountEntries = readMountEntries();
|
|
const policies = new Set(
|
|
targetPaths.map((targetPath) => resolveMountEntryJournalPolicy(targetPath, mountEntries)),
|
|
);
|
|
if (policies.has("unsupported")) {
|
|
return "unsupported";
|
|
}
|
|
return policies.has("rollback") ? "rollback" : "wal";
|
|
}
|
|
|
|
function isWindowsUncPath(targetPath: string): boolean {
|
|
return (
|
|
/^\\\\\?\\UNC\\[^\\]+\\[^\\]+/i.test(targetPath) ||
|
|
/^\\\\(?![?.]\\)[^\\]+\\[^\\]+/.test(targetPath)
|
|
);
|
|
}
|
|
|
|
function isWindowsDrivePath(targetPath: string): boolean {
|
|
return /^[A-Za-z]:[\\/]/.test(targetPath) || /^\\\\\?\\[A-Za-z]:[\\/]/i.test(targetPath);
|
|
}
|
|
|
|
function resolvePathJournalPolicy(targetPath: string): SqliteFilesystemJournalPolicy {
|
|
if (process.platform === "win32") {
|
|
const normalizedTargetPath = path.win32.normalize(targetPath);
|
|
if (isWindowsUncPath(normalizedTargetPath)) {
|
|
return "rollback";
|
|
}
|
|
if (isWindowsDrivePath(normalizedTargetPath)) {
|
|
try {
|
|
return isWindowsUncPath(path.win32.normalize(fs.realpathSync.native(targetPath)))
|
|
? "rollback"
|
|
: "wal";
|
|
} catch {
|
|
// Windows can deny SMB path normalization when parent components are
|
|
// unreadable. Treat an unclassifiable opened database as network-backed.
|
|
return "rollback";
|
|
}
|
|
}
|
|
}
|
|
const checkedPaths = findExistingVolumePaths(targetPath);
|
|
if (!checkedPaths) {
|
|
return "wal";
|
|
}
|
|
const mountLookupPaths = [checkedPaths.originalPath, checkedPaths.canonicalPath];
|
|
if (typeof fs.statfsSync !== "function") {
|
|
return combineMountEntryJournalPolicies(mountLookupPaths);
|
|
}
|
|
try {
|
|
const filesystemType = fs.statfsSync(checkedPaths.canonicalPath).type;
|
|
if (
|
|
filesystemType === LINUX_NFS_SUPER_MAGIC ||
|
|
filesystemType === LINUX_SMB_SUPER_MAGIC ||
|
|
filesystemType === LINUX_CIFS_SUPER_MAGIC ||
|
|
filesystemType === LINUX_SMB2_SUPER_MAGIC
|
|
) {
|
|
return "rollback";
|
|
}
|
|
} catch {
|
|
return combineMountEntryJournalPolicies(mountLookupPaths);
|
|
}
|
|
return combineMountEntryJournalPolicies(mountLookupPaths);
|
|
}
|
|
|
|
function readJournalModeResult(row: unknown): string | null {
|
|
if (!row || typeof row !== "object") {
|
|
return null;
|
|
}
|
|
const record = row as Record<string, unknown>;
|
|
const value = record.journal_mode ?? Object.values(record)[0];
|
|
return typeof value === "string" ? value.toLowerCase() : null;
|
|
}
|
|
|
|
function requireRollbackJournalMode(db: DatabaseSync, options: SqliteWalMaintenanceOptions): void {
|
|
const row = db.prepare("PRAGMA journal_mode = DELETE;").get();
|
|
const journalMode = readJournalModeResult(row);
|
|
if (journalMode !== "delete") {
|
|
const label = options.databaseLabel ?? "sqlite database";
|
|
const location = options.databasePath ? ` at ${options.databasePath}` : "";
|
|
const actual = journalMode ?? "unknown";
|
|
throw new Error(
|
|
`${label}${location} is on a network-backed volume but SQLite kept journal_mode=${actual}; refusing to continue with WAL on network storage.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function enableMacosCheckpointFullfsync(db: DatabaseSync): void {
|
|
if (process.platform !== "darwin") {
|
|
return;
|
|
}
|
|
try {
|
|
db.exec("PRAGMA checkpoint_fullfsync = 1;");
|
|
} catch {
|
|
// Older SQLite builds may ignore or reject platform-specific pragmas. WAL
|
|
// setup should still proceed because this is a durability upgrade, not a
|
|
// prerequisite for opening the store.
|
|
}
|
|
}
|
|
|
|
function refuseUnsupportedFilesystem(options: SqliteWalMaintenanceOptions): never {
|
|
const label = options.databaseLabel ?? "sqlite database";
|
|
const location = options.databasePath ? ` at ${options.databasePath}` : "";
|
|
throw new Error(
|
|
`${label}${location} is on SSHFS, which cannot safely coordinate SQLite writes across mounts; refusing to open the database.`,
|
|
);
|
|
}
|
|
|
|
/** Configure safe journaling pragmas and return a handle for checkpoint/close maintenance. */
|
|
export function configureSqliteWalMaintenance(
|
|
db: DatabaseSync,
|
|
options: SqliteWalMaintenanceOptions = {},
|
|
): SqliteWalMaintenance {
|
|
const autoCheckpointPages = normalizeNonNegativeInteger(
|
|
options.autoCheckpointPages ?? DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES,
|
|
"autoCheckpointPages",
|
|
);
|
|
const checkpointIntervalMs = normalizeNonNegativeInteger(
|
|
options.checkpointIntervalMs ?? DEFAULT_SQLITE_WAL_CHECKPOINT_INTERVAL_MS,
|
|
"checkpointIntervalMs",
|
|
);
|
|
const timerIntervalMs = Math.min(checkpointIntervalMs, MAX_TIMER_TIMEOUT_MS);
|
|
const checkpointMode = options.checkpointMode ?? "TRUNCATE";
|
|
const periodicCheckpointMode = options.checkpointMode ?? "PASSIVE";
|
|
const journalPolicy = options.databasePath
|
|
? resolvePathJournalPolicy(options.databasePath)
|
|
: "wal";
|
|
if (journalPolicy === "unsupported") {
|
|
refuseUnsupportedFilesystem(options);
|
|
}
|
|
if (journalPolicy === "rollback") {
|
|
requireRollbackJournalMode(db, options);
|
|
return {
|
|
checkpoint: () => true,
|
|
close: () => true,
|
|
};
|
|
}
|
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
enableMacosCheckpointFullfsync(db);
|
|
db.exec(`PRAGMA wal_autocheckpoint = ${autoCheckpointPages};`);
|
|
|
|
const runCheckpoint = (mode: SqliteWalCheckpointMode): boolean => {
|
|
try {
|
|
db.exec(`PRAGMA wal_checkpoint(${mode});`);
|
|
return true;
|
|
} catch (error) {
|
|
options.onCheckpointError?.(error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Bounded page release for databases opened with auto_vacuum=INCREMENTAL.
|
|
// A no-op elsewhere, and never a blocking full VACUUM: unbounded vacuums on
|
|
// the event loop have starved channel sockets in production (#83712).
|
|
const runIncrementalVacuum = (): void => {
|
|
try {
|
|
db.exec(`PRAGMA incremental_vacuum(${INCREMENTAL_VACUUM_MAX_PAGES_PER_PASS});`);
|
|
} catch (error) {
|
|
options.onCheckpointError?.(error);
|
|
}
|
|
};
|
|
|
|
const checkpoint = (): boolean => runCheckpoint(checkpointMode);
|
|
|
|
let timer: IntervalHandle | null = null;
|
|
if (timerIntervalMs > 0) {
|
|
timer = setInterval(() => {
|
|
runCheckpoint(periodicCheckpointMode);
|
|
runIncrementalVacuum();
|
|
}, timerIntervalMs) as IntervalHandle;
|
|
timer.unref?.();
|
|
}
|
|
|
|
return {
|
|
checkpoint,
|
|
close: () => {
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
return checkpoint();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Register a best-effort exit-time close for a SQLite handle cache. Returns an
|
|
* unregister callback the cache's orderly close path must invoke, so tests and
|
|
* runtime shutdowns do not accumulate listeners on shared worker processes.
|
|
*/
|
|
export function registerSqliteCacheExitClose(closeAll: () => void): () => void {
|
|
const closeOnExit = () => {
|
|
try {
|
|
closeAll();
|
|
} catch {
|
|
// Exit-time close is best-effort; unclean exits rely on WAL recovery.
|
|
}
|
|
};
|
|
process.once("exit", closeOnExit);
|
|
return () => {
|
|
process.removeListener("exit", closeOnExit);
|
|
};
|
|
}
|
|
|
|
/** Configure per-connection SQLite pragmas in the safe lock-retry/WAL order. */
|
|
export function configureSqliteConnectionPragmas(
|
|
db: DatabaseSync,
|
|
options: SqliteConnectionPragmaOptions = {},
|
|
): SqliteWalMaintenance {
|
|
const { busyTimeoutMs, foreignKeys, synchronous, ...walOptions } = options;
|
|
if (busyTimeoutMs !== undefined) {
|
|
db.exec(
|
|
`PRAGMA busy_timeout = ${normalizeNonNegativeInteger(busyTimeoutMs, "busyTimeoutMs")};`,
|
|
);
|
|
}
|
|
const maintenance = configureSqliteWalMaintenance(db, walOptions);
|
|
if (synchronous) {
|
|
db.exec(`PRAGMA synchronous = ${synchronous};`);
|
|
}
|
|
if (foreignKeys) {
|
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
}
|
|
return maintenance;
|
|
}
|