CLI: add per-session cleanup action table for dry-runs

This commit is contained in:
Gustavo Madeira Santana 2026-02-23 13:08:38 -05:00 committed by Shakker
parent 382bdce4b3
commit ff076fc358
No known key found for this signature in database
5 changed files with 338 additions and 142 deletions

View File

@ -29,6 +29,7 @@ openclaw sessions cleanup --json
`openclaw sessions cleanup` uses `session.maintenance` settings from config:
- `--dry-run`: preview how many entries would be pruned/capped without writing.
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed.
- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`.
- `--active-key <key>`: protect a specific active key from disk-budget eviction.
- `--store <path>`: run against a specific `sessions.json` file.

View File

@ -61,13 +61,20 @@ describe("sessionsCleanupCommand", () => {
maxDiskBytes: null,
highWaterBytes: null,
});
mocks.pruneStaleEntries.mockImplementation((store: Record<string, SessionEntry>) => {
if (store.stale) {
delete store.stale;
return 1;
}
return 0;
});
mocks.pruneStaleEntries.mockImplementation(
(
store: Record<string, SessionEntry>,
_maxAgeMs: number,
opts?: { onPruned?: (params: { key: string; entry: SessionEntry }) => void },
) => {
if (store.stale) {
opts?.onPruned?.({ key: "stale", entry: store.stale });
delete store.stale;
return 1;
}
return 0;
},
);
mocks.capEntryCount.mockImplementation(() => 0);
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.enforceSessionDiskBudget.mockResolvedValue({
@ -151,4 +158,25 @@ describe("sessionsCleanupCommand", () => {
}),
);
});
it("renders a dry-run action table with keep/prune actions", async () => {
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
mocks.loadSessionStore.mockReturnValue({
stale: { sessionId: "stale", updatedAt: 1, model: "pi:opus" },
fresh: { sessionId: "fresh", updatedAt: 2, model: "pi:opus" },
});
const { runtime, logs } = makeRuntime();
await sessionsCleanupCommand(
{
dryRun: true,
},
runtime,
);
expect(logs.some((line) => line.includes("Planned session actions:"))).toBe(true);
expect(logs.some((line) => line.includes("Action") && line.includes("Key"))).toBe(true);
expect(logs.some((line) => line.includes("fresh") && line.includes("keep"))).toBe(true);
expect(logs.some((line) => line.includes("stale") && line.includes("prune-stale"))).toBe(true);
});
});

View File

@ -10,6 +10,19 @@ import {
updateSessionStore,
} from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import {
formatSessionAgeCell,
formatSessionFlagsCell,
formatSessionKeyCell,
formatSessionModelCell,
resolveSessionDisplayDefaults,
resolveSessionDisplayModel,
SESSION_AGE_PAD,
SESSION_KEY_PAD,
SESSION_MODEL_PAD,
toSessionDisplayRows,
} from "./sessions-table.js";
export type SessionsCleanupOptions = {
store?: string;
@ -19,8 +32,48 @@ export type SessionsCleanupOptions = {
json?: boolean;
};
type SessionCleanupAction = "keep" | "prune-stale" | "cap-overflow" | "evict-budget";
const ACTION_PAD = 12;
function resolveSessionCleanupAction(params: {
key: string;
staleKeys: Set<string>;
cappedKeys: Set<string>;
budgetEvictedKeys: Set<string>;
}): SessionCleanupAction {
if (params.staleKeys.has(params.key)) {
return "prune-stale";
}
if (params.cappedKeys.has(params.key)) {
return "cap-overflow";
}
if (params.budgetEvictedKeys.has(params.key)) {
return "evict-budget";
}
return "keep";
}
function formatCleanupActionCell(action: SessionCleanupAction, rich: boolean): string {
const label = action.padEnd(ACTION_PAD);
if (!rich) {
return label;
}
if (action === "keep") {
return theme.muted(label);
}
if (action === "prune-stale") {
return theme.warn(label);
}
if (action === "cap-overflow") {
return theme.accentBright(label);
}
return theme.error(label);
}
export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runtime: RuntimeEnv) {
const cfg = loadConfig();
const displayDefaults = resolveSessionDisplayDefaults(cfg);
const defaultAgentId = resolveDefaultAgentId(cfg);
const storePath = resolveStorePath(opts.store ?? cfg.session?.store, { agentId: defaultAgentId });
const maintenance = resolveMaintenanceConfig();
@ -28,8 +81,21 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
const beforeStore = loadSessionStore(storePath, { skipCache: true });
const previewStore = structuredClone(beforeStore);
const pruned = pruneStaleEntries(previewStore, maintenance.pruneAfterMs, { log: false });
const capped = capEntryCount(previewStore, maintenance.maxEntries, { log: false });
const staleKeys = new Set<string>();
const cappedKeys = new Set<string>();
const pruned = pruneStaleEntries(previewStore, maintenance.pruneAfterMs, {
log: false,
onPruned: ({ key }) => {
staleKeys.add(key);
},
});
const capped = capEntryCount(previewStore, maintenance.maxEntries, {
log: false,
onCapped: ({ key }) => {
cappedKeys.add(key);
},
});
const beforeBudgetStore = structuredClone(previewStore);
const diskBudget = await enforceSessionDiskBudget({
store: previewStore,
storePath,
@ -38,6 +104,13 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
warnOnly: false,
dryRun: true,
});
const budgetEvictedKeys = new Set<string>();
for (const key of Object.keys(beforeBudgetStore)) {
if (Object.hasOwn(previewStore, key)) {
continue;
}
budgetEvictedKeys.add(key);
}
const beforeCount = Object.keys(beforeStore).length;
const afterPreviewCount = Object.keys(previewStore).length;
const wouldMutate =
@ -57,6 +130,16 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
wouldMutate,
};
const actionRows = toSessionDisplayRows(beforeStore).map((row) => ({
row,
action: resolveSessionCleanupAction({
key: row.key,
staleKeys,
cappedKeys,
budgetEvictedKeys,
}),
}));
if (opts.json && opts.dryRun) {
runtime.log(JSON.stringify(summary, null, 2));
return;
@ -65,7 +148,9 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
if (!opts.json) {
runtime.log(`Session store: ${storePath}`);
runtime.log(`Maintenance mode: ${effectiveMode}`);
runtime.log(`Entries: ${beforeCount} -> ${afterPreviewCount}`);
runtime.log(
`Entries: ${beforeCount} -> ${afterPreviewCount} (remove ${beforeCount - afterPreviewCount})`,
);
runtime.log(`Would prune stale: ${pruned}`);
runtime.log(`Would cap overflow: ${capped}`);
if (diskBudget) {
@ -73,6 +158,30 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
`Would enforce disk budget: ${diskBudget.totalBytesBefore} -> ${diskBudget.totalBytesAfter} bytes (files ${diskBudget.removedFiles}, entries ${diskBudget.removedEntries})`,
);
}
if (opts.dryRun && actionRows.length > 0) {
const rich = isRich();
runtime.log("");
runtime.log("Planned session actions:");
const header = [
"Action".padEnd(ACTION_PAD),
"Key".padEnd(SESSION_KEY_PAD),
"Age".padEnd(SESSION_AGE_PAD),
"Model".padEnd(SESSION_MODEL_PAD),
"Flags",
].join(" ");
runtime.log(rich ? theme.heading(header) : header);
for (const actionRow of actionRows) {
const model = resolveSessionDisplayModel(cfg, actionRow.row, displayDefaults);
const line = [
formatCleanupActionCell(actionRow.action, rich),
formatSessionKeyCell(actionRow.row.key, rich),
formatSessionAgeCell(actionRow.row.updatedAt, rich),
formatSessionModelCell(model, rich),
formatSessionFlagsCell(actionRow.row, rich),
].join(" ");
runtime.log(line.trimEnd());
}
}
}
if (opts.dryRun) {

View File

@ -0,0 +1,148 @@
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import type { SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveSessionModelRef } from "../gateway/session-utils.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { theme } from "../terminal/theme.js";
export type SessionDisplayRow = {
key: string;
updatedAt: number | null;
ageMs: number | null;
sessionId?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
responseUsage?: string;
groupActivation?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
model?: string;
modelProvider?: string;
providerOverride?: string;
modelOverride?: string;
contextTokens?: number;
};
export type SessionDisplayDefaults = {
model: string;
};
export const SESSION_KEY_PAD = 26;
export const SESSION_AGE_PAD = 9;
export const SESSION_MODEL_PAD = 14;
export function toSessionDisplayRows(store: Record<string, SessionEntry>): SessionDisplayRow[] {
return Object.entries(store)
.map(([key, entry]) => {
const updatedAt = entry?.updatedAt ?? null;
return {
key,
updatedAt,
ageMs: updatedAt ? Date.now() - updatedAt : null,
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
elevatedLevel: entry?.elevatedLevel,
responseUsage: entry?.responseUsage,
groupActivation: entry?.groupActivation,
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: entry?.totalTokens,
totalTokensFresh: entry?.totalTokensFresh,
model: entry?.model,
modelProvider: entry?.modelProvider,
providerOverride: entry?.providerOverride,
modelOverride: entry?.modelOverride,
contextTokens: entry?.contextTokens,
} satisfies SessionDisplayRow;
})
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
export function resolveSessionDisplayDefaults(cfg: OpenClawConfig): SessionDisplayDefaults {
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
return {
model: resolved.model ?? DEFAULT_MODEL,
};
}
export function resolveSessionDisplayModel(
cfg: OpenClawConfig,
row: Pick<
SessionDisplayRow,
"key" | "model" | "modelProvider" | "modelOverride" | "providerOverride"
>,
defaults: SessionDisplayDefaults,
): string {
const resolved = resolveSessionModelRef(cfg, row, parseAgentSessionKey(row.key)?.agentId);
return resolved.model ?? defaults.model;
}
function truncateSessionKey(key: string): string {
if (key.length <= SESSION_KEY_PAD) {
return key;
}
const head = Math.max(4, SESSION_KEY_PAD - 10);
return `${key.slice(0, head)}...${key.slice(-6)}`;
}
export function formatSessionKeyCell(key: string, rich: boolean): string {
const label = truncateSessionKey(key).padEnd(SESSION_KEY_PAD);
return rich ? theme.accent(label) : label;
}
export function formatSessionAgeCell(updatedAt: number | null | undefined, rich: boolean): string {
const ageLabel = updatedAt ? formatTimeAgo(Date.now() - updatedAt) : "unknown";
const padded = ageLabel.padEnd(SESSION_AGE_PAD);
return rich ? theme.muted(padded) : padded;
}
export function formatSessionModelCell(model: string | null | undefined, rich: boolean): string {
const label = (model ?? "unknown").padEnd(SESSION_MODEL_PAD);
return rich ? theme.info(label) : label;
}
export function formatSessionFlagsCell(
row: Pick<
SessionDisplayRow,
| "thinkingLevel"
| "verboseLevel"
| "reasoningLevel"
| "elevatedLevel"
| "responseUsage"
| "groupActivation"
| "systemSent"
| "abortedLastRun"
| "sessionId"
>,
rich: boolean,
): string {
const flags = [
row.thinkingLevel ? `think:${row.thinkingLevel}` : null,
row.verboseLevel ? `verbose:${row.verboseLevel}` : null,
row.reasoningLevel ? `reasoning:${row.reasoningLevel}` : null,
row.elevatedLevel ? `elev:${row.elevatedLevel}` : null,
row.responseUsage ? `usage:${row.responseUsage}` : null,
row.groupActivation ? `activation:${row.groupActivation}` : null,
row.systemSent ? "system" : null,
row.abortedLastRun ? "aborted" : null,
row.sessionId ? `id:${row.sessionId}` : null,
].filter(Boolean);
const label = flags.join(" ");
return label.length === 0 ? "" : rich ? theme.muted(label) : label;
}

View File

@ -1,62 +1,39 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveFreshSessionTotalTokens,
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js";
import { classifySessionKey } from "../gateway/session-utils.js";
import { info } from "../globals.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { parseAgentSessionKey } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import {
formatSessionAgeCell,
formatSessionFlagsCell,
formatSessionKeyCell,
formatSessionModelCell,
resolveSessionDisplayDefaults,
resolveSessionDisplayModel,
SESSION_AGE_PAD,
SESSION_KEY_PAD,
SESSION_MODEL_PAD,
type SessionDisplayRow,
toSessionDisplayRows,
} from "./sessions-table.js";
type SessionRow = {
key: string;
type SessionRow = SessionDisplayRow & {
kind: "direct" | "group" | "global" | "unknown";
updatedAt: number | null;
ageMs: number | null;
sessionId?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
responseUsage?: string;
groupActivation?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
model?: string;
modelProvider?: string;
providerOverride?: string;
modelOverride?: string;
contextTokens?: number;
};
const KIND_PAD = 6;
const KEY_PAD = 26;
const AGE_PAD = 9;
const MODEL_PAD = 14;
const TOKENS_PAD = 20;
const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
const truncateKey = (key: string) => {
if (key.length <= KEY_PAD) {
return key;
}
const head = Math.max(4, KEY_PAD - 10);
return `${key.slice(0, head)}...${key.slice(-6)}`;
};
const colorByPct = (label: string, pct: number | null, rich: boolean) => {
if (!rich || pct === null) {
return label;
@ -108,80 +85,17 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
return theme.muted(label);
};
const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => {
const ageLabel = updatedAt ? formatTimeAgo(Date.now() - updatedAt) : "unknown";
const padded = ageLabel.padEnd(AGE_PAD);
return rich ? theme.muted(padded) : padded;
};
const formatModelCell = (model: string | null | undefined, rich: boolean) => {
const label = (model ?? "unknown").padEnd(MODEL_PAD);
return rich ? theme.info(label) : label;
};
const formatFlagsCell = (row: SessionRow, rich: boolean) => {
const flags = [
row.thinkingLevel ? `think:${row.thinkingLevel}` : null,
row.verboseLevel ? `verbose:${row.verboseLevel}` : null,
row.reasoningLevel ? `reasoning:${row.reasoningLevel}` : null,
row.elevatedLevel ? `elev:${row.elevatedLevel}` : null,
row.responseUsage ? `usage:${row.responseUsage}` : null,
row.groupActivation ? `activation:${row.groupActivation}` : null,
row.systemSent ? "system" : null,
row.abortedLastRun ? "aborted" : null,
row.sessionId ? `id:${row.sessionId}` : null,
].filter(Boolean);
const label = flags.join(" ");
return label.length === 0 ? "" : rich ? theme.muted(label) : label;
};
function toRows(store: Record<string, SessionEntry>): SessionRow[] {
return Object.entries(store)
.map(([key, entry]) => {
const updatedAt = entry?.updatedAt ?? null;
return {
key,
kind: classifySessionKey(key, entry),
updatedAt,
ageMs: updatedAt ? Date.now() - updatedAt : null,
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
elevatedLevel: entry?.elevatedLevel,
responseUsage: entry?.responseUsage,
groupActivation: entry?.groupActivation,
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: entry?.totalTokens,
totalTokensFresh: entry?.totalTokensFresh,
model: entry?.model,
modelProvider: entry?.modelProvider,
providerOverride: entry?.providerOverride,
modelOverride: entry?.modelOverride,
contextTokens: entry?.contextTokens,
} satisfies SessionRow;
})
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
export async function sessionsCommand(
opts: { json?: boolean; store?: string; active?: string },
runtime: RuntimeEnv,
) {
const cfg = loadConfig();
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const displayDefaults = resolveSessionDisplayDefaults(cfg);
const configContextTokens =
cfg.agents?.defaults?.contextTokens ??
lookupContextTokens(resolved.model) ??
lookupContextTokens(displayDefaults.model) ??
DEFAULT_CONTEXT_TOKENS;
const configModel = resolved.model ?? DEFAULT_MODEL;
const configModel = displayDefaults.model;
const defaultAgentId = resolveDefaultAgentId(cfg);
const storePath = resolveStorePath(opts.store ?? cfg.session?.store, { agentId: defaultAgentId });
const store = loadSessionStore(storePath);
@ -197,15 +111,20 @@ export async function sessionsCommand(
activeMinutes = parsed;
}
const rows = toRows(store).filter((row) => {
if (activeMinutes === undefined) {
return true;
}
if (!row.updatedAt) {
return false;
}
return Date.now() - row.updatedAt <= activeMinutes * 60_000;
});
const rows = toSessionDisplayRows(store)
.map((row) => ({
...row,
kind: classifySessionKey(row.key, store[row.key]),
}))
.filter((row) => {
if (activeMinutes === undefined) {
return true;
}
if (!row.updatedAt) {
return false;
}
return Date.now() - row.updatedAt <= activeMinutes * 60_000;
});
if (opts.json) {
runtime.log(
@ -215,12 +134,7 @@ export async function sessionsCommand(
count: rows.length,
activeMinutes: activeMinutes ?? null,
sessions: rows.map((r) => {
const resolvedModel = resolveSessionModelRef(
cfg,
r,
parseAgentSessionKey(r.key)?.agentId,
);
const model = resolvedModel.model ?? configModel;
const model = resolveSessionDisplayModel(cfg, r, displayDefaults);
return {
...r,
totalTokens: resolveFreshSessionTotalTokens(r) ?? null,
@ -252,9 +166,9 @@ export async function sessionsCommand(
const rich = isRich();
const header = [
"Kind".padEnd(KIND_PAD),
"Key".padEnd(KEY_PAD),
"Age".padEnd(AGE_PAD),
"Model".padEnd(MODEL_PAD),
"Key".padEnd(SESSION_KEY_PAD),
"Age".padEnd(SESSION_AGE_PAD),
"Model".padEnd(SESSION_MODEL_PAD),
"Tokens (ctx %)".padEnd(TOKENS_PAD),
"Flags",
].join(" ");
@ -262,21 +176,17 @@ export async function sessionsCommand(
runtime.log(rich ? theme.heading(header) : header);
for (const row of rows) {
const resolvedModel = resolveSessionModelRef(cfg, row, parseAgentSessionKey(row.key)?.agentId);
const model = resolvedModel.model ?? configModel;
const model = resolveSessionDisplayModel(cfg, row, displayDefaults) ?? configModel;
const contextTokens = row.contextTokens ?? lookupContextTokens(model) ?? configContextTokens;
const total = resolveFreshSessionTotalTokens(row);
const keyLabel = truncateKey(row.key).padEnd(KEY_PAD);
const keyCell = rich ? theme.accent(keyLabel) : keyLabel;
const line = [
formatKindCell(row.kind, rich),
keyCell,
formatAgeCell(row.updatedAt, rich),
formatModelCell(model, rich),
formatSessionKeyCell(row.key, rich),
formatSessionAgeCell(row.updatedAt, rich),
formatSessionModelCell(model, rich),
formatTokensCell(total, contextTokens ?? null, rich),
formatFlagsCell(row, rich),
formatSessionFlagsCell(row, rich),
].join(" ");
runtime.log(line.trimEnd());