mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:38:25 +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
@@ -124,4 +124,51 @@ describe("doctor state integrity oauth dir checks", () => {
|
||||
expect(confirmSkipInNonInteractive).toHaveBeenCalledWith(OAUTH_PROMPT_MATCHER);
|
||||
expect(stateIntegrityText()).toContain("CRITICAL: OAuth dir missing");
|
||||
});
|
||||
|
||||
it("detects orphan transcripts and offers archival remediation", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
setupSessionState(cfg, process.env, process.env.HOME ?? "");
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main", process.env, () => tempHome);
|
||||
fs.writeFileSync(path.join(sessionsDir, "orphan-session.jsonl"), '{"type":"session"}\n');
|
||||
const confirmSkipInNonInteractive = vi.fn(async (params: { message: string }) =>
|
||||
params.message.includes("orphan transcript file"),
|
||||
);
|
||||
await noteStateIntegrity(cfg, { confirmSkipInNonInteractive });
|
||||
expect(stateIntegrityText()).toContain("orphan transcript file");
|
||||
expect(confirmSkipInNonInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("orphan transcript file"),
|
||||
}),
|
||||
);
|
||||
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 ");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,12 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||
import {
|
||||
formatSessionArchiveTimestamp,
|
||||
isPrimarySessionTranscriptFileName,
|
||||
loadSessionStore,
|
||||
resolveMainSessionKey,
|
||||
resolveSessionFilePath,
|
||||
@@ -202,6 +205,7 @@ export async function noteStateIntegrity(
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir);
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
const storeDir = path.dirname(storePath);
|
||||
const absoluteStorePath = path.resolve(storePath);
|
||||
const displayStateDir = shortenHomePath(stateDir);
|
||||
const displayOauthDir = shortenHomePath(oauthDir);
|
||||
const displaySessionsDir = shortenHomePath(sessionsDir);
|
||||
@@ -408,7 +412,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: ${formatCliCommand(`openclaw sessions --store "${absoluteStorePath}"`)}`,
|
||||
` Preview cleanup impact: ${formatCliCommand(`openclaw sessions cleanup --store "${absoluteStorePath}" --dry-run`)}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -435,6 +443,54 @@ export async function noteStateIntegrity(
|
||||
}
|
||||
}
|
||||
|
||||
if (existsDir(sessionsDir)) {
|
||||
const referencedTranscriptPaths = new Set<string>();
|
||||
for (const [, entry] of entries) {
|
||||
if (!entry?.sessionId) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
referencedTranscriptPaths.add(
|
||||
path.resolve(resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts)),
|
||||
);
|
||||
} catch {
|
||||
// ignore invalid legacy paths
|
||||
}
|
||||
}
|
||||
const sessionDirEntries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
||||
const orphanTranscriptPaths = sessionDirEntries
|
||||
.filter((entry) => entry.isFile() && isPrimarySessionTranscriptFileName(entry.name))
|
||||
.map((entry) => path.resolve(path.join(sessionsDir, entry.name)))
|
||||
.filter((filePath) => !referencedTranscriptPaths.has(filePath));
|
||||
if (orphanTranscriptPaths.length > 0) {
|
||||
warnings.push(
|
||||
`- Found ${orphanTranscriptPaths.length} orphan transcript file(s) in ${displaySessionsDir}. They are not referenced by sessions.json and can consume disk over time.`,
|
||||
);
|
||||
const archiveOrphans = await prompter.confirmSkipInNonInteractive({
|
||||
message: `Archive ${orphanTranscriptPaths.length} orphan transcript file(s) in ${displaySessionsDir}?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (archiveOrphans) {
|
||||
let archived = 0;
|
||||
const archivedAt = formatSessionArchiveTimestamp();
|
||||
for (const orphanPath of orphanTranscriptPaths) {
|
||||
const archivedPath = `${orphanPath}.deleted.${archivedAt}`;
|
||||
try {
|
||||
fs.renameSync(orphanPath, archivedPath);
|
||||
archived += 1;
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`- Failed to archive orphan transcript ${shortenHomePath(orphanPath)}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (archived > 0) {
|
||||
changes.push(`- Archived ${archived} orphan transcript file(s) in ${displaySessionsDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
note(warnings.join("\n"), "State integrity");
|
||||
}
|
||||
|
||||
79
src/commands/session-store-targets.test.ts
Normal file
79
src/commands/session-store-targets.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, 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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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("dedupes shared store paths for --all-agents", () => {
|
||||
listAgentIdsMock.mockReturnValue(["main", "work"]);
|
||||
resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json");
|
||||
|
||||
const targets = resolveSessionStoreTargets(
|
||||
{
|
||||
session: { store: "/tmp/shared-sessions.json" },
|
||||
},
|
||||
{ allAgents: true },
|
||||
);
|
||||
|
||||
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/shared-sessions.json" }]);
|
||||
expect(resolveStorePathMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
80
src/commands/session-store-targets.ts
Normal file
80
src/commands/session-store-targets.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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;
|
||||
};
|
||||
|
||||
function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] {
|
||||
const deduped = new Map<string, SessionStoreTarget>();
|
||||
for (const target of targets) {
|
||||
if (!deduped.has(target.storePath)) {
|
||||
deduped.set(target.storePath, target);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
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) {
|
||||
const targets = listAgentIds(cfg).map((agentId) => ({
|
||||
agentId,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId }),
|
||||
}));
|
||||
return dedupeTargetsByStorePath(targets);
|
||||
}
|
||||
|
||||
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 }),
|
||||
},
|
||||
];
|
||||
}
|
||||
246
src/commands/sessions-cleanup.test.ts
Normal file
246
src/commands/sessions-cleanup.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(),
|
||||
resolveSessionStoreTargets: vi.fn(),
|
||||
resolveMaintenanceConfig: vi.fn(),
|
||||
loadSessionStore: vi.fn(),
|
||||
pruneStaleEntries: vi.fn(),
|
||||
capEntryCount: vi.fn(),
|
||||
updateSessionStore: vi.fn(),
|
||||
enforceSessionDiskBudget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: mocks.loadConfig,
|
||||
}));
|
||||
|
||||
vi.mock("./session-store-targets.js", () => ({
|
||||
resolveSessionStoreTargets: mocks.resolveSessionStoreTargets,
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
resolveMaintenanceConfig: mocks.resolveMaintenanceConfig,
|
||||
loadSessionStore: mocks.loadSessionStore,
|
||||
pruneStaleEntries: mocks.pruneStaleEntries,
|
||||
capEntryCount: mocks.capEntryCount,
|
||||
updateSessionStore: mocks.updateSessionStore,
|
||||
enforceSessionDiskBudget: mocks.enforceSessionDiskBudget,
|
||||
}));
|
||||
|
||||
import { sessionsCleanupCommand } from "./sessions-cleanup.js";
|
||||
|
||||
function makeRuntime(): { runtime: RuntimeEnv; logs: string[] } {
|
||||
const logs: string[] = [];
|
||||
return {
|
||||
runtime: {
|
||||
log: (msg: unknown) => logs.push(String(msg)),
|
||||
error: () => {},
|
||||
exit: () => {},
|
||||
},
|
||||
logs,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sessionsCleanupCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.loadConfig.mockReturnValue({ session: { store: "/cfg/sessions.json" } });
|
||||
mocks.resolveSessionStoreTargets.mockReturnValue([
|
||||
{ agentId: "main", storePath: "/resolved/sessions.json" },
|
||||
]);
|
||||
mocks.resolveMaintenanceConfig.mockReturnValue({
|
||||
mode: "warn",
|
||||
pruneAfterMs: 7 * 24 * 60 * 60 * 1000,
|
||||
maxEntries: 500,
|
||||
rotateBytes: 10_485_760,
|
||||
resetArchiveRetentionMs: 7 * 24 * 60 * 60 * 1000,
|
||||
maxDiskBytes: null,
|
||||
highWaterBytes: null,
|
||||
});
|
||||
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({
|
||||
totalBytesBefore: 1000,
|
||||
totalBytesAfter: 700,
|
||||
removedFiles: 1,
|
||||
removedEntries: 1,
|
||||
freedBytes: 300,
|
||||
maxBytes: 900,
|
||||
highWaterBytes: 700,
|
||||
overBudget: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a single JSON object for non-dry runs and applies maintenance", async () => {
|
||||
mocks.loadSessionStore
|
||||
.mockReturnValueOnce({
|
||||
stale: { sessionId: "stale", updatedAt: 1 },
|
||||
fresh: { sessionId: "fresh", updatedAt: 2 },
|
||||
})
|
||||
.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(
|
||||
{
|
||||
json: true,
|
||||
enforce: true,
|
||||
activeKey: "agent:main:main",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(payload.applied).toBe(true);
|
||||
expect(payload.mode).toBe("enforce");
|
||||
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: 0,
|
||||
removedEntries: 0,
|
||||
}),
|
||||
);
|
||||
expect(mocks.updateSessionStore).toHaveBeenCalledWith(
|
||||
"/resolved/sessions.json",
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
activeSessionKey: "agent:main:main",
|
||||
maintenanceOverride: { mode: "enforce" },
|
||||
onMaintenanceApplied: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns dry-run JSON without mutating the store", async () => {
|
||||
mocks.loadSessionStore.mockReturnValue({
|
||||
stale: { sessionId: "stale", updatedAt: 1 },
|
||||
fresh: { sessionId: "fresh", updatedAt: 2 },
|
||||
});
|
||||
|
||||
const { runtime, logs } = makeRuntime();
|
||||
await sessionsCleanupCommand(
|
||||
{
|
||||
json: true,
|
||||
dryRun: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(payload.dryRun).toBe(true);
|
||||
expect(payload.applied).toBeUndefined();
|
||||
expect(mocks.updateSessionStore).not.toHaveBeenCalled();
|
||||
expect(payload.diskBudget).toEqual(
|
||||
expect.objectContaining({
|
||||
removedFiles: 1,
|
||||
removedEntries: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
397
src/commands/sessions-cleanup.ts
Normal file
397
src/commands/sessions-cleanup.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
capEntryCount,
|
||||
enforceSessionDiskBudget,
|
||||
loadSessionStore,
|
||||
pruneStaleEntries,
|
||||
resolveMaintenanceConfig,
|
||||
updateSessionStore,
|
||||
type SessionEntry,
|
||||
type SessionMaintenanceApplyReport,
|
||||
} 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,
|
||||
formatSessionKeyCell,
|
||||
formatSessionModelCell,
|
||||
resolveSessionDisplayDefaults,
|
||||
resolveSessionDisplayModel,
|
||||
SESSION_AGE_PAD,
|
||||
SESSION_KEY_PAD,
|
||||
SESSION_MODEL_PAD,
|
||||
toSessionDisplayRows,
|
||||
} from "./sessions-table.js";
|
||||
|
||||
export type SessionsCleanupOptions = {
|
||||
store?: string;
|
||||
agent?: string;
|
||||
allAgents?: boolean;
|
||||
dryRun?: boolean;
|
||||
enforce?: boolean;
|
||||
activeKey?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type SessionCleanupAction = "keep" | "prune-stale" | "cap-overflow" | "evict-budget";
|
||||
|
||||
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>;
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
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>();
|
||||
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: 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)) {
|
||||
budgetEvictedKeys.add(key);
|
||||
}
|
||||
}
|
||||
const beforeCount = Object.keys(beforeStore).length;
|
||||
const afterPreviewCount = Object.keys(previewStore).length;
|
||||
const wouldMutate =
|
||||
pruned > 0 ||
|
||||
capped > 0 ||
|
||||
Boolean((diskBudget?.removedEntries ?? 0) > 0 || (diskBudget?.removedFiles ?? 0) > 0);
|
||||
|
||||
const summary: SessionCleanupSummary = {
|
||||
agentId: params.target.agentId,
|
||||
storePath: params.target.storePath,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
beforeCount,
|
||||
afterCount: afterPreviewCount,
|
||||
pruned,
|
||||
capped,
|
||||
diskBudget,
|
||||
wouldMutate,
|
||||
};
|
||||
|
||||
return {
|
||||
summary,
|
||||
actionRows: buildActionRows({
|
||||
beforeStore,
|
||||
staleKeys,
|
||||
cappedKeys,
|
||||
budgetEvictedKeys,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const appliedSummaries: SessionCleanupSummary[] = [];
|
||||
for (const target of targets) {
|
||||
const appliedReportRef: { current: SessionMaintenanceApplyReport | null } = {
|
||||
current: null,
|
||||
};
|
||||
await updateSessionStore(
|
||||
target.storePath,
|
||||
async () => {
|
||||
// Maintenance runs in saveSessionStoreUnlocked(); no direct store mutation needed here.
|
||||
},
|
||||
{
|
||||
activeSessionKey: opts.activeKey,
|
||||
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 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);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
if (appliedSummaries.length === 1) {
|
||||
runtime.log(JSON.stringify(appliedSummaries[0] ?? {}, null, 2));
|
||||
return;
|
||||
}
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
allAgents: true,
|
||||
mode,
|
||||
dryRun: false,
|
||||
stores: appliedSummaries,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
148
src/commands/sessions-table.ts
Normal file
148
src/commands/sessions-table.ts
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() =>
|
||||
@@ -25,6 +25,7 @@ const resolveStorePathMock = vi.hoisted(() =>
|
||||
return `/tmp/sessions-${opts?.agentId ?? "missing"}.json`;
|
||||
}),
|
||||
);
|
||||
const loadSessionStoreMock = vi.hoisted(() => vi.fn(() => ({})));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
@@ -39,7 +40,7 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
resolveStorePath: resolveStorePathMock,
|
||||
loadSessionStore: vi.fn(() => ({})),
|
||||
loadSessionStore: loadSessionStoreMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -58,6 +59,67 @@ function createRuntime(): { runtime: RuntimeEnv; logs: string[] } {
|
||||
}
|
||||
|
||||
describe("sessionsCommand default store agent selection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveStorePathMock.mockImplementation(
|
||||
(_store: string | undefined, opts?: { agentId?: string }) => {
|
||||
return `/tmp/sessions-${opts?.agentId ?? "missing"}.json`;
|
||||
},
|
||||
);
|
||||
loadSessionStoreMock.mockImplementation(() => ({}));
|
||||
});
|
||||
|
||||
it("includes agentId on sessions rows for --all-agents JSON output", async () => {
|
||||
resolveStorePathMock.mockClear();
|
||||
loadSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock
|
||||
.mockReturnValueOnce({
|
||||
main_row: { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" },
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
voice_row: { sessionId: "s2", updatedAt: Date.now() - 120_000, model: "pi:opus" },
|
||||
});
|
||||
const { runtime, logs } = createRuntime();
|
||||
|
||||
await sessionsCommand({ allAgents: true, json: true }, runtime);
|
||||
|
||||
const payload = JSON.parse(logs[0] ?? "{}") as {
|
||||
allAgents?: boolean;
|
||||
sessions?: Array<{ key: string; agentId?: string }>;
|
||||
};
|
||||
expect(payload.allAgents).toBe(true);
|
||||
expect(payload.sessions?.map((session) => session.agentId)).toContain("main");
|
||||
expect(payload.sessions?.map((session) => session.agentId)).toContain("voice");
|
||||
});
|
||||
|
||||
it("avoids duplicate rows when --all-agents resolves to a shared store path", async () => {
|
||||
resolveStorePathMock.mockReset();
|
||||
resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json");
|
||||
loadSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
"agent:main:room": { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" },
|
||||
"agent:voice:room": { sessionId: "s2", updatedAt: Date.now() - 30_000, model: "pi:opus" },
|
||||
});
|
||||
const { runtime, logs } = createRuntime();
|
||||
|
||||
await sessionsCommand({ allAgents: true, json: true }, runtime);
|
||||
|
||||
const payload = JSON.parse(logs[0] ?? "{}") as {
|
||||
count?: number;
|
||||
stores?: Array<{ agentId: string; path: string }>;
|
||||
allAgents?: boolean;
|
||||
sessions?: Array<{ key: string; agentId?: string }>;
|
||||
};
|
||||
expect(payload.count).toBe(2);
|
||||
expect(payload.allAgents).toBe(true);
|
||||
expect(payload.stores).toEqual([{ agentId: "main", path: "/tmp/shared-sessions.json" }]);
|
||||
expect(payload.sessions?.map((session) => session.agentId).toSorted()).toEqual([
|
||||
"main",
|
||||
"voice",
|
||||
]);
|
||||
expect(loadSessionStoreMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses configured default agent id when resolving implicit session store path", async () => {
|
||||
resolveStorePathMock.mockClear();
|
||||
const { runtime, logs } = createRuntime();
|
||||
@@ -69,4 +131,26 @@ 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();
|
||||
loadSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock
|
||||
.mockReturnValueOnce({
|
||||
main_row: { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" },
|
||||
})
|
||||
.mockReturnValueOnce({});
|
||||
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)");
|
||||
expect(logs[2]).toContain("Agent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,62 +1,38 @@
|
||||
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 { loadSessionStore, resolveFreshSessionTotalTokens } from "../config/sessions.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 { resolveSessionStoreTargets } from "./session-store-targets.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 & {
|
||||
agentId: string;
|
||||
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 AGENT_PAD = 10;
|
||||
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,83 +84,29 @@ 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 },
|
||||
opts: { json?: boolean; store?: string; active?: string; agent?: string; allAgents?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const aggregateAgents = opts.allAgents === true;
|
||||
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 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) {
|
||||
@@ -197,30 +119,44 @@ 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 = targets
|
||||
.flatMap((target) => {
|
||||
const store = loadSessionStore(target.storePath);
|
||||
return toSessionDisplayRows(store).map((row) => ({
|
||||
...row,
|
||||
agentId: parseAgentSessionKey(row.key)?.agentId ?? target.agentId,
|
||||
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;
|
||||
})
|
||||
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
|
||||
if (opts.json) {
|
||||
const multi = targets.length > 1;
|
||||
const aggregate = aggregateAgents || multi;
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
path: storePath,
|
||||
path: aggregate ? null : (targets[0]?.storePath ?? null),
|
||||
stores: aggregate
|
||||
? targets.map((target) => ({
|
||||
agentId: target.agentId,
|
||||
path: target.storePath,
|
||||
}))
|
||||
: undefined,
|
||||
allAgents: aggregateAgents ? true : undefined,
|
||||
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,
|
||||
@@ -239,7 +175,13 @@ export async function sessionsCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.log(info(`Session store: ${storePath}`));
|
||||
if (targets.length === 1 && !aggregateAgents) {
|
||||
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)`));
|
||||
@@ -250,11 +192,13 @@ export async function sessionsCommand(
|
||||
}
|
||||
|
||||
const rich = isRich();
|
||||
const showAgentColumn = aggregateAgents || targets.length > 1;
|
||||
const header = [
|
||||
...(showAgentColumn ? ["Agent".padEnd(AGENT_PAD)] : []),
|
||||
"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 +206,20 @@ 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);
|
||||
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 = [
|
||||
...(showAgentColumn
|
||||
? [rich ? theme.accentBright(row.agentId.padEnd(AGENT_PAD)) : row.agentId.padEnd(AGENT_PAD)]
|
||||
: []),
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user