mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
refactor(sessions): route cleanup through controlled writers
This commit is contained in:
@@ -40,7 +40,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Models CLI: restore `openclaw models list --provider <id>` catalog and registry fallback rows for unconfigured providers, so provider-specific verification commands no longer report "No models found." Fixes #75517; supersedes #75615. Thanks @lotsoftick and @koshaji.
|
||||
- Gateway/macOS: write LaunchAgent services with a canonical system PATH and stop preserving old plist PATH entries, so Volta, asdf, fnm, and pnpm shell paths no longer affect gateway child-process Node resolution. Fixes #75233; supersedes #75246. Thanks @nphyde2.
|
||||
- Slack/hooks: preserve bot alert attachment text in message-received hook content when command text is blank. Fixes #76035; refs #76036. Thanks @amsminn.
|
||||
- Sessions: route Gateway session-store writes and CLI cleanup maintenance through a dedicated in-process writer and borrow the validated mutable cache during the writer slot, avoiding runtime file locks plus repeated `sessions.json` rereads and JSON clones on hot metadata updates. Refs #68554. Thanks @henkterharmsel.
|
||||
- Sessions/agents: route Gateway session-store writes, CLI cleanup maintenance, and agent-delete session purges through a dedicated in-process writer and borrow the validated mutable cache during the writer slot, avoiding runtime file locks plus repeated `sessions.json` rereads and JSON clones on hot metadata updates. Refs #68554. Thanks @henkterharmsel.
|
||||
- Control UI/chat: show inline feedback when local slash-command dispatch is unavailable or fails unexpectedly instead of clearing the composer silently. Fixes #52105. Thanks @MooreQiao.
|
||||
- Memory/markdown: replace CRLF managed blocks in place and collapse duplicate marker blocks without rewriting unmanaged markdown, so Dreaming and Memory Wiki files self-heal from repeated generated sections. Fixes #75491; supersedes #75495, #75810, and #76008. Thanks @asaenokkostya-coder, @ottodeng, @everettjf, and @lrg913427-dot.
|
||||
- Agents/tools: return critical tool-loop circuit-breaker stops as blocked tool results instead of thrown tool failures, so models see the guardrail and stop retrying the same call. Thanks @rayraiser.
|
||||
|
||||
@@ -152,6 +152,7 @@ Notes:
|
||||
- `main` cannot be deleted.
|
||||
- Without `--force`, interactive confirmation is required.
|
||||
- Workspace, agent state, and session transcript directories are moved to Trash, not hard-deleted.
|
||||
- When the Gateway is reachable, deletion is sent through the Gateway so config and session-store cleanup share the same writer as runtime traffic. If the Gateway cannot be reached, the CLI falls back to the offline local path.
|
||||
- If another agent's workspace is the same path, inside this workspace, or contains this workspace,
|
||||
the workspace is retained and `--json` reports `workspaceRetained`,
|
||||
`workspaceRetainedReason`, and `workspaceSharedWith`.
|
||||
|
||||
@@ -98,7 +98,7 @@ openclaw sessions cleanup --json
|
||||
- `--store <path>`: run against a specific `sessions.json` file.
|
||||
- `--json`: print a JSON summary. With `--all-agents`, output includes one summary per store.
|
||||
|
||||
When a Gateway is reachable, enforcing cleanup for configured agent stores is
|
||||
When a Gateway is reachable, non-dry-run cleanup for configured agent stores is
|
||||
sent through the Gateway so it shares the same session-store writer as runtime
|
||||
traffic. Use `--store <path>` for explicit offline repair of a store file.
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f
|
||||
- `maxDiskBytes`: optional sessions-directory budget
|
||||
- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`)
|
||||
|
||||
Normal Gateway writes flow through a per-store session writer that serializes in-process mutations without taking a runtime file lock. Hot-path patch helpers borrow the validated mutable cache while they hold that writer slot, so large `sessions.json` files are not cloned or reread for every metadata update. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`; direct whole-store saves are compatibility and offline-maintenance tools. When a Gateway is reachable, `openclaw sessions cleanup --enforce` delegates maintenance to the Gateway so cleanup joins the same writer queue; `--store <path>` is the explicit offline repair path for direct file maintenance. `maxEntries` cleanup is still batched for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately.
|
||||
Normal Gateway writes flow through a per-store session writer that serializes in-process mutations without taking a runtime file lock. Hot-path patch helpers borrow the validated mutable cache while they hold that writer slot, so large `sessions.json` files are not cloned or reread for every metadata update. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`; direct whole-store saves are compatibility and offline-maintenance tools. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue; `--store <path>` is the explicit offline repair path for direct file maintenance. `maxEntries` cleanup is still batched for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately.
|
||||
|
||||
Maintenance keeps durable external conversation pointers such as group sessions
|
||||
and thread-scoped chat sessions, but synthetic runtime entries for cron, hooks,
|
||||
|
||||
55
src/agents/agent-delete-safety.ts
Normal file
55
src/agents/agent-delete-safety.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { lowercasePreservingWhitespace } from "../shared/string-coerce.js";
|
||||
import { listAgentEntries, resolveAgentWorkspaceDir } from "./agent-scope.js";
|
||||
|
||||
function normalizeWorkspacePathForComparison(input: string): string {
|
||||
const resolved = path.resolve(input.replaceAll("\0", ""));
|
||||
let normalized = resolved;
|
||||
try {
|
||||
normalized = fs.realpathSync.native(resolved);
|
||||
} catch {
|
||||
// Keep lexical path for non-existent directories.
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return lowercasePreservingWhitespace(normalized);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
|
||||
const relative = path.relative(rootPath, candidatePath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function workspacePathsOverlap(left: string, right: string): boolean {
|
||||
const normalizedLeft = normalizeWorkspacePathForComparison(left);
|
||||
const normalizedRight = normalizeWorkspacePathForComparison(right);
|
||||
return (
|
||||
isPathWithinRoot(normalizedLeft, normalizedRight) ||
|
||||
isPathWithinRoot(normalizedRight, normalizedLeft)
|
||||
);
|
||||
}
|
||||
|
||||
export function findOverlappingWorkspaceAgentIds(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
workspaceDir: string,
|
||||
): string[] {
|
||||
const entries = listAgentEntries(cfg);
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const overlappingAgentIds: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const otherAgentId = normalizeAgentId(entry.id);
|
||||
if (otherAgentId === normalizedAgentId) {
|
||||
continue;
|
||||
}
|
||||
const otherWorkspace = resolveAgentWorkspaceDir(cfg, otherAgentId);
|
||||
if (workspacePathsOverlap(workspaceDir, otherWorkspace)) {
|
||||
overlappingAgentIds.push(otherAgentId);
|
||||
}
|
||||
}
|
||||
return overlappingAgentIds;
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveStorePath, updateSessionStore } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveStoredSessionOwnerAgentId } from "../gateway/session-store-key.js";
|
||||
import { getLogger } from "../logging/logger.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import {
|
||||
requireValidConfigFileSnapshot as requireValidConfigFileSnapshotBase,
|
||||
@@ -21,34 +16,3 @@ export async function requireValidConfigFileSnapshot(runtime: RuntimeEnv) {
|
||||
export async function requireValidConfig(runtime: RuntimeEnv): Promise<OpenClawConfig | null> {
|
||||
return await requireValidConfigSnapshot(runtime);
|
||||
}
|
||||
|
||||
/** Purge session store entries for a deleted agent (#65524). Best-effort. */
|
||||
export async function purgeAgentSessionStoreEntries(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const storeConfig = cfg.session?.store;
|
||||
const storeAgentId =
|
||||
typeof storeConfig === "string" && storeConfig.includes("{agentId}")
|
||||
? normalizedAgentId
|
||||
: normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId: normalizedAgentId });
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
for (const key of Object.keys(store)) {
|
||||
if (
|
||||
resolveStoredSessionOwnerAgentId({
|
||||
cfg,
|
||||
agentId: storeAgentId,
|
||||
sessionKey: key,
|
||||
}) === normalizedAgentId
|
||||
) {
|
||||
delete store[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
getLogger().debug("session store purge skipped during agent delete", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,56 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js";
|
||||
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { replaceConfigFile } from "../config/config.js";
|
||||
import { logConfigUpdated } from "../config/logging.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
purgeAgentSessionStoreEntries,
|
||||
resolveSessionTranscriptsDirForAgent,
|
||||
} from "../config/sessions.js";
|
||||
import { callGateway, isGatewayTransportError } from "../gateway/call.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { lowercasePreservingWhitespace } from "../shared/string-coerce.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||
import {
|
||||
createQuietRuntime,
|
||||
purgeAgentSessionStoreEntries,
|
||||
requireValidConfigFileSnapshot,
|
||||
} from "./agents.command-shared.js";
|
||||
import { createQuietRuntime, requireValidConfigFileSnapshot } from "./agents.command-shared.js";
|
||||
import { findAgentEntryIndex, listAgentEntries, pruneAgentConfig } from "./agents.config.js";
|
||||
import { moveToTrash } from "./onboard-helpers.js";
|
||||
|
||||
function normalizeWorkspacePathForComparison(input: string): string {
|
||||
const resolved = path.resolve(input.replaceAll("\0", ""));
|
||||
let normalized = resolved;
|
||||
try {
|
||||
normalized = fs.realpathSync.native(resolved);
|
||||
} catch {
|
||||
// Keep lexical path for non-existent directories.
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return lowercasePreservingWhitespace(normalized);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
|
||||
const relative = path.relative(rootPath, candidatePath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function workspacePathsOverlap(left: string, right: string): boolean {
|
||||
const normalizedLeft = normalizeWorkspacePathForComparison(left);
|
||||
const normalizedRight = normalizeWorkspacePathForComparison(right);
|
||||
return (
|
||||
isPathWithinRoot(normalizedLeft, normalizedRight) ||
|
||||
isPathWithinRoot(normalizedRight, normalizedLeft)
|
||||
);
|
||||
}
|
||||
|
||||
function findOverlappingWorkspaceAgentIds(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
workspaceDir: string,
|
||||
): string[] {
|
||||
const entries = listAgentEntries(cfg);
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const overlappingAgentIds: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const otherAgentId = normalizeAgentId(entry.id);
|
||||
if (otherAgentId === normalizedAgentId) {
|
||||
continue;
|
||||
}
|
||||
const otherWorkspace = resolveAgentWorkspaceDir(cfg, otherAgentId);
|
||||
if (workspacePathsOverlap(workspaceDir, otherWorkspace)) {
|
||||
overlappingAgentIds.push(otherAgentId);
|
||||
}
|
||||
}
|
||||
return overlappingAgentIds;
|
||||
}
|
||||
|
||||
type AgentsDeleteOptions = {
|
||||
id: string;
|
||||
force?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type AgentsDeleteGatewayResult = {
|
||||
ok: true;
|
||||
agentId: string;
|
||||
removedBindings: number;
|
||||
};
|
||||
|
||||
async function maybeDeleteAgentThroughGateway(params: {
|
||||
agentId: string;
|
||||
deleteFiles: boolean;
|
||||
}): Promise<AgentsDeleteGatewayResult | null> {
|
||||
try {
|
||||
return await callGateway<AgentsDeleteGatewayResult>({
|
||||
method: "agents.delete",
|
||||
params: {
|
||||
agentId: params.agentId,
|
||||
deleteFiles: params.deleteFiles,
|
||||
},
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
requiredMethods: ["agents.delete"],
|
||||
});
|
||||
} catch (error) {
|
||||
if (isGatewayTransportError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function agentsDeleteCommand(
|
||||
opts: AgentsDeleteOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
@@ -127,8 +105,34 @@ export async function agentsDeleteCommand(
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
|
||||
|
||||
const result = pruneAgentConfig(cfg, agentId);
|
||||
|
||||
const gatewayResult = await maybeDeleteAgentThroughGateway({
|
||||
agentId,
|
||||
deleteFiles: true,
|
||||
});
|
||||
if (gatewayResult) {
|
||||
const workspaceSharedWith = findOverlappingWorkspaceAgentIds(cfg, agentId, workspaceDir);
|
||||
const workspaceRetained = workspaceSharedWith.length > 0;
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, {
|
||||
agentId,
|
||||
workspace: workspaceDir,
|
||||
workspaceRetained: workspaceRetained || undefined,
|
||||
workspaceRetainedReason: workspaceRetained ? "shared" : undefined,
|
||||
workspaceSharedWith: workspaceRetained ? workspaceSharedWith : undefined,
|
||||
agentDir,
|
||||
sessionsDir,
|
||||
removedBindings: gatewayResult.removedBindings,
|
||||
removedAllow: result.removedAllow,
|
||||
transport: "gateway",
|
||||
});
|
||||
} else {
|
||||
runtime.log(`Deleted agent: ${agentId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await replaceConfigFile({
|
||||
nextConfig: result.config,
|
||||
...(baseHash !== undefined ? { baseHash } : {}),
|
||||
|
||||
@@ -15,12 +15,22 @@ const processMocks = vi.hoisted(() => ({
|
||||
runCommandWithTimeout: vi.fn(async () => ({ stdout: "", stderr: "", code: 0 })),
|
||||
}));
|
||||
|
||||
const gatewayMocks = vi.hoisted(() => ({
|
||||
callGateway: vi.fn(),
|
||||
isGatewayTransportError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../config/config.js")>("../config/config.js")),
|
||||
readConfigFileSnapshot: configMocks.readConfigFileSnapshot,
|
||||
replaceConfigFile: configMocks.replaceConfigFile,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: gatewayMocks.callGateway,
|
||||
isGatewayTransportError: gatewayMocks.isGatewayTransportError,
|
||||
}));
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: processMocks.runCommandWithTimeout,
|
||||
}));
|
||||
@@ -75,11 +85,65 @@ describe("agents delete command", () => {
|
||||
configMocks.readConfigFileSnapshot.mockReset();
|
||||
configMocks.replaceConfigFile.mockReset();
|
||||
processMocks.runCommandWithTimeout.mockClear();
|
||||
gatewayMocks.callGateway.mockReset();
|
||||
gatewayMocks.callGateway.mockRejectedValue(
|
||||
Object.assign(new Error("closed"), { name: "GatewayTransportError" }),
|
||||
);
|
||||
gatewayMocks.isGatewayTransportError.mockReset();
|
||||
gatewayMocks.isGatewayTransportError.mockImplementation(
|
||||
(error: unknown) => error instanceof Error && error.name === "GatewayTransportError",
|
||||
);
|
||||
runtime.log.mockClear();
|
||||
runtime.error.mockClear();
|
||||
runtime.exit.mockClear();
|
||||
});
|
||||
|
||||
it("routes deletion through the Gateway when reachable", async () => {
|
||||
await withStateDirEnv("openclaw-agents-delete-gateway-", async ({ stateDir }) => {
|
||||
const now = Date.now();
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
|
||||
{ id: "ops", workspace: path.join(stateDir, "workspace-ops") },
|
||||
],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const sessions = {
|
||||
"agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 1 },
|
||||
"agent:main:main": { sessionId: "sess-main", updatedAt: now + 2 },
|
||||
};
|
||||
const storePath = await arrangeAgentsDeleteTest({
|
||||
stateDir,
|
||||
cfg,
|
||||
deletedAgentId: "ops",
|
||||
sessions,
|
||||
});
|
||||
gatewayMocks.callGateway.mockResolvedValue({
|
||||
ok: true,
|
||||
agentId: "ops",
|
||||
removedBindings: 0,
|
||||
});
|
||||
|
||||
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
||||
|
||||
expect(gatewayMocks.callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "agents.delete",
|
||||
params: { agentId: "ops", deleteFiles: true },
|
||||
requiredMethods: ["agents.delete"],
|
||||
}),
|
||||
);
|
||||
expect(configMocks.replaceConfigFile).not.toHaveBeenCalled();
|
||||
expectSessionStore(storePath, sessions);
|
||||
expect(readJsonLogs()[0]).toMatchObject({
|
||||
agentId: "ops",
|
||||
removedBindings: 0,
|
||||
transport: "gateway",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("purges deleted agent entries from the session store", async () => {
|
||||
await withStateDirEnv("openclaw-agents-delete-", async ({ stateDir }) => {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -14,6 +14,9 @@ const mocks = vi.hoisted(() => ({
|
||||
capEntryCount: vi.fn(),
|
||||
updateSessionStore: vi.fn(),
|
||||
enforceSessionDiskBudget: vi.fn(),
|
||||
resolveSessionCleanupAction: vi.fn(),
|
||||
runSessionsCleanup: vi.fn(),
|
||||
serializeSessionCleanupResult: vi.fn(),
|
||||
callGateway: vi.fn(),
|
||||
isGatewayTransportError: vi.fn(),
|
||||
}));
|
||||
@@ -37,6 +40,9 @@ vi.mock("../config/sessions.js", () => ({
|
||||
capEntryCount: mocks.capEntryCount,
|
||||
updateSessionStore: mocks.updateSessionStore,
|
||||
enforceSessionDiskBudget: mocks.enforceSessionDiskBudget,
|
||||
resolveSessionCleanupAction: mocks.resolveSessionCleanupAction,
|
||||
runSessionsCleanup: mocks.runSessionsCleanup,
|
||||
serializeSessionCleanupResult: mocks.serializeSessionCleanupResult,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
@@ -106,6 +112,47 @@ describe("sessionsCleanupCommand", () => {
|
||||
mocks.updateSessionStore.mockResolvedValue(0);
|
||||
mocks.callGateway.mockResolvedValue(null);
|
||||
mocks.isGatewayTransportError.mockReturnValue(true);
|
||||
mocks.resolveSessionCleanupAction.mockImplementation(
|
||||
(params: {
|
||||
key: string;
|
||||
missingKeys: Set<string>;
|
||||
staleKeys: Set<string>;
|
||||
cappedKeys: Set<string>;
|
||||
budgetEvictedKeys: Set<string>;
|
||||
}) => {
|
||||
if (params.missingKeys.has(params.key)) {
|
||||
return "prune-missing";
|
||||
}
|
||||
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";
|
||||
},
|
||||
);
|
||||
mocks.serializeSessionCleanupResult.mockImplementation(
|
||||
(params: { mode: string; dryRun: boolean; summaries: Record<string, unknown>[] }) => {
|
||||
if (params.summaries.length === 1) {
|
||||
return params.summaries[0] ?? {};
|
||||
}
|
||||
return {
|
||||
allAgents: true,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
stores: params.summaries,
|
||||
};
|
||||
},
|
||||
);
|
||||
mocks.runSessionsCleanup.mockResolvedValue({
|
||||
mode: "warn",
|
||||
previewResults: [],
|
||||
appliedSummaries: [],
|
||||
});
|
||||
mocks.enforceSessionDiskBudget.mockResolvedValue({
|
||||
totalBytesBefore: 1000,
|
||||
totalBytesAfter: 700,
|
||||
@@ -122,34 +169,18 @@ describe("sessionsCleanupCommand", () => {
|
||||
mocks.callGateway.mockRejectedValue(
|
||||
Object.assign(new Error("closed"), { name: "GatewayTransportError" }),
|
||||
);
|
||||
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?.({
|
||||
mocks.runSessionsCleanup.mockResolvedValue({
|
||||
mode: "enforce",
|
||||
previewResults: [],
|
||||
appliedSummaries: [
|
||||
{
|
||||
agentId: "main",
|
||||
storePath: "/resolved/sessions.json",
|
||||
mode: "enforce",
|
||||
dryRun: false,
|
||||
beforeCount: 3,
|
||||
afterCount: 1,
|
||||
missing: 0,
|
||||
pruned: 0,
|
||||
capped: 2,
|
||||
diskBudget: {
|
||||
@@ -162,10 +193,12 @@ describe("sessionsCleanupCommand", () => {
|
||||
highWaterBytes: 800,
|
||||
overBudget: true,
|
||||
},
|
||||
});
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
wouldMutate: true,
|
||||
applied: true,
|
||||
appliedCount: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { runtime, logs } = makeRuntime();
|
||||
await sessionsCleanupCommand(
|
||||
@@ -191,13 +224,14 @@ describe("sessionsCleanupCommand", () => {
|
||||
removedEntries: 0,
|
||||
}),
|
||||
);
|
||||
expect(mocks.updateSessionStore).toHaveBeenCalledWith(
|
||||
"/resolved/sessions.json",
|
||||
expect.any(Function),
|
||||
expect(mocks.runSessionsCleanup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
activeSessionKey: "agent:main:main",
|
||||
maintenanceOverride: { mode: "enforce" },
|
||||
onMaintenanceApplied: expect.any(Function),
|
||||
cfg: { session: { store: "/cfg/sessions.json" } },
|
||||
opts: expect.objectContaining({
|
||||
enforce: true,
|
||||
activeKey: "agent:main:main",
|
||||
}),
|
||||
targets: [{ agentId: "main", storePath: "/resolved/sessions.json" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -240,9 +274,40 @@ describe("sessionsCleanupCommand", () => {
|
||||
});
|
||||
|
||||
it("returns dry-run JSON without mutating the store", async () => {
|
||||
mocks.loadSessionStore.mockReturnValue({
|
||||
stale: { sessionId: "stale", updatedAt: 1 },
|
||||
fresh: { sessionId: "fresh", updatedAt: 2 },
|
||||
mocks.runSessionsCleanup.mockResolvedValue({
|
||||
mode: "warn",
|
||||
previewResults: [
|
||||
{
|
||||
summary: {
|
||||
agentId: "main",
|
||||
storePath: "/resolved/sessions.json",
|
||||
mode: "warn",
|
||||
dryRun: true,
|
||||
beforeCount: 2,
|
||||
afterCount: 1,
|
||||
missing: 0,
|
||||
pruned: 1,
|
||||
capped: 0,
|
||||
diskBudget: {
|
||||
totalBytesBefore: 1000,
|
||||
totalBytesAfter: 700,
|
||||
removedFiles: 1,
|
||||
removedEntries: 1,
|
||||
freedBytes: 300,
|
||||
maxBytes: 900,
|
||||
highWaterBytes: 700,
|
||||
overBudget: true,
|
||||
},
|
||||
wouldMutate: true,
|
||||
},
|
||||
beforeStore: {},
|
||||
missingKeys: new Set<string>(),
|
||||
staleKeys: new Set<string>(),
|
||||
cappedKeys: new Set<string>(),
|
||||
budgetEvictedKeys: new Set<string>(),
|
||||
},
|
||||
],
|
||||
appliedSummaries: [],
|
||||
});
|
||||
|
||||
const { runtime, logs } = makeRuntime();
|
||||
@@ -258,6 +323,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(payload.dryRun).toBe(true);
|
||||
expect(payload.applied).toBeUndefined();
|
||||
expect(mocks.runSessionsCleanup).toHaveBeenCalled();
|
||||
expect(mocks.updateSessionStore).not.toHaveBeenCalled();
|
||||
expect(payload.diskBudget).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -269,8 +335,31 @@ describe("sessionsCleanupCommand", () => {
|
||||
|
||||
it("counts missing transcript entries when --fix-missing is enabled in dry-run", async () => {
|
||||
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
|
||||
mocks.loadSessionStore.mockReturnValue({
|
||||
missing: { sessionId: "missing-transcript", updatedAt: 1 },
|
||||
mocks.runSessionsCleanup.mockResolvedValue({
|
||||
mode: "warn",
|
||||
previewResults: [
|
||||
{
|
||||
summary: {
|
||||
agentId: "main",
|
||||
storePath: "/resolved/sessions.json",
|
||||
mode: "warn",
|
||||
dryRun: true,
|
||||
beforeCount: 1,
|
||||
afterCount: 0,
|
||||
missing: 1,
|
||||
pruned: 0,
|
||||
capped: 0,
|
||||
diskBudget: null,
|
||||
wouldMutate: true,
|
||||
},
|
||||
beforeStore: {},
|
||||
missingKeys: new Set(["missing"]),
|
||||
staleKeys: new Set<string>(),
|
||||
cappedKeys: new Set<string>(),
|
||||
budgetEvictedKeys: new Set<string>(),
|
||||
},
|
||||
],
|
||||
appliedSummaries: [],
|
||||
});
|
||||
|
||||
const { runtime, logs } = makeRuntime();
|
||||
@@ -292,9 +381,34 @@ 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" },
|
||||
mocks.runSessionsCleanup.mockResolvedValue({
|
||||
mode: "warn",
|
||||
previewResults: [
|
||||
{
|
||||
summary: {
|
||||
agentId: "main",
|
||||
storePath: "/resolved/sessions.json",
|
||||
mode: "warn",
|
||||
dryRun: true,
|
||||
beforeCount: 2,
|
||||
afterCount: 1,
|
||||
missing: 0,
|
||||
pruned: 1,
|
||||
capped: 0,
|
||||
diskBudget: null,
|
||||
wouldMutate: true,
|
||||
},
|
||||
beforeStore: {
|
||||
stale: { sessionId: "stale", updatedAt: 1, model: "pi:opus" },
|
||||
fresh: { sessionId: "fresh", updatedAt: 2, model: "pi:opus" },
|
||||
},
|
||||
missingKeys: new Set<string>(),
|
||||
staleKeys: new Set(["stale"]),
|
||||
cappedKeys: new Set<string>(),
|
||||
budgetEvictedKeys: new Set<string>(),
|
||||
},
|
||||
],
|
||||
appliedSummaries: [],
|
||||
});
|
||||
|
||||
const { runtime, logs } = makeRuntime();
|
||||
@@ -317,9 +431,52 @@ describe("sessionsCleanupCommand", () => {
|
||||
{ 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 } });
|
||||
mocks.runSessionsCleanup.mockResolvedValue({
|
||||
mode: "warn",
|
||||
previewResults: [
|
||||
{
|
||||
summary: {
|
||||
agentId: "main",
|
||||
storePath: "/resolved/main-sessions.json",
|
||||
mode: "warn",
|
||||
dryRun: true,
|
||||
beforeCount: 1,
|
||||
afterCount: 0,
|
||||
missing: 0,
|
||||
pruned: 1,
|
||||
capped: 0,
|
||||
diskBudget: null,
|
||||
wouldMutate: true,
|
||||
},
|
||||
beforeStore: {},
|
||||
missingKeys: new Set<string>(),
|
||||
staleKeys: new Set(["stale"]),
|
||||
cappedKeys: new Set<string>(),
|
||||
budgetEvictedKeys: new Set<string>(),
|
||||
},
|
||||
{
|
||||
summary: {
|
||||
agentId: "work",
|
||||
storePath: "/resolved/work-sessions.json",
|
||||
mode: "warn",
|
||||
dryRun: true,
|
||||
beforeCount: 1,
|
||||
afterCount: 0,
|
||||
missing: 0,
|
||||
pruned: 1,
|
||||
capped: 0,
|
||||
diskBudget: null,
|
||||
wouldMutate: true,
|
||||
},
|
||||
beforeStore: {},
|
||||
missingKeys: new Set<string>(),
|
||||
staleKeys: new Set(["stale"]),
|
||||
cappedKeys: new Set<string>(),
|
||||
budgetEvictedKeys: new Set<string>(),
|
||||
},
|
||||
],
|
||||
appliedSummaries: [],
|
||||
});
|
||||
|
||||
const { runtime, logs } = makeRuntime();
|
||||
await sessionsCleanupCommand(
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import fs from "node:fs";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import {
|
||||
capEntryCount,
|
||||
enforceSessionDiskBudget,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
loadSessionStore,
|
||||
pruneStaleEntries,
|
||||
resolveMaintenanceConfig,
|
||||
updateSessionStore,
|
||||
type SessionEntry,
|
||||
type SessionMaintenanceApplyReport,
|
||||
resolveSessionCleanupAction,
|
||||
runSessionsCleanup,
|
||||
serializeSessionCleanupResult,
|
||||
type SessionCleanupSummary,
|
||||
type SessionsCleanupOptions,
|
||||
type SessionsCleanupResult,
|
||||
} from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { callGateway, isGatewayTransportError } from "../gateway/call.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import {
|
||||
resolveSessionStoreTargets,
|
||||
resolveSessionStoreTargetsOrExit,
|
||||
type SessionStoreTarget,
|
||||
} from "./session-store-targets.js";
|
||||
import {
|
||||
resolveSessionDisplayDefaults,
|
||||
resolveSessionDisplayModel,
|
||||
} from "./sessions-display-model.js";
|
||||
import { resolveSessionStoreTargetsOrExit } from "./session-store-targets.js";
|
||||
import { resolveSessionDisplayModel } from "./sessions-display-model.js";
|
||||
import {
|
||||
formatSessionAgeCell,
|
||||
formatSessionFlagsCell,
|
||||
@@ -37,78 +25,16 @@ import {
|
||||
toSessionDisplayRows,
|
||||
} from "./sessions-table.js";
|
||||
|
||||
export type SessionsCleanupOptions = {
|
||||
store?: string;
|
||||
agent?: string;
|
||||
allAgents?: boolean;
|
||||
dryRun?: boolean;
|
||||
enforce?: boolean;
|
||||
activeKey?: string;
|
||||
json?: boolean;
|
||||
fixMissing?: boolean;
|
||||
};
|
||||
|
||||
type SessionCleanupAction =
|
||||
| "keep"
|
||||
| "prune-missing"
|
||||
| "prune-stale"
|
||||
| "cap-overflow"
|
||||
| "evict-budget";
|
||||
|
||||
const ACTION_PAD = 12;
|
||||
|
||||
type SessionCleanupActionRow = ReturnType<typeof toSessionDisplayRows>[number] & {
|
||||
action: SessionCleanupAction;
|
||||
action: ReturnType<typeof resolveSessionCleanupAction>;
|
||||
};
|
||||
|
||||
type SessionCleanupSummary = {
|
||||
agentId: string;
|
||||
storePath: string;
|
||||
mode: "warn" | "enforce";
|
||||
dryRun: boolean;
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
missing: number;
|
||||
pruned: number;
|
||||
capped: number;
|
||||
diskBudget: Awaited<ReturnType<typeof enforceSessionDiskBudget>>;
|
||||
wouldMutate: boolean;
|
||||
applied?: true;
|
||||
appliedCount?: number;
|
||||
};
|
||||
|
||||
export type SessionsCleanupResult =
|
||||
| SessionCleanupSummary
|
||||
| {
|
||||
allAgents: true;
|
||||
mode: "warn" | "enforce";
|
||||
dryRun: boolean;
|
||||
stores: SessionCleanupSummary[];
|
||||
};
|
||||
|
||||
function resolveSessionCleanupAction(params: {
|
||||
key: string;
|
||||
missingKeys: Set<string>;
|
||||
staleKeys: Set<string>;
|
||||
cappedKeys: Set<string>;
|
||||
budgetEvictedKeys: Set<string>;
|
||||
}): SessionCleanupAction {
|
||||
if (params.missingKeys.has(params.key)) {
|
||||
return "prune-missing";
|
||||
}
|
||||
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 {
|
||||
function formatCleanupActionCell(
|
||||
action: ReturnType<typeof resolveSessionCleanupAction>,
|
||||
rich: boolean,
|
||||
): string {
|
||||
const label = action.padEnd(ACTION_PAD);
|
||||
if (!rich) {
|
||||
return label;
|
||||
@@ -129,7 +55,7 @@ function formatCleanupActionCell(action: SessionCleanupAction, rich: boolean): s
|
||||
}
|
||||
|
||||
function buildActionRows(params: {
|
||||
beforeStore: Record<string, SessionEntry>;
|
||||
beforeStore: Parameters<typeof toSessionDisplayRows>[0];
|
||||
missingKeys: Set<string>;
|
||||
staleKeys: Set<string>;
|
||||
cappedKeys: Set<string>;
|
||||
@@ -148,135 +74,10 @@ function buildActionRows(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function pruneMissingTranscriptEntries(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
storePath: string;
|
||||
onPruned?: (key: string) => void;
|
||||
}): number {
|
||||
const sessionPathOpts = resolveSessionFilePathOptions({
|
||||
storePath: params.storePath,
|
||||
});
|
||||
let removed = 0;
|
||||
for (const [key, entry] of Object.entries(params.store)) {
|
||||
if (!entry?.sessionId) {
|
||||
continue;
|
||||
}
|
||||
const transcriptPath = resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts);
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
delete params.store[key];
|
||||
removed += 1;
|
||||
params.onPruned?.(key);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async function previewStoreCleanup(params: {
|
||||
target: SessionStoreTarget;
|
||||
mode: "warn" | "enforce";
|
||||
dryRun: boolean;
|
||||
activeKey?: string;
|
||||
fixMissing?: boolean;
|
||||
}) {
|
||||
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 missingKeys = new Set<string>();
|
||||
const missing =
|
||||
params.fixMissing === true
|
||||
? pruneMissingTranscriptEntries({
|
||||
store: previewStore,
|
||||
storePath: params.target.storePath,
|
||||
onPruned: (key) => {
|
||||
missingKeys.add(key);
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
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 =
|
||||
missing > 0 ||
|
||||
pruned > 0 ||
|
||||
capped > 0 ||
|
||||
(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,
|
||||
missing,
|
||||
pruned,
|
||||
capped,
|
||||
diskBudget,
|
||||
wouldMutate,
|
||||
};
|
||||
|
||||
return {
|
||||
summary,
|
||||
actionRows: buildActionRows({
|
||||
beforeStore,
|
||||
staleKeys,
|
||||
cappedKeys,
|
||||
budgetEvictedKeys,
|
||||
missingKeys,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSessionCleanupResult(params: {
|
||||
mode: "warn" | "enforce";
|
||||
dryRun: boolean;
|
||||
summaries: SessionCleanupSummary[];
|
||||
}): SessionsCleanupResult {
|
||||
if (params.summaries.length === 1) {
|
||||
return params.summaries[0] ?? ({} as SessionCleanupSummary);
|
||||
}
|
||||
return {
|
||||
allAgents: true,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
stores: params.summaries,
|
||||
};
|
||||
}
|
||||
|
||||
function renderStoreDryRunPlan(params: {
|
||||
cfg: OpenClawConfig;
|
||||
summary: SessionCleanupSummary;
|
||||
actionRows: SessionCleanupActionRow[];
|
||||
displayDefaults: ReturnType<typeof resolveSessionDisplayDefaults>;
|
||||
runtime: RuntimeEnv;
|
||||
showAgentHeader: boolean;
|
||||
}) {
|
||||
@@ -343,122 +144,6 @@ function renderAppliedSummaries(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSessionsCleanup(params: {
|
||||
cfg: OpenClawConfig;
|
||||
opts: SessionsCleanupOptions;
|
||||
targets?: SessionStoreTarget[];
|
||||
}): Promise<{
|
||||
mode: "warn" | "enforce";
|
||||
previewResults: Array<{
|
||||
summary: SessionCleanupSummary;
|
||||
actionRows: SessionCleanupActionRow[];
|
||||
}>;
|
||||
appliedSummaries: SessionCleanupSummary[];
|
||||
}> {
|
||||
const { cfg, opts } = params;
|
||||
const mode = opts.enforce ? "enforce" : resolveMaintenanceConfig().mode;
|
||||
const targets =
|
||||
params.targets ??
|
||||
resolveSessionStoreTargets(cfg, {
|
||||
store: opts.store,
|
||||
agent: opts.agent,
|
||||
allAgents: opts.allAgents,
|
||||
});
|
||||
|
||||
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,
|
||||
fixMissing: Boolean(opts.fixMissing),
|
||||
});
|
||||
previewResults.push(result);
|
||||
}
|
||||
|
||||
const appliedSummaries: SessionCleanupSummary[] = [];
|
||||
if (!opts.dryRun) {
|
||||
for (const target of targets) {
|
||||
const appliedReportRef: { current: SessionMaintenanceApplyReport | null } = {
|
||||
current: null,
|
||||
};
|
||||
const missingApplied = await updateSessionStore(
|
||||
target.storePath,
|
||||
async (store) => {
|
||||
if (!opts.fixMissing) {
|
||||
return 0;
|
||||
}
|
||||
return pruneMissingTranscriptEntries({
|
||||
store,
|
||||
storePath: target.storePath,
|
||||
});
|
||||
},
|
||||
{
|
||||
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,
|
||||
missing: 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,
|
||||
missing: missingApplied,
|
||||
pruned: appliedReport.pruned,
|
||||
capped: appliedReport.capped,
|
||||
diskBudget: appliedReport.diskBudget,
|
||||
wouldMutate:
|
||||
missingApplied > 0 ||
|
||||
appliedReport.pruned > 0 ||
|
||||
appliedReport.capped > 0 ||
|
||||
(appliedReport.diskBudget?.removedEntries ?? 0) > 0 ||
|
||||
(appliedReport.diskBudget?.removedFiles ?? 0) > 0,
|
||||
applied: true,
|
||||
appliedCount: Object.keys(afterStore).length,
|
||||
};
|
||||
appliedSummaries.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return { mode, previewResults, appliedSummaries };
|
||||
}
|
||||
|
||||
async function maybeRunGatewayCleanup(
|
||||
opts: SessionsCleanupOptions,
|
||||
): Promise<SessionsCleanupResult | null> {
|
||||
@@ -502,7 +187,6 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
|
||||
}
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
const displayDefaults = resolveSessionDisplayDefaults(cfg);
|
||||
const targets = resolveSessionStoreTargetsOrExit({
|
||||
cfg,
|
||||
opts: {
|
||||
@@ -542,8 +226,7 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
|
||||
renderStoreDryRunPlan({
|
||||
cfg,
|
||||
summary: result.summary,
|
||||
actionRows: result.actionRows,
|
||||
displayDefaults,
|
||||
actionRows: buildActionRows(result),
|
||||
runtime,
|
||||
showAgentHeader: previewResults.length > 1,
|
||||
});
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from "./sessions/session-file.js";
|
||||
export * from "./sessions/delivery-info.js";
|
||||
export * from "./sessions/disk-budget.js";
|
||||
export * from "./sessions/targets.js";
|
||||
export * from "./sessions/cleanup-service.js";
|
||||
|
||||
363
src/config/sessions/cleanup-service.ts
Normal file
363
src/config/sessions/cleanup-service.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import fs from "node:fs";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveStoredSessionOwnerAgentId } from "../../gateway/session-store-key.js";
|
||||
import { getLogger } from "../../logging/logger.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import type { OpenClawConfig } from "../types.openclaw.js";
|
||||
import { enforceSessionDiskBudget } from "./disk-budget.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
resolveStorePath,
|
||||
} from "./paths.js";
|
||||
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
|
||||
import {
|
||||
capEntryCount,
|
||||
pruneStaleEntries,
|
||||
type ResolvedSessionMaintenanceConfig,
|
||||
} from "./store-maintenance.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
updateSessionStore,
|
||||
type SessionMaintenanceApplyReport,
|
||||
} from "./store.js";
|
||||
import {
|
||||
resolveSessionStoreTargets,
|
||||
type SessionStoreTarget,
|
||||
type SessionStoreSelectionOptions,
|
||||
} from "./targets.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
export type SessionsCleanupOptions = SessionStoreSelectionOptions & {
|
||||
dryRun?: boolean;
|
||||
enforce?: boolean;
|
||||
activeKey?: string;
|
||||
json?: boolean;
|
||||
fixMissing?: boolean;
|
||||
};
|
||||
|
||||
export type SessionCleanupAction =
|
||||
| "keep"
|
||||
| "prune-missing"
|
||||
| "prune-stale"
|
||||
| "cap-overflow"
|
||||
| "evict-budget";
|
||||
|
||||
export type SessionCleanupSummary = {
|
||||
agentId: string;
|
||||
storePath: string;
|
||||
mode: ResolvedSessionMaintenanceConfig["mode"];
|
||||
dryRun: boolean;
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
missing: number;
|
||||
pruned: number;
|
||||
capped: number;
|
||||
diskBudget: Awaited<ReturnType<typeof enforceSessionDiskBudget>>;
|
||||
wouldMutate: boolean;
|
||||
applied?: true;
|
||||
appliedCount?: number;
|
||||
};
|
||||
|
||||
export type SessionsCleanupResult =
|
||||
| SessionCleanupSummary
|
||||
| {
|
||||
allAgents: true;
|
||||
mode: ResolvedSessionMaintenanceConfig["mode"];
|
||||
dryRun: boolean;
|
||||
stores: SessionCleanupSummary[];
|
||||
};
|
||||
|
||||
export type SessionsCleanupRunResult = {
|
||||
mode: ResolvedSessionMaintenanceConfig["mode"];
|
||||
previewResults: Array<{
|
||||
summary: SessionCleanupSummary;
|
||||
beforeStore: Record<string, SessionEntry>;
|
||||
missingKeys: Set<string>;
|
||||
staleKeys: Set<string>;
|
||||
cappedKeys: Set<string>;
|
||||
budgetEvictedKeys: Set<string>;
|
||||
}>;
|
||||
appliedSummaries: SessionCleanupSummary[];
|
||||
};
|
||||
|
||||
export function resolveSessionCleanupAction(params: {
|
||||
key: string;
|
||||
missingKeys: Set<string>;
|
||||
staleKeys: Set<string>;
|
||||
cappedKeys: Set<string>;
|
||||
budgetEvictedKeys: Set<string>;
|
||||
}): SessionCleanupAction {
|
||||
if (params.missingKeys.has(params.key)) {
|
||||
return "prune-missing";
|
||||
}
|
||||
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";
|
||||
}
|
||||
|
||||
export function serializeSessionCleanupResult(params: {
|
||||
mode: ResolvedSessionMaintenanceConfig["mode"];
|
||||
dryRun: boolean;
|
||||
summaries: SessionCleanupSummary[];
|
||||
}): SessionsCleanupResult {
|
||||
if (params.summaries.length === 1) {
|
||||
return params.summaries[0] ?? ({} as SessionCleanupSummary);
|
||||
}
|
||||
return {
|
||||
allAgents: true,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
stores: params.summaries,
|
||||
};
|
||||
}
|
||||
|
||||
function pruneMissingTranscriptEntries(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
storePath: string;
|
||||
onPruned?: (key: string) => void;
|
||||
}): number {
|
||||
const sessionPathOpts = resolveSessionFilePathOptions({
|
||||
storePath: params.storePath,
|
||||
});
|
||||
let removed = 0;
|
||||
for (const [key, entry] of Object.entries(params.store)) {
|
||||
if (!entry?.sessionId) {
|
||||
continue;
|
||||
}
|
||||
const transcriptPath = resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts);
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
delete params.store[key];
|
||||
removed += 1;
|
||||
params.onPruned?.(key);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async function previewStoreCleanup(params: {
|
||||
target: SessionStoreTarget;
|
||||
mode: ResolvedSessionMaintenanceConfig["mode"];
|
||||
dryRun: boolean;
|
||||
activeKey?: string;
|
||||
fixMissing?: boolean;
|
||||
}) {
|
||||
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 missingKeys = new Set<string>();
|
||||
const missing =
|
||||
params.fixMissing === true
|
||||
? pruneMissingTranscriptEntries({
|
||||
store: previewStore,
|
||||
storePath: params.target.storePath,
|
||||
onPruned: (key) => {
|
||||
missingKeys.add(key);
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
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 =
|
||||
missing > 0 ||
|
||||
pruned > 0 ||
|
||||
capped > 0 ||
|
||||
(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,
|
||||
missing,
|
||||
pruned,
|
||||
capped,
|
||||
diskBudget,
|
||||
wouldMutate,
|
||||
};
|
||||
|
||||
return {
|
||||
summary,
|
||||
beforeStore,
|
||||
missingKeys,
|
||||
staleKeys,
|
||||
cappedKeys,
|
||||
budgetEvictedKeys,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSessionsCleanup(params: {
|
||||
cfg: OpenClawConfig;
|
||||
opts: SessionsCleanupOptions;
|
||||
targets?: SessionStoreTarget[];
|
||||
}): Promise<SessionsCleanupRunResult> {
|
||||
const { cfg, opts } = params;
|
||||
const mode = opts.enforce ? "enforce" : resolveMaintenanceConfig().mode;
|
||||
const targets =
|
||||
params.targets ??
|
||||
resolveSessionStoreTargets(cfg, {
|
||||
store: opts.store,
|
||||
agent: opts.agent,
|
||||
allAgents: opts.allAgents,
|
||||
});
|
||||
|
||||
const previewResults: SessionsCleanupRunResult["previewResults"] = [];
|
||||
for (const target of targets) {
|
||||
const result = await previewStoreCleanup({
|
||||
target,
|
||||
mode,
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
activeKey: opts.activeKey,
|
||||
fixMissing: Boolean(opts.fixMissing),
|
||||
});
|
||||
previewResults.push(result);
|
||||
}
|
||||
|
||||
const appliedSummaries: SessionCleanupSummary[] = [];
|
||||
if (!opts.dryRun) {
|
||||
for (const target of targets) {
|
||||
const appliedReportRef: { current: SessionMaintenanceApplyReport | null } = {
|
||||
current: null,
|
||||
};
|
||||
const missingApplied = await updateSessionStore(
|
||||
target.storePath,
|
||||
async (store) => {
|
||||
if (!opts.fixMissing) {
|
||||
return 0;
|
||||
}
|
||||
return pruneMissingTranscriptEntries({
|
||||
store,
|
||||
storePath: target.storePath,
|
||||
});
|
||||
},
|
||||
{
|
||||
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,
|
||||
missing: 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,
|
||||
missing: missingApplied,
|
||||
pruned: appliedReport.pruned,
|
||||
capped: appliedReport.capped,
|
||||
diskBudget: appliedReport.diskBudget,
|
||||
wouldMutate:
|
||||
missingApplied > 0 ||
|
||||
appliedReport.pruned > 0 ||
|
||||
appliedReport.capped > 0 ||
|
||||
(appliedReport.diskBudget?.removedEntries ?? 0) > 0 ||
|
||||
(appliedReport.diskBudget?.removedFiles ?? 0) > 0,
|
||||
applied: true,
|
||||
appliedCount: Object.keys(afterStore).length,
|
||||
};
|
||||
appliedSummaries.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return { mode, previewResults, appliedSummaries };
|
||||
}
|
||||
|
||||
/** Purge session store entries for a deleted agent (#65524). Best-effort. */
|
||||
export async function purgeAgentSessionStoreEntries(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const storeConfig = cfg.session?.store;
|
||||
const storeAgentId =
|
||||
typeof storeConfig === "string" && storeConfig.includes("{agentId}")
|
||||
? normalizedAgentId
|
||||
: normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId: normalizedAgentId });
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
for (const key of Object.keys(store)) {
|
||||
if (
|
||||
resolveStoredSessionOwnerAgentId({
|
||||
cfg,
|
||||
agentId: storeAgentId,
|
||||
sessionKey: key,
|
||||
}) === normalizedAgentId
|
||||
) {
|
||||
delete store[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
getLogger().debug("session store purge skipped during agent delete", err);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ vi.mock("../../commands/agents.config.js", () => ({
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
listAgentIds: () => ["main"],
|
||||
listAgentEntries: mocks.listAgentEntries,
|
||||
resolveAgentDir: mocks.resolveAgentDir,
|
||||
resolveAgentConfig: (cfg: unknown, agentId: string) =>
|
||||
getAgentList(cfg).find((entry) => entry.id === agentId),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { findOverlappingWorkspaceAgentIds } from "../../agents/agent-delete-safety.js";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveAgentDir,
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
ensureAgentWorkspace,
|
||||
isWorkspaceSetupCompleted,
|
||||
} from "../../agents/workspace.js";
|
||||
import { purgeAgentSessionStoreEntries } from "../../commands/agents.command-shared.js";
|
||||
import {
|
||||
applyAgentConfig,
|
||||
findAgentEntryIndex,
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
pruneAgentConfig,
|
||||
} from "../../commands/agents.config.js";
|
||||
import { replaceConfigFile } from "../../config/config.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
|
||||
import {
|
||||
purgeAgentSessionStoreEntries,
|
||||
resolveSessionTranscriptsDirForAgent,
|
||||
} from "../../config/sessions.js";
|
||||
import type { IdentityConfig } from "../../config/types.base.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { sameFileIdentity } from "../../infra/file-identity.js";
|
||||
@@ -657,8 +660,10 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
await purgeAgentSessionStoreEntries(cfg, agentId);
|
||||
|
||||
if (deleteFiles) {
|
||||
const workspaceSharedWith = findOverlappingWorkspaceAgentIds(cfg, agentId, workspaceDir);
|
||||
const deleteWorkspace = workspaceSharedWith.length === 0;
|
||||
await Promise.all([
|
||||
moveToTrashBestEffort(workspaceDir),
|
||||
...(deleteWorkspace ? [moveToTrashBestEffort(workspaceDir)] : []),
|
||||
moveToTrashBestEffort(agentDir),
|
||||
moveToTrashBestEffort(sessionsDir),
|
||||
]);
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js";
|
||||
import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js";
|
||||
import { normalizeReasoningLevel, normalizeThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import { runSessionsCleanup } from "../../commands/sessions-cleanup.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
runSessionsCleanup,
|
||||
serializeSessionCleanupResult,
|
||||
resolveMainSessionKey,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
@@ -680,15 +681,11 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
fixMissing: params.fixMissing,
|
||||
},
|
||||
});
|
||||
const result =
|
||||
appliedSummaries.length === 1
|
||||
? (appliedSummaries[0] ?? {})
|
||||
: {
|
||||
allAgents: true,
|
||||
mode,
|
||||
dryRun: false,
|
||||
stores: appliedSummaries,
|
||||
};
|
||||
const result = serializeSessionCleanupResult({
|
||||
mode,
|
||||
dryRun: false,
|
||||
summaries: appliedSummaries,
|
||||
});
|
||||
respond(true, result, undefined);
|
||||
for (const summary of appliedSummaries) {
|
||||
emitSessionsChanged(context, {
|
||||
|
||||
Reference in New Issue
Block a user