mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:48:28 +00:00
Session/Cron maintenance hardening and cleanup UX (#24753)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 7533b85156
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
This commit is contained in:
committed by
GitHub
parent
29b19455e3
commit
eff3c5c707
@@ -8,7 +8,11 @@ import {
|
||||
} from "../config/sessions.js";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
|
||||
import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js";
|
||||
import {
|
||||
appendCronRunLog,
|
||||
resolveCronRunLogPath,
|
||||
resolveCronRunLogPruneOptions,
|
||||
} from "../cron/run-log.js";
|
||||
import { CronService } from "../cron/service.js";
|
||||
import { resolveCronStorePath } from "../cron/store.js";
|
||||
import { normalizeHttpWebhookUrl } from "../cron/webhook-url.js";
|
||||
@@ -144,6 +148,7 @@ export function buildGatewayCronService(params: {
|
||||
};
|
||||
|
||||
const defaultAgentId = resolveDefaultAgentId(params.cfg);
|
||||
const runLogPrune = resolveCronRunLogPruneOptions(params.cfg.cron?.runLog);
|
||||
const resolveSessionStorePath = (agentId?: string) =>
|
||||
resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: agentId ?? defaultAgentId,
|
||||
@@ -289,25 +294,29 @@ export function buildGatewayCronService(params: {
|
||||
storePath,
|
||||
jobId: evt.jobId,
|
||||
});
|
||||
void appendCronRunLog(logPath, {
|
||||
ts: Date.now(),
|
||||
jobId: evt.jobId,
|
||||
action: "finished",
|
||||
status: evt.status,
|
||||
error: evt.error,
|
||||
summary: evt.summary,
|
||||
delivered: evt.delivered,
|
||||
deliveryStatus: evt.deliveryStatus,
|
||||
deliveryError: evt.deliveryError,
|
||||
sessionId: evt.sessionId,
|
||||
sessionKey: evt.sessionKey,
|
||||
runAtMs: evt.runAtMs,
|
||||
durationMs: evt.durationMs,
|
||||
nextRunAtMs: evt.nextRunAtMs,
|
||||
model: evt.model,
|
||||
provider: evt.provider,
|
||||
usage: evt.usage,
|
||||
}).catch((err) => {
|
||||
void appendCronRunLog(
|
||||
logPath,
|
||||
{
|
||||
ts: Date.now(),
|
||||
jobId: evt.jobId,
|
||||
action: "finished",
|
||||
status: evt.status,
|
||||
error: evt.error,
|
||||
summary: evt.summary,
|
||||
delivered: evt.delivered,
|
||||
deliveryStatus: evt.deliveryStatus,
|
||||
deliveryError: evt.deliveryError,
|
||||
sessionId: evt.sessionId,
|
||||
sessionKey: evt.sessionKey,
|
||||
runAtMs: evt.runAtMs,
|
||||
durationMs: evt.durationMs,
|
||||
nextRunAtMs: evt.nextRunAtMs,
|
||||
model: evt.model,
|
||||
provider: evt.provider,
|
||||
usage: evt.usage,
|
||||
},
|
||||
runLogPrune,
|
||||
).catch((err) => {
|
||||
cronLogger.warn({ err: String(err), logPath }, "cron: run log append failed");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
formatSessionArchiveTimestamp,
|
||||
parseSessionArchiveTimestamp,
|
||||
type SessionArchiveReason,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionTranscriptPath,
|
||||
resolveSessionTranscriptPathInDir,
|
||||
@@ -159,10 +162,19 @@ export function resolveSessionTranscriptCandidates(
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
export type ArchiveFileReason = "bak" | "reset" | "deleted";
|
||||
export type ArchiveFileReason = SessionArchiveReason;
|
||||
|
||||
function canonicalizePathForComparison(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
try {
|
||||
return fs.realpathSync(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string {
|
||||
const ts = new Date().toISOString().replaceAll(":", "-");
|
||||
const ts = formatSessionArchiveTimestamp();
|
||||
const archived = `${filePath}.${reason}.${ts}`;
|
||||
fs.renameSync(filePath, archived);
|
||||
return archived;
|
||||
@@ -178,19 +190,35 @@ export function archiveSessionTranscripts(opts: {
|
||||
sessionFile?: string;
|
||||
agentId?: string;
|
||||
reason: "reset" | "deleted";
|
||||
/**
|
||||
* When true, only archive files resolved under the session store directory.
|
||||
* This prevents maintenance operations from mutating paths outside the agent sessions dir.
|
||||
*/
|
||||
restrictToStoreDir?: boolean;
|
||||
}): string[] {
|
||||
const archived: string[] = [];
|
||||
const storeDir =
|
||||
opts.restrictToStoreDir && opts.storePath
|
||||
? canonicalizePathForComparison(path.dirname(opts.storePath))
|
||||
: null;
|
||||
for (const candidate of resolveSessionTranscriptCandidates(
|
||||
opts.sessionId,
|
||||
opts.storePath,
|
||||
opts.sessionFile,
|
||||
opts.agentId,
|
||||
)) {
|
||||
if (!fs.existsSync(candidate)) {
|
||||
const candidatePath = canonicalizePathForComparison(candidate);
|
||||
if (storeDir) {
|
||||
const relative = path.relative(storeDir, candidatePath);
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(candidatePath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
archived.push(archiveFileOnDisk(candidate, opts.reason));
|
||||
archived.push(archiveFileOnDisk(candidatePath, opts.reason));
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
@@ -198,32 +226,10 @@ export function archiveSessionTranscripts(opts: {
|
||||
return archived;
|
||||
}
|
||||
|
||||
function restoreArchiveTimestamp(raw: string): string {
|
||||
const [datePart, timePart] = raw.split("T");
|
||||
if (!datePart || !timePart) {
|
||||
return raw;
|
||||
}
|
||||
return `${datePart}T${timePart.replace(/-/g, ":")}`;
|
||||
}
|
||||
|
||||
function parseArchivedTimestamp(fileName: string, reason: ArchiveFileReason): number | null {
|
||||
const marker = `.${reason}.`;
|
||||
const index = fileName.lastIndexOf(marker);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
const raw = fileName.slice(index + marker.length);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = Date.parse(restoreArchiveTimestamp(raw));
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
export async function cleanupArchivedSessionTranscripts(opts: {
|
||||
directories: string[];
|
||||
olderThanMs: number;
|
||||
reason?: "deleted";
|
||||
reason?: ArchiveFileReason;
|
||||
nowMs?: number;
|
||||
}): Promise<{ removed: number; scanned: number }> {
|
||||
if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) {
|
||||
@@ -238,7 +244,7 @@ export async function cleanupArchivedSessionTranscripts(opts: {
|
||||
for (const dir of directories) {
|
||||
const entries = await fs.promises.readdir(dir).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
const timestamp = parseArchivedTimestamp(entry, reason);
|
||||
const timestamp = parseSessionArchiveTimestamp(entry, reason);
|
||||
if (timestamp == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user