From ff076fc358aa88e80354d5165e951a2f79477535 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 23 Feb 2026 13:08:38 -0500 Subject: [PATCH] CLI: add per-session cleanup action table for dry-runs --- docs/cli/sessions.md | 1 + src/commands/sessions-cleanup.test.ts | 42 +++++-- src/commands/sessions-cleanup.ts | 115 ++++++++++++++++- src/commands/sessions-table.ts | 148 ++++++++++++++++++++++ src/commands/sessions.ts | 174 +++++++------------------- 5 files changed, 338 insertions(+), 142 deletions(-) create mode 100644 src/commands/sessions-table.ts diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index a15e4250c13..056676d28d9 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -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 `: protect a specific active key from disk-budget eviction. - `--store `: run against a specific `sessions.json` file. diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 3436d993912..4844c18e409 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -61,13 +61,20 @@ describe("sessionsCleanupCommand", () => { maxDiskBytes: null, highWaterBytes: null, }); - mocks.pruneStaleEntries.mockImplementation((store: Record) => { - if (store.stale) { - delete store.stale; - return 1; - } - return 0; - }); + mocks.pruneStaleEntries.mockImplementation( + ( + store: Record, + _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); + }); }); diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index fd2c79f4051..088a6ccf7d6 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -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; + cappedKeys: Set; + budgetEvictedKeys: Set; +}): 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(); + const cappedKeys = new Set(); + 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(); + 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) { diff --git a/src/commands/sessions-table.ts b/src/commands/sessions-table.ts new file mode 100644 index 00000000000..a9e47f664a2 --- /dev/null +++ b/src/commands/sessions-table.ts @@ -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): 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; +} diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 53221559479..3bd6ad74105 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -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): 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());