Merge 519cf8d5d1cbc6db3c1e995e1b1a477359b9bf47 into 8a05c05596ca9ba0735dafd8e359885de4c2c969
This commit is contained in:
commit
2fd60d9373
@ -162,7 +162,7 @@ async function previewStoreCleanup(params: {
|
||||
activeKey?: string;
|
||||
fixMissing?: boolean;
|
||||
}) {
|
||||
const maintenance = resolveMaintenanceConfig();
|
||||
const maintenance = resolveMaintenanceConfig(params.target.agentId);
|
||||
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
|
||||
const previewStore = structuredClone(beforeStore);
|
||||
const staleKeys = new Set<string>();
|
||||
@ -377,6 +377,7 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
|
||||
});
|
||||
},
|
||||
{
|
||||
agentId: target.agentId,
|
||||
activeSessionKey: opts.activeKey,
|
||||
maintenanceOverride: {
|
||||
mode,
|
||||
|
||||
176
src/config/sessions/store-maintenance.per-agent.test.ts
Normal file
176
src/config/sessions/store-maintenance.per-agent.test.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock config loading to control test inputs.
|
||||
vi.mock("../config.js", () => ({
|
||||
loadConfig: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { resolveMaintenanceConfig } from "./store-maintenance.js";
|
||||
|
||||
const mockLoadConfig = vi.mocked(loadConfig);
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-agent session maintenance config resolution.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveMaintenanceConfig with agentId", () => {
|
||||
it("returns global config when no agentId is provided", () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
pruneAfter: "7d",
|
||||
maxEntries: 200,
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "worker",
|
||||
maintenance: { mode: "warn", maxEntries: 50 },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as ReturnType<typeof loadConfig>);
|
||||
|
||||
const result = resolveMaintenanceConfig();
|
||||
|
||||
expect(result.mode).toBe("enforce");
|
||||
expect(result.maxEntries).toBe(200);
|
||||
expect(result.pruneAfterMs).toBe(7 * DAY_MS);
|
||||
});
|
||||
|
||||
it("merges per-agent overrides over global config", () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
pruneAfter: "30d",
|
||||
maxEntries: 500,
|
||||
rotateBytes: "10mb",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "approval",
|
||||
maintenance: {
|
||||
mode: "warn",
|
||||
maxEntries: 50,
|
||||
pruneAfter: "1d",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as ReturnType<typeof loadConfig>);
|
||||
|
||||
const result = resolveMaintenanceConfig("approval");
|
||||
|
||||
expect(result.mode).toBe("warn");
|
||||
expect(result.maxEntries).toBe(50);
|
||||
expect(result.pruneAfterMs).toBe(1 * DAY_MS);
|
||||
// rotateBytes falls through from global.
|
||||
expect(result.rotateBytes).toBe(10_485_760);
|
||||
});
|
||||
|
||||
it("falls through to global when agent has no maintenance config", () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
maxEntries: 300,
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "worker" }],
|
||||
},
|
||||
} as ReturnType<typeof loadConfig>);
|
||||
|
||||
const result = resolveMaintenanceConfig("worker");
|
||||
|
||||
expect(result.mode).toBe("enforce");
|
||||
expect(result.maxEntries).toBe(300);
|
||||
});
|
||||
|
||||
it("falls through to global when agent is not found in list", () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
maxEntries: 100,
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "other" }],
|
||||
},
|
||||
} as ReturnType<typeof loadConfig>);
|
||||
|
||||
const result = resolveMaintenanceConfig("nonexistent");
|
||||
|
||||
expect(result.mode).toBe("enforce");
|
||||
expect(result.maxEntries).toBe(100);
|
||||
});
|
||||
|
||||
it("uses built-in defaults when no global or per-agent config exists", () => {
|
||||
mockLoadConfig.mockReturnValue({} as ReturnType<typeof loadConfig>);
|
||||
|
||||
const result = resolveMaintenanceConfig("any-agent");
|
||||
|
||||
expect(result.mode).toBe("warn");
|
||||
expect(result.maxEntries).toBe(500);
|
||||
expect(result.pruneAfterMs).toBe(30 * DAY_MS);
|
||||
expect(result.rotateBytes).toBe(10_485_760);
|
||||
});
|
||||
|
||||
it("per-agent maxDiskBytes/highWaterBytes override global", () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
maintenance: {
|
||||
maxDiskBytes: "200mb",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "heavy",
|
||||
maintenance: {
|
||||
maxDiskBytes: "500mb",
|
||||
highWaterBytes: "400mb",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as ReturnType<typeof loadConfig>);
|
||||
|
||||
const result = resolveMaintenanceConfig("heavy");
|
||||
|
||||
expect(result.maxDiskBytes).toBe(500 * 1024 * 1024);
|
||||
expect(result.highWaterBytes).toBe(400 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("per-agent config without global maintenance still works", () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "standalone",
|
||||
maintenance: {
|
||||
mode: "enforce",
|
||||
pruneAfter: "2d",
|
||||
maxEntries: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as ReturnType<typeof loadConfig>);
|
||||
|
||||
const result = resolveMaintenanceConfig("standalone");
|
||||
|
||||
expect(result.mode).toBe("enforce");
|
||||
expect(result.maxEntries).toBe(10);
|
||||
expect(result.pruneAfterMs).toBe(2 * DAY_MS);
|
||||
});
|
||||
});
|
||||
@ -124,16 +124,58 @@ function resolveHighWaterBytes(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve maintenance settings from openclaw.json (`session.maintenance`).
|
||||
* Look up per-agent maintenance overrides from `agents.list[].maintenance`.
|
||||
* Returns `undefined` when the agent has no overrides or is not found.
|
||||
*/
|
||||
function resolveAgentMaintenanceOverride(
|
||||
agentId: string | undefined,
|
||||
): SessionMaintenanceConfig | undefined {
|
||||
if (!agentId) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const agents = loadConfig().agents?.list;
|
||||
if (!agents) {
|
||||
return undefined;
|
||||
}
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
return agent?.maintenance ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge global session.maintenance with per-agent overrides.
|
||||
* Per-agent values take precedence; unset fields fall through to global.
|
||||
*/
|
||||
function mergeMaintenanceConfig(
|
||||
global: SessionMaintenanceConfig | undefined,
|
||||
perAgent: SessionMaintenanceConfig | undefined,
|
||||
): SessionMaintenanceConfig | undefined {
|
||||
if (!perAgent) {
|
||||
return global;
|
||||
}
|
||||
if (!global) {
|
||||
return perAgent;
|
||||
}
|
||||
return { ...global, ...perAgent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve maintenance settings from openclaw.json (`session.maintenance`),
|
||||
* optionally merging per-agent overrides from `agents.list[].maintenance`.
|
||||
* Falls back to built-in defaults when config is missing or unset.
|
||||
*/
|
||||
export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig {
|
||||
let maintenance: SessionMaintenanceConfig | undefined;
|
||||
export function resolveMaintenanceConfig(agentId?: string): ResolvedSessionMaintenanceConfig {
|
||||
let globalMaintenance: SessionMaintenanceConfig | undefined;
|
||||
try {
|
||||
maintenance = loadConfig().session?.maintenance;
|
||||
globalMaintenance = loadConfig().session?.maintenance;
|
||||
} catch {
|
||||
// Config may not be available (e.g. in tests). Use defaults.
|
||||
}
|
||||
const agentOverride = resolveAgentMaintenanceOverride(agentId);
|
||||
const maintenance = mergeMaintenanceConfig(globalMaintenance, agentOverride);
|
||||
const pruneAfterMs = resolvePruneAfterMs(maintenance);
|
||||
const maxDiskBytes = resolveMaxDiskBytes(maintenance);
|
||||
return {
|
||||
|
||||
@ -307,6 +307,8 @@ export type { ResolvedSessionMaintenanceConfig, SessionMaintenanceWarning };
|
||||
type SaveSessionStoreOptions = {
|
||||
/** Skip pruning, capping, and rotation (e.g. during one-time migrations). */
|
||||
skipMaintenance?: boolean;
|
||||
/** Agent id for per-agent maintenance config resolution. */
|
||||
agentId?: string;
|
||||
/** Active session key for warn-only maintenance. */
|
||||
activeSessionKey?: string;
|
||||
/**
|
||||
@ -410,7 +412,10 @@ async function saveSessionStoreUnlocked(
|
||||
|
||||
if (!opts?.skipMaintenance) {
|
||||
// Resolve maintenance config once (avoids repeated loadConfig() calls).
|
||||
const maintenance = { ...resolveMaintenanceConfig(), ...opts?.maintenanceOverride };
|
||||
const maintenance = {
|
||||
...resolveMaintenanceConfig(opts?.agentId),
|
||||
...opts?.maintenanceOverride,
|
||||
};
|
||||
const shouldWarnOnly = maintenance.mode === "warn";
|
||||
const beforeCount = Object.keys(store).length;
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ChatType } from "../channels/chat-type.js";
|
||||
import type { AgentDefaultsConfig } from "./types.agent-defaults.js";
|
||||
import type { AgentModelConfig, AgentSandboxConfig } from "./types.agents-shared.js";
|
||||
import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
|
||||
import type { HumanDelayConfig, IdentityConfig, SessionMaintenanceConfig } from "./types.base.js";
|
||||
import type { GroupChatConfig } from "./types.messages.js";
|
||||
import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js";
|
||||
|
||||
@ -80,6 +80,8 @@ export type AgentConfig = {
|
||||
/** Per-agent default model for spawned sub-agents (string or {primary,fallbacks}). */
|
||||
model?: AgentModelConfig;
|
||||
};
|
||||
/** Optional per-agent session maintenance overrides. */
|
||||
maintenance?: SessionMaintenanceConfig;
|
||||
/** Optional per-agent sandbox overrides. */
|
||||
sandbox?: AgentSandboxConfig;
|
||||
/** Optional per-agent stream params (e.g. cacheRetention, temperature). */
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
ToolsLinksSchema,
|
||||
ToolsMediaSchema,
|
||||
} from "./zod-schema.core.js";
|
||||
import { SessionMaintenanceSchema } from "./zod-schema.maintenance.js";
|
||||
import { sensitive } from "./zod-schema.sensitive.js";
|
||||
|
||||
export const HeartbeatSchema = z
|
||||
@ -791,6 +792,7 @@ export const AgentEntrySchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
maintenance: SessionMaintenanceSchema,
|
||||
sandbox: AgentSandboxSchema,
|
||||
params: z.record(z.string(), z.unknown()).optional(),
|
||||
tools: AgentToolsSchema,
|
||||
|
||||
75
src/config/zod-schema.maintenance.ts
Normal file
75
src/config/zod-schema.maintenance.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { z } from "zod";
|
||||
import { parseByteSize } from "../cli/parse-bytes.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
|
||||
export const SessionMaintenanceSchema = z
|
||||
.object({
|
||||
mode: z.enum(["enforce", "warn"]).optional(),
|
||||
pruneAfter: z.union([z.string(), z.number()]).optional(),
|
||||
/** @deprecated Use pruneAfter instead. */
|
||||
pruneDays: z.number().int().positive().optional(),
|
||||
maxEntries: z.number().int().positive().optional(),
|
||||
rotateBytes: z.union([z.string(), z.number()]).optional(),
|
||||
resetArchiveRetention: z.union([z.string(), z.number(), z.literal(false)]).optional(),
|
||||
maxDiskBytes: z.union([z.string(), z.number()]).optional(),
|
||||
highWaterBytes: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.pruneAfter !== undefined) {
|
||||
try {
|
||||
parseDurationMs(String(val.pruneAfter).trim(), { defaultUnit: "d" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["pruneAfter"],
|
||||
message: "invalid duration (use ms, s, m, h, d)",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (val.rotateBytes !== undefined) {
|
||||
try {
|
||||
parseByteSize(String(val.rotateBytes).trim(), { defaultUnit: "b" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["rotateBytes"],
|
||||
message: "invalid size (use b, kb, mb, gb, tb)",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (val.resetArchiveRetention !== undefined && val.resetArchiveRetention !== false) {
|
||||
try {
|
||||
parseDurationMs(String(val.resetArchiveRetention).trim(), { defaultUnit: "d" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["resetArchiveRetention"],
|
||||
message: "invalid duration (use ms, s, m, h, d)",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (val.maxDiskBytes !== undefined) {
|
||||
try {
|
||||
parseByteSize(String(val.maxDiskBytes).trim(), { defaultUnit: "b" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["maxDiskBytes"],
|
||||
message: "invalid size (use b, kb, mb, gb, tb)",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (val.highWaterBytes !== undefined) {
|
||||
try {
|
||||
parseByteSize(String(val.highWaterBytes).trim(), { defaultUnit: "b" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["highWaterBytes"],
|
||||
message: "invalid size (use b, kb, mb, gb, tb)",
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.optional();
|
||||
@ -1,6 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { parseByteSize } from "../cli/parse-bytes.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { ElevatedAllowFromSchema } from "./zod-schema.agent-runtime.js";
|
||||
import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js";
|
||||
import {
|
||||
@ -11,8 +9,11 @@ import {
|
||||
TypingModeSchema,
|
||||
TtsConfigSchema,
|
||||
} from "./zod-schema.core.js";
|
||||
import { SessionMaintenanceSchema } from "./zod-schema.maintenance.js";
|
||||
import { sensitive } from "./zod-schema.sensitive.js";
|
||||
|
||||
export { SessionMaintenanceSchema };
|
||||
|
||||
const SessionResetConfigSchema = z
|
||||
.object({
|
||||
mode: z.union([z.literal("daily"), z.literal("idle")]).optional(),
|
||||
@ -69,77 +70,7 @@ export const SessionSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
maintenance: z
|
||||
.object({
|
||||
mode: z.enum(["enforce", "warn"]).optional(),
|
||||
pruneAfter: z.union([z.string(), z.number()]).optional(),
|
||||
/** @deprecated Use pruneAfter instead. */
|
||||
pruneDays: z.number().int().positive().optional(),
|
||||
maxEntries: z.number().int().positive().optional(),
|
||||
rotateBytes: z.union([z.string(), z.number()]).optional(),
|
||||
resetArchiveRetention: z.union([z.string(), z.number(), z.literal(false)]).optional(),
|
||||
maxDiskBytes: z.union([z.string(), z.number()]).optional(),
|
||||
highWaterBytes: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.pruneAfter !== undefined) {
|
||||
try {
|
||||
parseDurationMs(String(val.pruneAfter).trim(), { defaultUnit: "d" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["pruneAfter"],
|
||||
message: "invalid duration (use ms, s, m, h, d)",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (val.rotateBytes !== undefined) {
|
||||
try {
|
||||
parseByteSize(String(val.rotateBytes).trim(), { defaultUnit: "b" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["rotateBytes"],
|
||||
message: "invalid size (use b, kb, mb, gb, tb)",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (val.resetArchiveRetention !== undefined && val.resetArchiveRetention !== false) {
|
||||
try {
|
||||
parseDurationMs(String(val.resetArchiveRetention).trim(), { defaultUnit: "d" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["resetArchiveRetention"],
|
||||
message: "invalid duration (use ms, s, m, h, d)",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (val.maxDiskBytes !== undefined) {
|
||||
try {
|
||||
parseByteSize(String(val.maxDiskBytes).trim(), { defaultUnit: "b" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["maxDiskBytes"],
|
||||
message: "invalid size (use b, kb, mb, gb, tb)",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (val.highWaterBytes !== undefined) {
|
||||
try {
|
||||
parseByteSize(String(val.highWaterBytes).trim(), { defaultUnit: "b" });
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["highWaterBytes"],
|
||||
message: "invalid size (use b, kb, mb, gb, tb)",
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.optional(),
|
||||
maintenance: SessionMaintenanceSchema,
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user