mirror of https://github.com/openclaw/openclaw.git
CLI: add --agent/--all-agents for sessions and cleanup
This commit is contained in:
parent
ff076fc358
commit
ede32ceed7
|
|
@ -11,16 +11,27 @@ List stored conversation sessions.
|
|||
|
||||
```bash
|
||||
openclaw sessions
|
||||
openclaw sessions --agent work
|
||||
openclaw sessions --all-agents
|
||||
openclaw sessions --active 120
|
||||
openclaw sessions --json
|
||||
```
|
||||
|
||||
Scope selection:
|
||||
|
||||
- default: configured default agent store
|
||||
- `--agent <id>`: one configured agent store
|
||||
- `--all-agents`: aggregate all configured agent stores
|
||||
- `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`)
|
||||
|
||||
## Cleanup maintenance
|
||||
|
||||
Run maintenance now (instead of waiting for the next write cycle):
|
||||
|
||||
```bash
|
||||
openclaw sessions cleanup --dry-run
|
||||
openclaw sessions cleanup --agent work --dry-run
|
||||
openclaw sessions cleanup --all-agents --dry-run
|
||||
openclaw sessions cleanup --enforce
|
||||
openclaw sessions cleanup --enforce --active-key "agent:main:telegram:dm:123"
|
||||
openclaw sessions cleanup --json
|
||||
|
|
@ -32,8 +43,10 @@ openclaw sessions cleanup --json
|
|||
- 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.
|
||||
- `--agent <id>`: run cleanup for one configured agent store.
|
||||
- `--all-agents`: run cleanup for all configured agent stores.
|
||||
- `--store <path>`: run against a specific `sessions.json` file.
|
||||
- `--json`: print one JSON summary object. Dry-run output includes projected `diskBudget` impact (`totalBytesBefore/After`, `removedFiles`, `removedEntries`) when disk budgeting is enabled.
|
||||
- `--json`: print a JSON summary. With `--all-agents`, output includes one summary per store.
|
||||
|
||||
Related:
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,29 @@ describe("registerStatusHealthSessionsCommands", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("runs sessions command with --agent forwarding", async () => {
|
||||
await runCli(["sessions", "--agent", "work"]);
|
||||
|
||||
expect(sessionsCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agent: "work",
|
||||
allAgents: false,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs sessions command with --all-agents forwarding", async () => {
|
||||
await runCli(["sessions", "--all-agents"]);
|
||||
|
||||
expect(sessionsCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allAgents: true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs sessions cleanup subcommand with forwarded options", async () => {
|
||||
await runCli([
|
||||
"sessions",
|
||||
|
|
@ -156,6 +179,8 @@ describe("registerStatusHealthSessionsCommands", () => {
|
|||
expect(sessionsCleanupCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
store: "/tmp/sessions.json",
|
||||
agent: undefined,
|
||||
allAgents: false,
|
||||
dryRun: true,
|
||||
enforce: true,
|
||||
activeKey: "agent:main:main",
|
||||
|
|
@ -164,4 +189,15 @@ describe("registerStatusHealthSessionsCommands", () => {
|
|||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards parent-level all-agents to cleanup subcommand", async () => {
|
||||
await runCli(["sessions", "--all-agents", "cleanup", "--dry-run"]);
|
||||
|
||||
expect(sessionsCleanupCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allAgents: true,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -118,12 +118,16 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
|||
.option("--json", "Output as JSON", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.option("--store <path>", "Path to session store (default: resolved from config)")
|
||||
.option("--agent <id>", "Agent id to inspect (default: configured default agent)")
|
||||
.option("--all-agents", "Aggregate sessions across all configured agents", false)
|
||||
.option("--active <minutes>", "Only show sessions updated within the past N minutes")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
["openclaw sessions", "List all sessions."],
|
||||
["openclaw sessions --agent work", "List sessions for one agent."],
|
||||
["openclaw sessions --all-agents", "Aggregate sessions across agents."],
|
||||
["openclaw sessions --active 120", "Only last 2 hours."],
|
||||
["openclaw sessions --json", "Machine-readable output."],
|
||||
["openclaw sessions --store ./tmp/sessions.json", "Use a specific session store."],
|
||||
|
|
@ -142,6 +146,8 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
|||
{
|
||||
json: Boolean(opts.json),
|
||||
store: opts.store as string | undefined,
|
||||
agent: opts.agent as string | undefined,
|
||||
allAgents: Boolean(opts.allAgents),
|
||||
active: opts.active as string | undefined,
|
||||
},
|
||||
defaultRuntime,
|
||||
|
|
@ -153,6 +159,8 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
|||
.command("cleanup")
|
||||
.description("Run session-store maintenance now")
|
||||
.option("--store <path>", "Path to session store (default: resolved from config)")
|
||||
.option("--agent <id>", "Agent id to maintain (default: configured default agent)")
|
||||
.option("--all-agents", "Run maintenance across all configured agents", false)
|
||||
.option("--dry-run", "Preview maintenance actions without writing", false)
|
||||
.option("--enforce", "Apply maintenance even when configured mode is warn", false)
|
||||
.option("--active-key <key>", "Protect this session key from budget-eviction")
|
||||
|
|
@ -163,6 +171,8 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
|||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
["openclaw sessions cleanup --dry-run", "Preview stale/cap cleanup."],
|
||||
["openclaw sessions cleanup --enforce", "Apply maintenance now."],
|
||||
["openclaw sessions cleanup --agent work --dry-run", "Preview one agent store."],
|
||||
["openclaw sessions cleanup --all-agents --dry-run", "Preview all agent stores."],
|
||||
[
|
||||
"openclaw sessions cleanup --enforce --store ./tmp/sessions.json",
|
||||
"Use a specific store.",
|
||||
|
|
@ -173,6 +183,8 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
|||
const parentOpts = command.parent?.opts() as
|
||||
| {
|
||||
store?: string;
|
||||
agent?: string;
|
||||
allAgents?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
|
@ -180,6 +192,8 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
|||
await sessionsCleanupCommand(
|
||||
{
|
||||
store: (opts.store as string | undefined) ?? parentOpts?.store,
|
||||
agent: (opts.agent as string | undefined) ?? parentOpts?.agent,
|
||||
allAgents: Boolean(opts.allAgents || parentOpts?.allAgents),
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
enforce: Boolean(opts.enforce),
|
||||
activeKey: opts.activeKey as string | undefined,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ describe("program routes", () => {
|
|||
await expectRunFalse(["sessions"], ["node", "openclaw", "sessions", "--active"]);
|
||||
});
|
||||
|
||||
it("returns false for sessions route when --agent value is missing", async () => {
|
||||
await expectRunFalse(["sessions"], ["node", "openclaw", "sessions", "--agent"]);
|
||||
});
|
||||
|
||||
it("does not fast-route sessions subcommands", () => {
|
||||
expect(findRoutedCommand(["sessions", "cleanup"])).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ const routeSessions: RouteSpec = {
|
|||
match: (path) => path[0] === "sessions" && !path[1],
|
||||
run: async (argv) => {
|
||||
const json = hasFlag(argv, "--json");
|
||||
const allAgents = hasFlag(argv, "--all-agents");
|
||||
const agent = getFlagValue(argv, "--agent");
|
||||
if (agent === null) {
|
||||
return false;
|
||||
}
|
||||
const store = getFlagValue(argv, "--store");
|
||||
if (store === null) {
|
||||
return false;
|
||||
|
|
@ -57,7 +62,7 @@ const routeSessions: RouteSpec = {
|
|||
return false;
|
||||
}
|
||||
const { sessionsCommand } = await import("../../commands/sessions.js");
|
||||
await sessionsCommand({ json, store, active }, defaultRuntime);
|
||||
await sessionsCommand({ json, store, agent, allAgents, active }, defaultRuntime);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveSessionStoreTargets } from "./session-store-targets.js";
|
||||
|
||||
const resolveStorePathMock = vi.hoisted(() => vi.fn());
|
||||
const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentIdsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
resolveStorePath: resolveStorePathMock,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId: resolveDefaultAgentIdMock,
|
||||
listAgentIds: listAgentIdsMock,
|
||||
}));
|
||||
|
||||
describe("resolveSessionStoreTargets", () => {
|
||||
it("resolves the default agent store when no selector is provided", () => {
|
||||
resolveDefaultAgentIdMock.mockReturnValue("main");
|
||||
resolveStorePathMock.mockReturnValue("/tmp/main-sessions.json");
|
||||
|
||||
const targets = resolveSessionStoreTargets({}, {});
|
||||
|
||||
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/main-sessions.json" }]);
|
||||
expect(resolveStorePathMock).toHaveBeenCalledWith(undefined, { agentId: "main" });
|
||||
});
|
||||
|
||||
it("resolves all configured agent stores", () => {
|
||||
listAgentIdsMock.mockReturnValue(["main", "work"]);
|
||||
resolveStorePathMock
|
||||
.mockReturnValueOnce("/tmp/main-sessions.json")
|
||||
.mockReturnValueOnce("/tmp/work-sessions.json");
|
||||
|
||||
const targets = resolveSessionStoreTargets(
|
||||
{
|
||||
session: { store: "~/.openclaw/agents/{agentId}/sessions/sessions.json" },
|
||||
},
|
||||
{ allAgents: true },
|
||||
);
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ agentId: "main", storePath: "/tmp/main-sessions.json" },
|
||||
{ agentId: "work", storePath: "/tmp/work-sessions.json" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unknown agent ids", () => {
|
||||
listAgentIdsMock.mockReturnValue(["main", "work"]);
|
||||
expect(() => resolveSessionStoreTargets({}, { agent: "ghost" })).toThrow(/Unknown agent id/);
|
||||
});
|
||||
|
||||
it("rejects conflicting selectors", () => {
|
||||
expect(() => resolveSessionStoreTargets({}, { agent: "main", allAgents: true })).toThrow(
|
||||
/cannot be used together/i,
|
||||
);
|
||||
expect(() =>
|
||||
resolveSessionStoreTargets({}, { store: "/tmp/sessions.json", allAgents: true }),
|
||||
).toThrow(/cannot be combined/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveStorePath } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
||||
export type SessionStoreSelectionOptions = {
|
||||
store?: string;
|
||||
agent?: string;
|
||||
allAgents?: boolean;
|
||||
};
|
||||
|
||||
export type SessionStoreTarget = {
|
||||
agentId: string;
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
export function resolveSessionStoreTargets(
|
||||
cfg: OpenClawConfig,
|
||||
opts: SessionStoreSelectionOptions,
|
||||
): SessionStoreTarget[] {
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const hasAgent = Boolean(opts.agent?.trim());
|
||||
const allAgents = opts.allAgents === true;
|
||||
if (hasAgent && allAgents) {
|
||||
throw new Error("--agent and --all-agents cannot be used together");
|
||||
}
|
||||
if (opts.store && (hasAgent || allAgents)) {
|
||||
throw new Error("--store cannot be combined with --agent or --all-agents");
|
||||
}
|
||||
|
||||
if (opts.store) {
|
||||
return [
|
||||
{
|
||||
agentId: defaultAgentId,
|
||||
storePath: resolveStorePath(opts.store, { agentId: defaultAgentId }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (allAgents) {
|
||||
return listAgentIds(cfg).map((agentId) => ({
|
||||
agentId,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId }),
|
||||
}));
|
||||
}
|
||||
|
||||
if (hasAgent) {
|
||||
const knownAgents = listAgentIds(cfg);
|
||||
const requested = normalizeAgentId(opts.agent ?? "");
|
||||
if (!knownAgents.includes(requested)) {
|
||||
throw new Error(
|
||||
`Unknown agent id "${opts.agent}". Use "openclaw agents list" to see configured agents.`,
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
agentId: requested,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId: requested }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
agentId: defaultAgentId,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId: defaultAgentId }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -4,8 +4,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(),
|
||||
resolveDefaultAgentId: vi.fn(),
|
||||
resolveStorePath: vi.fn(),
|
||||
resolveSessionStoreTargets: vi.fn(),
|
||||
resolveMaintenanceConfig: vi.fn(),
|
||||
loadSessionStore: vi.fn(),
|
||||
pruneStaleEntries: vi.fn(),
|
||||
|
|
@ -18,12 +17,11 @@ vi.mock("../config/config.js", () => ({
|
|||
loadConfig: mocks.loadConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
|
||||
vi.mock("./session-store-targets.js", () => ({
|
||||
resolveSessionStoreTargets: mocks.resolveSessionStoreTargets,
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
resolveStorePath: mocks.resolveStorePath,
|
||||
resolveMaintenanceConfig: mocks.resolveMaintenanceConfig,
|
||||
loadSessionStore: mocks.loadSessionStore,
|
||||
pruneStaleEntries: mocks.pruneStaleEntries,
|
||||
|
|
@ -50,8 +48,9 @@ describe("sessionsCleanupCommand", () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.loadConfig.mockReturnValue({ session: { store: "/cfg/sessions.json" } });
|
||||
mocks.resolveDefaultAgentId.mockReturnValue("main");
|
||||
mocks.resolveStorePath.mockReturnValue("/resolved/sessions.json");
|
||||
mocks.resolveSessionStoreTargets.mockReturnValue([
|
||||
{ agentId: "main", storePath: "/resolved/sessions.json" },
|
||||
]);
|
||||
mocks.resolveMaintenanceConfig.mockReturnValue({
|
||||
mode: "warn",
|
||||
pruneAfterMs: 7 * 24 * 60 * 60 * 1000,
|
||||
|
|
@ -179,4 +178,31 @@ describe("sessionsCleanupCommand", () => {
|
|||
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);
|
||||
});
|
||||
|
||||
it("returns grouped JSON for --all-agents dry-runs", async () => {
|
||||
mocks.resolveSessionStoreTargets.mockReturnValue([
|
||||
{ agentId: "main", storePath: "/resolved/main-sessions.json" },
|
||||
{ agentId: "work", storePath: "/resolved/work-sessions.json" },
|
||||
]);
|
||||
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
|
||||
mocks.loadSessionStore
|
||||
.mockReturnValueOnce({ stale: { sessionId: "stale-main", updatedAt: 1 } })
|
||||
.mockReturnValueOnce({ stale: { sessionId: "stale-work", updatedAt: 1 } });
|
||||
|
||||
const { runtime, logs } = makeRuntime();
|
||||
await sessionsCleanupCommand(
|
||||
{
|
||||
json: true,
|
||||
dryRun: true,
|
||||
allAgents: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(payload.allAgents).toBe(true);
|
||||
expect(Array.isArray(payload.stores)).toBe(true);
|
||||
expect((payload.stores as unknown[]).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
capEntryCount,
|
||||
|
|
@ -6,11 +5,12 @@ import {
|
|||
loadSessionStore,
|
||||
pruneStaleEntries,
|
||||
resolveMaintenanceConfig,
|
||||
resolveStorePath,
|
||||
updateSessionStore,
|
||||
type SessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { resolveSessionStoreTargets, type SessionStoreTarget } from "./session-store-targets.js";
|
||||
import {
|
||||
formatSessionAgeCell,
|
||||
formatSessionFlagsCell,
|
||||
|
|
@ -26,6 +26,8 @@ import {
|
|||
|
||||
export type SessionsCleanupOptions = {
|
||||
store?: string;
|
||||
agent?: string;
|
||||
allAgents?: boolean;
|
||||
dryRun?: boolean;
|
||||
enforce?: boolean;
|
||||
activeKey?: string;
|
||||
|
|
@ -36,6 +38,25 @@ type SessionCleanupAction = "keep" | "prune-stale" | "cap-overflow" | "evict-bud
|
|||
|
||||
const ACTION_PAD = 12;
|
||||
|
||||
type SessionCleanupActionRow = ReturnType<typeof toSessionDisplayRows>[number] & {
|
||||
action: SessionCleanupAction;
|
||||
};
|
||||
|
||||
type SessionCleanupSummary = {
|
||||
agentId: string;
|
||||
storePath: string;
|
||||
mode: "warn" | "enforce";
|
||||
dryRun: boolean;
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
pruned: number;
|
||||
capped: number;
|
||||
diskBudget: Awaited<ReturnType<typeof enforceSessionDiskBudget>>;
|
||||
wouldMutate: boolean;
|
||||
applied?: true;
|
||||
appliedCount?: number;
|
||||
};
|
||||
|
||||
function resolveSessionCleanupAction(params: {
|
||||
key: string;
|
||||
staleKeys: Set<string>;
|
||||
|
|
@ -71,15 +92,31 @@ function formatCleanupActionCell(action: SessionCleanupAction, rich: boolean): s
|
|||
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();
|
||||
const effectiveMode = opts.enforce ? "enforce" : maintenance.mode;
|
||||
function buildActionRows(params: {
|
||||
beforeStore: Record<string, SessionEntry>;
|
||||
staleKeys: Set<string>;
|
||||
cappedKeys: Set<string>;
|
||||
budgetEvictedKeys: Set<string>;
|
||||
}): SessionCleanupActionRow[] {
|
||||
return toSessionDisplayRows(params.beforeStore).map((row) => ({
|
||||
...row,
|
||||
action: resolveSessionCleanupAction({
|
||||
key: row.key,
|
||||
staleKeys: params.staleKeys,
|
||||
cappedKeys: params.cappedKeys,
|
||||
budgetEvictedKeys: params.budgetEvictedKeys,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
const beforeStore = loadSessionStore(storePath, { skipCache: true });
|
||||
async function previewStoreCleanup(params: {
|
||||
target: SessionStoreTarget;
|
||||
mode: "warn" | "enforce";
|
||||
dryRun: boolean;
|
||||
activeKey?: string;
|
||||
}) {
|
||||
const maintenance = resolveMaintenanceConfig();
|
||||
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
|
||||
const previewStore = structuredClone(beforeStore);
|
||||
const staleKeys = new Set<string>();
|
||||
const cappedKeys = new Set<string>();
|
||||
|
|
@ -98,18 +135,17 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
|
|||
const beforeBudgetStore = structuredClone(previewStore);
|
||||
const diskBudget = await enforceSessionDiskBudget({
|
||||
store: previewStore,
|
||||
storePath,
|
||||
activeSessionKey: opts.activeKey,
|
||||
storePath: params.target.storePath,
|
||||
activeSessionKey: params.activeKey,
|
||||
maintenance,
|
||||
warnOnly: false,
|
||||
dryRun: true,
|
||||
});
|
||||
const budgetEvictedKeys = new Set<string>();
|
||||
for (const key of Object.keys(beforeBudgetStore)) {
|
||||
if (Object.hasOwn(previewStore, key)) {
|
||||
continue;
|
||||
if (!Object.hasOwn(previewStore, key)) {
|
||||
budgetEvictedKeys.add(key);
|
||||
}
|
||||
budgetEvictedKeys.add(key);
|
||||
}
|
||||
const beforeCount = Object.keys(beforeStore).length;
|
||||
const afterPreviewCount = Object.keys(previewStore).length;
|
||||
|
|
@ -118,10 +154,11 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
|
|||
capped > 0 ||
|
||||
Boolean((diskBudget?.removedEntries ?? 0) > 0 || (diskBudget?.removedFiles ?? 0) > 0);
|
||||
|
||||
const summary = {
|
||||
storePath,
|
||||
mode: effectiveMode,
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
const summary: SessionCleanupSummary = {
|
||||
agentId: params.target.agentId,
|
||||
storePath: params.target.storePath,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
beforeCount,
|
||||
afterCount: afterPreviewCount,
|
||||
pruned,
|
||||
|
|
@ -130,86 +167,184 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
|
|||
wouldMutate,
|
||||
};
|
||||
|
||||
const actionRows = toSessionDisplayRows(beforeStore).map((row) => ({
|
||||
row,
|
||||
action: resolveSessionCleanupAction({
|
||||
key: row.key,
|
||||
return {
|
||||
summary,
|
||||
actionRows: buildActionRows({
|
||||
beforeStore,
|
||||
staleKeys,
|
||||
cappedKeys,
|
||||
budgetEvictedKeys,
|
||||
}),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
if (opts.json && opts.dryRun) {
|
||||
runtime.log(JSON.stringify(summary, null, 2));
|
||||
function renderStoreDryRunPlan(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
summary: SessionCleanupSummary;
|
||||
actionRows: SessionCleanupActionRow[];
|
||||
displayDefaults: ReturnType<typeof resolveSessionDisplayDefaults>;
|
||||
runtime: RuntimeEnv;
|
||||
showAgentHeader: boolean;
|
||||
}) {
|
||||
const rich = isRich();
|
||||
if (params.showAgentHeader) {
|
||||
params.runtime.log(`Agent: ${params.summary.agentId}`);
|
||||
}
|
||||
params.runtime.log(`Session store: ${params.summary.storePath}`);
|
||||
params.runtime.log(`Maintenance mode: ${params.summary.mode}`);
|
||||
params.runtime.log(
|
||||
`Entries: ${params.summary.beforeCount} -> ${params.summary.afterCount} (remove ${params.summary.beforeCount - params.summary.afterCount})`,
|
||||
);
|
||||
params.runtime.log(`Would prune stale: ${params.summary.pruned}`);
|
||||
params.runtime.log(`Would cap overflow: ${params.summary.capped}`);
|
||||
if (params.summary.diskBudget) {
|
||||
params.runtime.log(
|
||||
`Would enforce disk budget: ${params.summary.diskBudget.totalBytesBefore} -> ${params.summary.diskBudget.totalBytesAfter} bytes (files ${params.summary.diskBudget.removedFiles}, entries ${params.summary.diskBudget.removedEntries})`,
|
||||
);
|
||||
}
|
||||
if (params.actionRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
params.runtime.log("");
|
||||
params.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(" ");
|
||||
params.runtime.log(rich ? theme.heading(header) : header);
|
||||
for (const actionRow of params.actionRows) {
|
||||
const model = resolveSessionDisplayModel(params.cfg, actionRow, params.displayDefaults);
|
||||
const line = [
|
||||
formatCleanupActionCell(actionRow.action, rich),
|
||||
formatSessionKeyCell(actionRow.key, rich),
|
||||
formatSessionAgeCell(actionRow.updatedAt, rich),
|
||||
formatSessionModelCell(model, rich),
|
||||
formatSessionFlagsCell(actionRow, rich),
|
||||
].join(" ");
|
||||
params.runtime.log(line.trimEnd());
|
||||
}
|
||||
}
|
||||
|
||||
export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runtime: RuntimeEnv) {
|
||||
const cfg = loadConfig();
|
||||
const displayDefaults = resolveSessionDisplayDefaults(cfg);
|
||||
const mode = opts.enforce ? "enforce" : resolveMaintenanceConfig().mode;
|
||||
let targets: SessionStoreTarget[];
|
||||
try {
|
||||
targets = resolveSessionStoreTargets(cfg, {
|
||||
store: opts.store,
|
||||
agent: opts.agent,
|
||||
allAgents: opts.allAgents,
|
||||
});
|
||||
} catch (error) {
|
||||
runtime.error(error instanceof Error ? error.message : String(error));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.json) {
|
||||
runtime.log(`Session store: ${storePath}`);
|
||||
runtime.log(`Maintenance mode: ${effectiveMode}`);
|
||||
runtime.log(
|
||||
`Entries: ${beforeCount} -> ${afterPreviewCount} (remove ${beforeCount - afterPreviewCount})`,
|
||||
);
|
||||
runtime.log(`Would prune stale: ${pruned}`);
|
||||
runtime.log(`Would cap overflow: ${capped}`);
|
||||
if (diskBudget) {
|
||||
runtime.log(
|
||||
`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());
|
||||
}
|
||||
}
|
||||
const previewResults: Array<{
|
||||
summary: SessionCleanupSummary;
|
||||
actionRows: SessionCleanupActionRow[];
|
||||
}> = [];
|
||||
for (const target of targets) {
|
||||
const result = await previewStoreCleanup({
|
||||
target,
|
||||
mode,
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
activeKey: opts.activeKey,
|
||||
});
|
||||
previewResults.push(result);
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
if (opts.json) {
|
||||
if (previewResults.length === 1) {
|
||||
runtime.log(JSON.stringify(previewResults[0]?.summary ?? {}, null, 2));
|
||||
return;
|
||||
}
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
allAgents: true,
|
||||
mode,
|
||||
dryRun: true,
|
||||
stores: previewResults.map((result) => result.summary),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < previewResults.length; i += 1) {
|
||||
const result = previewResults[i];
|
||||
if (i > 0) {
|
||||
runtime.log("");
|
||||
}
|
||||
renderStoreDryRunPlan({
|
||||
cfg,
|
||||
summary: result.summary,
|
||||
actionRows: result.actionRows,
|
||||
displayDefaults,
|
||||
runtime,
|
||||
showAgentHeader: previewResults.length > 1,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSessionStore(
|
||||
storePath,
|
||||
async () => {
|
||||
// Maintenance runs in saveSessionStoreUnlocked(); no direct store mutation needed here.
|
||||
},
|
||||
{
|
||||
activeSessionKey: opts.activeKey,
|
||||
maintenanceOverride: {
|
||||
mode: effectiveMode,
|
||||
const appliedSummaries: SessionCleanupSummary[] = [];
|
||||
for (const target of targets) {
|
||||
await updateSessionStore(
|
||||
target.storePath,
|
||||
async () => {
|
||||
// Maintenance runs in saveSessionStoreUnlocked(); no direct store mutation needed here.
|
||||
},
|
||||
},
|
||||
);
|
||||
{
|
||||
activeSessionKey: opts.activeKey,
|
||||
maintenanceOverride: {
|
||||
mode,
|
||||
},
|
||||
},
|
||||
);
|
||||
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,
|
||||
};
|
||||
appliedSummaries.push(summary);
|
||||
}
|
||||
|
||||
const afterStore = loadSessionStore(storePath, { skipCache: true });
|
||||
const appliedCount = Object.keys(afterStore).length;
|
||||
if (opts.json) {
|
||||
if (appliedSummaries.length === 1) {
|
||||
runtime.log(JSON.stringify(appliedSummaries[0] ?? {}, null, 2));
|
||||
return;
|
||||
}
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...summary,
|
||||
applied: true,
|
||||
appliedCount,
|
||||
allAgents: true,
|
||||
mode,
|
||||
dryRun: false,
|
||||
stores: appliedSummaries,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
|
@ -218,5 +353,15 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
|
|||
return;
|
||||
}
|
||||
|
||||
runtime.log(`Applied maintenance. Current entries: ${appliedCount}`);
|
||||
for (let i = 0; i < appliedSummaries.length; i += 1) {
|
||||
const summary = appliedSummaries[i];
|
||||
if (i > 0) {
|
||||
runtime.log("");
|
||||
}
|
||||
if (appliedSummaries.length > 1) {
|
||||
runtime.log(`Agent: ${summary.agentId}`);
|
||||
}
|
||||
runtime.log(`Session store: ${summary.storePath}`);
|
||||
runtime.log(`Applied maintenance. Current entries: ${summary.appliedCount ?? 0}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,4 +69,19 @@ describe("sessionsCommand default store agent selection", () => {
|
|||
});
|
||||
expect(logs[0]).toContain("Session store: /tmp/sessions-voice.json");
|
||||
});
|
||||
|
||||
it("uses all configured agent stores with --all-agents", async () => {
|
||||
resolveStorePathMock.mockClear();
|
||||
const { runtime, logs } = createRuntime();
|
||||
|
||||
await sessionsCommand({ allAgents: true }, runtime);
|
||||
|
||||
expect(resolveStorePathMock).toHaveBeenCalledWith("/tmp/sessions-{agentId}.json", {
|
||||
agentId: "main",
|
||||
});
|
||||
expect(resolveStorePathMock).toHaveBeenCalledWith("/tmp/sessions-{agentId}.json", {
|
||||
agentId: "voice",
|
||||
});
|
||||
expect(logs[0]).toContain("Session stores: 2 (main, voice)");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveFreshSessionTotalTokens,
|
||||
resolveStorePath,
|
||||
} from "../config/sessions.js";
|
||||
import { loadSessionStore, resolveFreshSessionTotalTokens } from "../config/sessions.js";
|
||||
import { classifySessionKey } from "../gateway/session-utils.js";
|
||||
import { info } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { resolveSessionStoreTargets } from "./session-store-targets.js";
|
||||
import {
|
||||
formatSessionAgeCell,
|
||||
formatSessionFlagsCell,
|
||||
|
|
@ -86,7 +82,7 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
|
|||
};
|
||||
|
||||
export async function sessionsCommand(
|
||||
opts: { json?: boolean; store?: string; active?: string },
|
||||
opts: { json?: boolean; store?: string; active?: string; agent?: string; allAgents?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const cfg = loadConfig();
|
||||
|
|
@ -95,10 +91,18 @@ export async function sessionsCommand(
|
|||
cfg.agents?.defaults?.contextTokens ??
|
||||
lookupContextTokens(displayDefaults.model) ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
const configModel = displayDefaults.model;
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const storePath = resolveStorePath(opts.store ?? cfg.session?.store, { agentId: defaultAgentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
let targets: ReturnType<typeof resolveSessionStoreTargets>;
|
||||
try {
|
||||
targets = resolveSessionStoreTargets(cfg, {
|
||||
store: opts.store,
|
||||
agent: opts.agent,
|
||||
allAgents: opts.allAgents,
|
||||
});
|
||||
} catch (error) {
|
||||
runtime.error(error instanceof Error ? error.message : String(error));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
let activeMinutes: number | undefined;
|
||||
if (opts.active !== undefined) {
|
||||
|
|
@ -111,11 +115,14 @@ export async function sessionsCommand(
|
|||
activeMinutes = parsed;
|
||||
}
|
||||
|
||||
const rows = toSessionDisplayRows(store)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
kind: classifySessionKey(row.key, store[row.key]),
|
||||
}))
|
||||
const rows = targets
|
||||
.flatMap((target) => {
|
||||
const store = loadSessionStore(target.storePath);
|
||||
return toSessionDisplayRows(store).map((row) => ({
|
||||
...row,
|
||||
kind: classifySessionKey(row.key, store[row.key]),
|
||||
}));
|
||||
})
|
||||
.filter((row) => {
|
||||
if (activeMinutes === undefined) {
|
||||
return true;
|
||||
|
|
@ -124,13 +131,22 @@ export async function sessionsCommand(
|
|||
return false;
|
||||
}
|
||||
return Date.now() - row.updatedAt <= activeMinutes * 60_000;
|
||||
});
|
||||
})
|
||||
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
|
||||
if (opts.json) {
|
||||
const multi = targets.length > 1;
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
path: storePath,
|
||||
path: multi ? null : (targets[0]?.storePath ?? null),
|
||||
stores: multi
|
||||
? targets.map((target) => ({
|
||||
agentId: target.agentId,
|
||||
path: target.storePath,
|
||||
}))
|
||||
: undefined,
|
||||
allAgents: multi ? true : undefined,
|
||||
count: rows.length,
|
||||
activeMinutes: activeMinutes ?? null,
|
||||
sessions: rows.map((r) => {
|
||||
|
|
@ -153,7 +169,13 @@ export async function sessionsCommand(
|
|||
return;
|
||||
}
|
||||
|
||||
runtime.log(info(`Session store: ${storePath}`));
|
||||
if (targets.length === 1) {
|
||||
runtime.log(info(`Session store: ${targets[0]?.storePath}`));
|
||||
} else {
|
||||
runtime.log(
|
||||
info(`Session stores: ${targets.length} (${targets.map((t) => t.agentId).join(", ")})`),
|
||||
);
|
||||
}
|
||||
runtime.log(info(`Sessions listed: ${rows.length}`));
|
||||
if (activeMinutes) {
|
||||
runtime.log(info(`Filtered to last ${activeMinutes} minute(s)`));
|
||||
|
|
@ -176,7 +198,7 @@ export async function sessionsCommand(
|
|||
runtime.log(rich ? theme.heading(header) : header);
|
||||
|
||||
for (const row of rows) {
|
||||
const model = resolveSessionDisplayModel(cfg, row, displayDefaults) ?? configModel;
|
||||
const model = resolveSessionDisplayModel(cfg, row, displayDefaults);
|
||||
const contextTokens = row.contextTokens ?? lookupContextTokens(model) ?? configContextTokens;
|
||||
const total = resolveFreshSessionTotalTokens(row);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue