mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-21 03:55:02 +00:00
Sessions: report applied cleanup stats and tighten doctor hints
This commit is contained in:
@@ -143,4 +143,32 @@ describe("doctor state integrity oauth dir checks", () => {
|
||||
const files = fs.readdirSync(sessionsDir);
|
||||
expect(files.some((name) => name.startsWith("orphan-session.jsonl.deleted."))).toBe(true);
|
||||
});
|
||||
|
||||
it("prints openclaw-only verification hints when recent sessions are missing transcripts", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
setupSessionState(cfg, process.env, process.env.HOME ?? "");
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" });
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "missing-transcript",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await noteStateIntegrity(cfg, { confirmSkipInNonInteractive: vi.fn(async () => false) });
|
||||
|
||||
const text = stateIntegrityText();
|
||||
expect(text).toContain("recent sessions are missing transcripts");
|
||||
expect(text).toMatch(/openclaw sessions --store ".*sessions\.json"/);
|
||||
expect(text).toMatch(/openclaw sessions cleanup --store ".*sessions\.json" --dry-run/);
|
||||
expect(text).not.toContain("--active");
|
||||
expect(text).not.toContain(" ls ");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,6 +207,7 @@ export async function noteStateIntegrity(
|
||||
const displayStateDir = shortenHomePath(stateDir);
|
||||
const displayOauthDir = shortenHomePath(oauthDir);
|
||||
const displaySessionsDir = shortenHomePath(sessionsDir);
|
||||
const displayStorePath = shortenHomePath(storePath);
|
||||
const displayStoreDir = shortenHomePath(storeDir);
|
||||
const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined;
|
||||
const requireOAuthDir = shouldRequireOAuthDir(cfg, env);
|
||||
@@ -410,7 +411,11 @@ export async function noteStateIntegrity(
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
warnings.push(
|
||||
`- ${missing.length}/${recent.length} recent sessions are missing transcripts. Check for deleted session files or split state dirs.`,
|
||||
[
|
||||
`- ${missing.length}/${recent.length} recent sessions are missing transcripts.`,
|
||||
` Verify sessions in store: openclaw sessions --store "${displayStorePath}"`,
|
||||
` Preview cleanup impact: openclaw sessions cleanup --store "${displayStorePath}" --dry-run`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,41 @@ describe("sessionsCleanupCommand", () => {
|
||||
.mockReturnValueOnce({
|
||||
fresh: { sessionId: "fresh", updatedAt: 2 },
|
||||
});
|
||||
mocks.updateSessionStore.mockImplementation(
|
||||
async (
|
||||
_storePath: string,
|
||||
mutator: (store: Record<string, SessionEntry>) => Promise<void> | void,
|
||||
opts?: {
|
||||
onMaintenanceApplied?: (report: {
|
||||
mode: "warn" | "enforce";
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
pruned: number;
|
||||
capped: number;
|
||||
diskBudget: Record<string, unknown> | null;
|
||||
}) => Promise<void> | void;
|
||||
},
|
||||
) => {
|
||||
await mutator({});
|
||||
await opts?.onMaintenanceApplied?.({
|
||||
mode: "enforce",
|
||||
beforeCount: 3,
|
||||
afterCount: 1,
|
||||
pruned: 0,
|
||||
capped: 2,
|
||||
diskBudget: {
|
||||
totalBytesBefore: 1200,
|
||||
totalBytesAfter: 800,
|
||||
removedFiles: 0,
|
||||
removedEntries: 0,
|
||||
freedBytes: 400,
|
||||
maxBytes: 1000,
|
||||
highWaterBytes: 800,
|
||||
overBudget: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { runtime, logs } = makeRuntime();
|
||||
await sessionsCleanupCommand(
|
||||
@@ -112,12 +147,14 @@ describe("sessionsCleanupCommand", () => {
|
||||
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(payload.applied).toBe(true);
|
||||
expect(payload.mode).toBe("enforce");
|
||||
expect(payload.beforeCount).toBe(2);
|
||||
expect(payload.beforeCount).toBe(3);
|
||||
expect(payload.appliedCount).toBe(1);
|
||||
expect(payload.pruned).toBe(0);
|
||||
expect(payload.capped).toBe(2);
|
||||
expect(payload.diskBudget).toEqual(
|
||||
expect.objectContaining({
|
||||
removedFiles: 1,
|
||||
removedEntries: 1,
|
||||
removedFiles: 0,
|
||||
removedEntries: 0,
|
||||
}),
|
||||
);
|
||||
expect(mocks.updateSessionStore).toHaveBeenCalledWith(
|
||||
@@ -126,6 +163,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
expect.objectContaining({
|
||||
activeSessionKey: "agent:main:main",
|
||||
maintenanceOverride: { mode: "enforce" },
|
||||
onMaintenanceApplied: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveMaintenanceConfig,
|
||||
updateSessionStore,
|
||||
type SessionEntry,
|
||||
type SessionMaintenanceApplyReport,
|
||||
} from "../config/sessions.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
@@ -299,6 +300,9 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
|
||||
|
||||
const appliedSummaries: SessionCleanupSummary[] = [];
|
||||
for (const target of targets) {
|
||||
const appliedReportRef: { current: SessionMaintenanceApplyReport | null } = {
|
||||
current: null,
|
||||
};
|
||||
await updateSessionStore(
|
||||
target.storePath,
|
||||
async () => {
|
||||
@@ -309,27 +313,53 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
|
||||
maintenanceOverride: {
|
||||
mode,
|
||||
},
|
||||
onMaintenanceApplied: (report) => {
|
||||
appliedReportRef.current = report;
|
||||
},
|
||||
},
|
||||
);
|
||||
const afterStore = loadSessionStore(target.storePath, { skipCache: true });
|
||||
const preview = previewResults.find((result) => result.summary.storePath === target.storePath);
|
||||
const summary: SessionCleanupSummary = {
|
||||
...(preview?.summary ?? {
|
||||
agentId: target.agentId,
|
||||
storePath: target.storePath,
|
||||
mode,
|
||||
dryRun: false,
|
||||
beforeCount: 0,
|
||||
afterCount: 0,
|
||||
pruned: 0,
|
||||
capped: 0,
|
||||
diskBudget: null,
|
||||
wouldMutate: false,
|
||||
}),
|
||||
dryRun: false,
|
||||
applied: true,
|
||||
appliedCount: Object.keys(afterStore).length,
|
||||
};
|
||||
const appliedReport = appliedReportRef.current;
|
||||
const summary: SessionCleanupSummary =
|
||||
appliedReport === null
|
||||
? {
|
||||
...(preview?.summary ?? {
|
||||
agentId: target.agentId,
|
||||
storePath: target.storePath,
|
||||
mode,
|
||||
dryRun: false,
|
||||
beforeCount: 0,
|
||||
afterCount: 0,
|
||||
pruned: 0,
|
||||
capped: 0,
|
||||
diskBudget: null,
|
||||
wouldMutate: false,
|
||||
}),
|
||||
dryRun: false,
|
||||
applied: true,
|
||||
appliedCount: Object.keys(afterStore).length,
|
||||
}
|
||||
: {
|
||||
agentId: target.agentId,
|
||||
storePath: target.storePath,
|
||||
mode: appliedReport.mode,
|
||||
dryRun: false,
|
||||
beforeCount: appliedReport.beforeCount,
|
||||
afterCount: appliedReport.afterCount,
|
||||
pruned: appliedReport.pruned,
|
||||
capped: appliedReport.capped,
|
||||
diskBudget: appliedReport.diskBudget,
|
||||
wouldMutate:
|
||||
appliedReport.pruned > 0 ||
|
||||
appliedReport.capped > 0 ||
|
||||
Boolean(
|
||||
(appliedReport.diskBudget?.removedEntries ?? 0) > 0 ||
|
||||
(appliedReport.diskBudget?.removedFiles ?? 0) > 0,
|
||||
),
|
||||
applied: true,
|
||||
appliedCount: Object.keys(afterStore).length,
|
||||
};
|
||||
appliedSummaries.push(summary);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { getFileMtimeMs, isCacheEnabled, resolveCacheTtlMs } from "../cache-utils.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js";
|
||||
import { enforceSessionDiskBudget } from "./disk-budget.js";
|
||||
import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js";
|
||||
import { deriveSessionMetaPatch } from "./metadata.js";
|
||||
import { mergeSessionEntry, type SessionEntry } from "./types.js";
|
||||
|
||||
@@ -312,6 +312,15 @@ export type SessionMaintenanceWarning = {
|
||||
wouldCap: boolean;
|
||||
};
|
||||
|
||||
export type SessionMaintenanceApplyReport = {
|
||||
mode: SessionMaintenanceMode;
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
pruned: number;
|
||||
capped: number;
|
||||
diskBudget: SessionDiskBudgetSweepResult | null;
|
||||
};
|
||||
|
||||
type ResolvedSessionMaintenanceConfig = {
|
||||
mode: SessionMaintenanceMode;
|
||||
pruneAfterMs: number;
|
||||
@@ -620,6 +629,8 @@ type SaveSessionStoreOptions = {
|
||||
activeSessionKey?: string;
|
||||
/** Optional callback for warn-only maintenance. */
|
||||
onWarn?: (warning: SessionMaintenanceWarning) => void | Promise<void>;
|
||||
/** Optional callback with maintenance stats after a save. */
|
||||
onMaintenanceApplied?: (report: SessionMaintenanceApplyReport) => void | Promise<void>;
|
||||
/** Optional overrides used by maintenance commands. */
|
||||
maintenanceOverride?: Partial<ResolvedSessionMaintenanceConfig>;
|
||||
};
|
||||
@@ -638,6 +649,7 @@ async function saveSessionStoreUnlocked(
|
||||
// Resolve maintenance config once (avoids repeated loadConfig() calls).
|
||||
const maintenance = { ...resolveMaintenanceConfig(), ...opts?.maintenanceOverride };
|
||||
const shouldWarnOnly = maintenance.mode === "warn";
|
||||
const beforeCount = Object.keys(store).length;
|
||||
|
||||
if (shouldWarnOnly) {
|
||||
const activeSessionKey = opts?.activeSessionKey?.trim();
|
||||
@@ -659,7 +671,7 @@ async function saveSessionStoreUnlocked(
|
||||
await opts?.onWarn?.(warning);
|
||||
}
|
||||
}
|
||||
await enforceSessionDiskBudget({
|
||||
const diskBudget = await enforceSessionDiskBudget({
|
||||
store,
|
||||
storePath,
|
||||
activeSessionKey: opts?.activeSessionKey,
|
||||
@@ -667,17 +679,25 @@ async function saveSessionStoreUnlocked(
|
||||
warnOnly: true,
|
||||
log,
|
||||
});
|
||||
await opts?.onMaintenanceApplied?.({
|
||||
mode: maintenance.mode,
|
||||
beforeCount,
|
||||
afterCount: Object.keys(store).length,
|
||||
pruned: 0,
|
||||
capped: 0,
|
||||
diskBudget,
|
||||
});
|
||||
} else {
|
||||
// Prune stale entries and cap total count before serializing.
|
||||
const removedSessionFiles = new Map<string, string | undefined>();
|
||||
pruneStaleEntries(store, maintenance.pruneAfterMs, {
|
||||
const pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, {
|
||||
onPruned: ({ entry }) => {
|
||||
if (!removedSessionFiles.has(entry.sessionId) || entry.sessionFile) {
|
||||
removedSessionFiles.set(entry.sessionId, entry.sessionFile);
|
||||
}
|
||||
},
|
||||
});
|
||||
capEntryCount(store, maintenance.maxEntries, {
|
||||
const capped = capEntryCount(store, maintenance.maxEntries, {
|
||||
onCapped: ({ entry }) => {
|
||||
if (!removedSessionFiles.has(entry.sessionId) || entry.sessionFile) {
|
||||
removedSessionFiles.set(entry.sessionId, entry.sessionFile);
|
||||
@@ -725,7 +745,7 @@ async function saveSessionStoreUnlocked(
|
||||
// Rotate the on-disk file if it exceeds the size threshold.
|
||||
await rotateSessionFile(storePath, maintenance.rotateBytes);
|
||||
|
||||
await enforceSessionDiskBudget({
|
||||
const diskBudget = await enforceSessionDiskBudget({
|
||||
store,
|
||||
storePath,
|
||||
activeSessionKey: opts?.activeSessionKey,
|
||||
@@ -733,6 +753,14 @@ async function saveSessionStoreUnlocked(
|
||||
warnOnly: false,
|
||||
log,
|
||||
});
|
||||
await opts?.onMaintenanceApplied?.({
|
||||
mode: maintenance.mode,
|
||||
beforeCount,
|
||||
afterCount: Object.keys(store).length,
|
||||
pruned,
|
||||
capped,
|
||||
diskBudget,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user