fix(cli): report honest SQLite store labels and consistent build SHA in help (#111659)

This commit is contained in:
Peter Steinberger
2026-07-19 22:07:44 -07:00
committed by GitHub
parent 1508a58674
commit 8f9e42f2a9
12 changed files with 161 additions and 50 deletions
+8
View File
@@ -778,6 +778,8 @@ export async function writeCliStartupMetadata(options?: {
: undefined;
const reusableBrowserHelpText =
reusableExisting &&
bundleIdentity &&
reusableExisting.rootHelpBundleSignature === bundleIdentity.signature &&
reusableExisting.browserHelpSourceSignature === browserHelpSourceSignature &&
typeof reusableExisting.browserHelpText === "string" &&
reusableExisting.browserHelpText.length > 0
@@ -785,6 +787,8 @@ export async function writeCliStartupMetadata(options?: {
: undefined;
const reusableSecretsHelpText =
reusableExisting &&
bundleIdentity &&
reusableExisting.rootHelpBundleSignature === bundleIdentity.signature &&
reusableExisting.secretsHelpSourceSignature === secretsHelpSourceSignature &&
typeof reusableExisting.secretsHelpText === "string" &&
reusableExisting.secretsHelpText.length > 0
@@ -792,6 +796,8 @@ export async function writeCliStartupMetadata(options?: {
: undefined;
const reusableNodesHelpText =
reusableExisting &&
bundleIdentity &&
reusableExisting.rootHelpBundleSignature === bundleIdentity.signature &&
reusableExisting.nodesHelpSourceSignature === nodesHelpSourceSignature &&
typeof reusableExisting.nodesHelpText === "string" &&
reusableExisting.nodesHelpText.length > 0
@@ -799,6 +805,8 @@ export async function writeCliStartupMetadata(options?: {
: undefined;
const reusableSubcommandHelpText =
reusableExisting &&
bundleIdentity &&
reusableExisting.rootHelpBundleSignature === bundleIdentity.signature &&
reusableExisting.subcommandHelpSourceSignature === subcommandHelpSourceSignature &&
hasAllPrecomputedSubcommandHelpText(reusableExisting.subcommandHelpText)
? (reusableExisting.subcommandHelpText as PrecomputedSubcommandHelpText)
+35 -7
View File
@@ -227,7 +227,7 @@ describe("sessionsCleanupCommand", () => {
expect(logs).toHaveLength(1);
expect(JSON.parse(logs[0] ?? "{}")).toEqual({
agentId: "main",
storePath: "/resolved/sessions.json",
storePath: "/resolved/openclaw-agent.sqlite",
mode: "enforce",
dryRun: false,
beforeCount: 3,
@@ -262,9 +262,10 @@ describe("sessionsCleanupCommand", () => {
});
it("delegates non-store enforcing cleanup through the Gateway writer when reachable", async () => {
const remoteStorePath = "C:\\Users\\gateway\\.openclaw\\agents\\main\\sessions\\sessions.json";
mocks.callGateway.mockResolvedValue({
agentId: "main",
storePath: "/resolved/sessions.json",
storePath: remoteStorePath,
mode: "enforce",
dryRun: false,
beforeCount: 3,
@@ -298,7 +299,7 @@ describe("sessionsCleanupCommand", () => {
expect(logs).toHaveLength(1);
expect(JSON.parse(logs[0] ?? "{}")).toEqual({
agentId: "main",
storePath: "/resolved/sessions.json",
storePath: remoteStorePath,
mode: "enforce",
dryRun: false,
beforeCount: 3,
@@ -315,6 +316,32 @@ describe("sessionsCleanupCommand", () => {
});
});
it("preserves a Gateway-owned store path in human output", async () => {
const remoteStorePath = "C:\\Users\\gateway\\.openclaw\\openclaw-agent.sqlite";
mocks.callGateway.mockResolvedValue({
agentId: "main",
storePath: remoteStorePath,
mode: "enforce",
dryRun: false,
beforeCount: 3,
afterCount: 1,
missing: 0,
dmScopeRetired: 0,
modelRunPruned: 0,
pruned: 2,
capped: 0,
diskBudget: null,
wouldMutate: true,
applied: true,
appliedCount: 1,
});
const { runtime, logs } = makeRuntime();
await sessionsCleanupCommand({ enforce: true }, runtime);
expectLogsToInclude(logs, `Session store: ${remoteStorePath}`);
});
it("returns dry-run JSON without mutating the store", async () => {
mocks.runSessionsCleanup.mockResolvedValue({
mode: "warn",
@@ -368,7 +395,7 @@ describe("sessionsCleanupCommand", () => {
expect(logs).toHaveLength(1);
expect(JSON.parse(logs[0] ?? "{}")).toEqual({
agentId: "main",
storePath: "/resolved/sessions.json",
storePath: "/resolved/openclaw-agent.sqlite",
mode: "warn",
dryRun: true,
beforeCount: 2,
@@ -440,7 +467,7 @@ describe("sessionsCleanupCommand", () => {
expect(logs).toHaveLength(1);
expect(JSON.parse(logs[0] ?? "{}")).toEqual({
agentId: "main",
storePath: "/resolved/sessions.json",
storePath: "/resolved/openclaw-agent.sqlite",
mode: "warn",
dryRun: true,
beforeCount: 1,
@@ -505,6 +532,7 @@ describe("sessionsCleanupCommand", () => {
runtime,
);
expectLogsToInclude(logs, "Session store: /resolved/openclaw-agent.sqlite");
expectLogsToInclude(logs, "Planned session actions:");
expectLogsToInclude(logs, "Would prune unreferenced artifacts: 2");
const tableHeaderLines = logs.filter((line) => line.includes("Action") && line.includes("Key"));
@@ -694,7 +722,7 @@ describe("sessionsCleanupCommand", () => {
stores: [
{
agentId: "main",
storePath: "/resolved/main-sessions.json",
storePath: "/resolved/main-sessions.sqlite",
mode: "warn",
dryRun: true,
beforeCount: 1,
@@ -709,7 +737,7 @@ describe("sessionsCleanupCommand", () => {
},
{
agentId: "work",
storePath: "/resolved/work-sessions.json",
storePath: "/resolved/work-sessions.work.sqlite",
mode: "warn",
dryRun: true,
beforeCount: 1,
+33 -13
View File
@@ -15,6 +15,7 @@ import {
type SessionsCleanupOptions,
type SessionsCleanupResult,
} from "../config/sessions.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway, isGatewayTransportError } from "../gateway/call.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
@@ -141,6 +142,15 @@ function renderLabelSummaries(params: {
params.runtime.log(`Total: ${totalKept} kept, ${totalPruned} pruned`);
}
function toDisplayedCleanupSummary(summary: SessionCleanupSummary): SessionCleanupSummary {
return {
...summary,
storePath: resolveSqliteTargetFromSessionStorePath(summary.storePath, {
agentId: summary.agentId,
}).path,
};
}
function renderStoreDryRunPlan(params: {
cfg: OpenClawConfig;
summary: SessionCleanupSummary;
@@ -149,10 +159,11 @@ function renderStoreDryRunPlan(params: {
showAgentHeader: boolean;
}) {
const rich = isRich();
const displaySummary = toDisplayedCleanupSummary(params.summary);
if (params.showAgentHeader) {
params.runtime.log(`Agent: ${params.summary.agentId}`);
}
params.runtime.log(`Session store: ${params.summary.storePath}`);
params.runtime.log(`Session store: ${displaySummary.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})`,
@@ -202,6 +213,7 @@ function renderStoreDryRunPlan(params: {
function renderAppliedSummaries(params: {
summaries: SessionCleanupSummary[];
runtime: RuntimeEnv;
locallyOwned: boolean;
}) {
for (let i = 0; i < params.summaries.length; i += 1) {
const summary = params.summaries[i];
@@ -214,7 +226,10 @@ function renderAppliedSummaries(params: {
if (params.summaries.length > 1) {
params.runtime.log(`Agent: ${summary.agentId}`);
}
params.runtime.log(`Session store: ${summary.storePath}`);
const storePath = params.locallyOwned
? toDisplayedCleanupSummary(summary).storePath
: summary.storePath;
params.runtime.log(`Session store: ${storePath}`);
params.runtime.log(`Applied maintenance. Current entries: ${summary.appliedCount ?? 0}`);
if (summary.unreferencedArtifacts?.removedFiles) {
params.runtime.log(
@@ -226,14 +241,14 @@ function renderAppliedSummaries(params: {
async function maybeRunGatewayCleanup(
opts: SessionsCleanupOptions,
): Promise<SessionsCleanupResult | null> {
): Promise<{ delegated: true; result: SessionsCleanupResult } | { delegated: false }> {
if (opts.store || opts.dryRun) {
// Explicit store paths and dry-runs must stay local; the gateway only owns
// live in-process cleanup for default stores.
return null;
return { delegated: false };
}
try {
return await callGateway<SessionsCleanupResult>({
const result = await callGateway<SessionsCleanupResult>({
method: "sessions.cleanup",
params: {
agent: opts.agent,
@@ -247,11 +262,12 @@ async function maybeRunGatewayCleanup(
clientName: GATEWAY_CLIENT_NAMES.CLI,
requiredMethods: ["sessions.cleanup"],
});
return { delegated: true, result };
} catch (error) {
if (isGatewayTransportError(error)) {
// A stopped gateway should not block local maintenance; fall back to the
// on-disk session stores when transport is unavailable.
return null;
return { delegated: false };
}
throw error;
}
@@ -259,15 +275,19 @@ async function maybeRunGatewayCleanup(
/** Runs session cleanup, optionally using the live gateway for active stores. */
export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runtime: RuntimeEnv) {
const gatewayResult = await maybeRunGatewayCleanup(opts);
if (gatewayResult) {
const gatewayCleanup = await maybeRunGatewayCleanup(opts);
if (gatewayCleanup.delegated) {
// The Gateway owns this path. Preserve its syntax because resolving a remote
// Windows path on a POSIX client (or vice versa) would fabricate a local path.
if (opts.json) {
writeRuntimeJson(runtime, gatewayResult);
writeRuntimeJson(runtime, gatewayCleanup.result);
return;
}
renderAppliedSummaries({
summaries: "stores" in gatewayResult ? gatewayResult.stores : [gatewayResult],
summaries:
"stores" in gatewayCleanup.result ? gatewayCleanup.result.stores : [gatewayCleanup.result],
runtime,
locallyOwned: false,
});
return;
}
@@ -298,7 +318,7 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
serializeSessionCleanupResult({
mode,
dryRun: true,
summaries: previewResults.map((result) => result.summary),
summaries: previewResults.map((result) => toDisplayedCleanupSummary(result.summary)),
}),
);
return;
@@ -325,11 +345,11 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
serializeSessionCleanupResult({
mode,
dryRun: false,
summaries: appliedSummaries,
summaries: appliedSummaries.map(toDisplayedCleanupSummary),
}),
);
return;
}
renderAppliedSummaries({ summaries: appliedSummaries, runtime });
renderAppliedSummaries({ summaries: appliedSummaries, runtime, locallyOwned: true });
}
@@ -156,8 +156,8 @@ describe("sessionsCommand default store agent selection", () => {
expect(payload.count).toBe(2);
expect(payload.allAgents).toBe(true);
expect(payload.stores).toEqual([
{ agentId: "main", path: "/tmp/shared-sessions.json" },
{ agentId: "voice", path: "/tmp/shared-sessions.json" },
{ agentId: "main", path: "/tmp/shared-sessions.sqlite" },
{ agentId: "voice", path: "/tmp/shared-sessions.voice.sqlite" },
]);
expect(payload.sessions?.map((session) => session.agentId).toSorted()).toEqual([
"main",
@@ -177,7 +177,7 @@ describe("sessionsCommand default store agent selection", () => {
agentId: "voice",
storePath: "/tmp/sessions-voice.json",
});
expect(logs[0]).toContain("Session store: /tmp/sessions-voice.json");
expect(logs[0]).toContain("Session store: /tmp/sessions-voice.voice.sqlite");
});
it("uses all configured agent stores with --all-agents", async () => {
+22
View File
@@ -182,6 +182,28 @@ describe("sessionsCommand", () => {
expect(group?.totalTokensFresh).toBe(false);
});
it("reports the SQLite database for the store and SQLite-backed sessionFile", async () => {
const store = await writeStore({
main: {
sessionId: "abc123",
sessionFile: "sqlite:main:abc123:/tmp/openclaw/agents/main/sessions/sessions.json",
updatedAt: Date.now() - 10 * 60_000,
model: "test:opus",
},
});
const payload = await runSessionsJson<{
path?: string;
sessions?: Array<{ key: string; sessionFile?: string }>;
}>(sessionsCommand, store);
expect(payload.path).toMatch(/openclaw-agent\.sqlite$/u);
expect(payload.path).not.toContain("sessions.json");
expect(payload.sessions?.find((row) => row.key === "main")?.sessionFile).toBe(
"sqlite:main:abc123:/tmp/openclaw/agents/main/agent/openclaw-agent.sqlite",
);
});
it("exports subagent lineage metadata in JSON output", async () => {
const store = await writeStore({
"agent:child:main": {
+25 -4
View File
@@ -17,6 +17,11 @@ import { normalizeChatType } from "../channels/chat-type.js";
import { getRuntimeConfig } from "../config/config.js";
import { resolveSessionTotalTokens } from "../config/sessions.js";
import { listSessionEntries } from "../config/sessions/session-accessor.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import {
formatSqliteSessionFileMarker,
parseSqliteSessionFileMarker,
} from "../config/sessions/sqlite-marker.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveStoredSessionKeyForAgentStore } from "../gateway/session-store-key.js";
@@ -229,9 +234,24 @@ function formatRuntimeCell(runtimeLabel: string, rich: boolean): string {
return rich ? theme.info(label) : label;
}
function resolveSessionStoreDisplayPath(target: { agentId: string; storePath: string }): string {
return resolveSqliteTargetFromSessionStorePath(target.storePath, {
agentId: target.agentId,
}).path;
}
function toJsonSessionRow(row: SessionRow): Omit<SessionRow, "runtimeLabel"> {
const { runtimeLabel, ...jsonRow } = row;
void runtimeLabel;
const marker = parseSqliteSessionFileMarker(jsonRow.sessionFile);
if (marker) {
jsonRow.sessionFile = formatSqliteSessionFileMarker({
...marker,
storePath: resolveSqliteTargetFromSessionStorePath(marker.storePath, {
agentId: marker.agentId,
}).path,
});
}
return jsonRow;
}
@@ -429,11 +449,11 @@ export async function sessionsCommand(
const multi = targets.length > 1;
const aggregate = aggregateAgents || multi;
writeRuntimeJson(runtime, {
path: aggregate ? null : (targets[0]?.storePath ?? null),
path: aggregate || !targets[0] ? null : resolveSessionStoreDisplayPath(targets[0]),
stores: aggregate
? targets.map((target) => ({
agentId: target.agentId,
path: target.storePath,
path: resolveSessionStoreDisplayPath(target),
}))
: undefined,
allAgents: aggregateAgents ? true : undefined,
@@ -476,8 +496,9 @@ export async function sessionsCommand(
return;
}
if (targets.length === 1 && !aggregateAgents) {
runtime.log(info(`Session store: ${targets[0]?.storePath}`));
const primaryTarget = targets[0];
if (primaryTarget && targets.length === 1 && !aggregateAgents) {
runtime.log(info(`Session store: ${resolveSessionStoreDisplayPath(primaryTarget)}`));
} else {
runtime.log(
info(`Session stores: ${targets.length} (${targets.map((t) => t.agentId).join(", ")})`),
+1 -1
View File
@@ -4,7 +4,7 @@ import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js
/** SQLite database target resolved from a legacy session store path. */
type ResolvedSqliteStoreTarget = {
agentId?: string;
path?: string;
path: string;
};
function resolveCustomStoreSqlitePath(params: {
@@ -64,12 +64,12 @@ function createDeferredIsolatedRun() {
function expectCronStatus(
status: Awaited<ReturnType<CronService["status"]>>,
params: { storePath: string; jobs: number },
params: { jobs: number },
) {
expect(status.enabled).toBe(true);
expect(status.storePath).toBe(params.storePath);
expect(status.storage).toBe("sqlite");
expect(status.sqlitePath).toContain("openclaw.sqlite");
expect(status.storePath).toBe(status.sqlitePath);
expect(status.jobs).toBe(params.jobs);
if (status.nextWakeAtMs !== null) {
expect(status.nextWakeAtMs).toBeTypeOf("number");
@@ -129,7 +129,7 @@ describe("CronService read ops while job is running", () => {
expect(isolatedRun.runIsolatedAgentJob).toHaveBeenCalledTimes(1);
await expect(cron.list({ includeDisabled: true })).resolves.toHaveLength(1);
expectCronStatus(await cron.status(), { storePath: store.storePath, jobs: 1 });
expectCronStatus(await cron.status(), { jobs: 1 });
const running = await cron.list({ includeDisabled: true });
expect(running[0]?.state.runningAtMs).toBeTypeOf("number");
@@ -205,7 +205,6 @@ describe("CronService read ops while job is running", () => {
message: "cron.status during cron.run timed out",
}),
{
storePath: store.storePath,
jobs: 1,
},
);
@@ -274,7 +273,6 @@ describe("CronService read ops while job is running", () => {
message: "cron.status during startup timed out",
}),
{
storePath: store.storePath,
jobs: 1,
},
);
+3 -2
View File
@@ -320,11 +320,12 @@ export function resumeScheduling(state: CronServiceState) {
export async function status(state: CronServiceState) {
return await locked(state, async () => {
await ensureLoadedForRead(state);
const sqlitePath = resolveOpenClawStateSqlitePath();
return {
enabled: state.deps.cronEnabled,
storePath: state.deps.storePath,
storePath: sqlitePath,
storage: "sqlite" as const,
sqlitePath: resolveOpenClawStateSqlitePath(),
sqlitePath,
jobs: state.store?.jobs.length ?? 0,
nextWakeAtMs: state.deps.cronEnabled ? (nextWakeAtMs(state) ?? null) : null,
};
+1 -1
View File
@@ -307,7 +307,7 @@ export type CronWakeMode = "now" | "next-heartbeat";
/** Lightweight service status returned to gateway/control surfaces. */
export type CronStatusSummary = {
enabled: boolean;
/** @deprecated Legacy partition key; actual storage is SQLite. Use `sqlitePath`. */
/** @deprecated Alias for `sqlitePath`. */
storePath: string;
/** Storage backend identifier. */
storage: "sqlite";
+1 -1
View File
@@ -1395,7 +1395,7 @@ describe("gateway server cron", () => {
| undefined;
expect(statusPayload?.enabled).toBe(true);
const storePath = typeof statusPayload?.storePath === "string" ? statusPayload.storePath : "";
expect(storePath).toContain("jobs.json");
expect(storePath).toContain("openclaw.sqlite");
const autoRes = await directCronReq(cronState, "cron.add", {
name: "auto run test",
+26 -13
View File
@@ -628,6 +628,23 @@ describe("write-cli-startup-metadata", () => {
let renderCount = 0;
let commandRenderCount = 0;
const renderSubcommandHelp = () => {
commandRenderCount += 1;
const buildInfo = JSON.parse(readFileSync(path.join(distDir, "build-info.json"), "utf8")) as {
commit: string;
version: string;
};
const banner = `OpenClaw ${buildInfo.version} (${buildInfo.commit.slice(0, 7)})`;
return {
doctor: `${banner}\nUsage: openclaw doctor\n`,
gateway: `${banner}\nUsage: openclaw gateway\n`,
models: `${banner}\nUsage: openclaw models\n`,
plugins: `${banner}\nUsage: openclaw plugins\n`,
sessions: `${banner}\nUsage: openclaw sessions\n`,
tasks: `${banner}\nUsage: openclaw tasks\n`,
};
};
writeStartupMetadataSourceSignatureFixture(tempRoot);
writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n");
@@ -653,17 +670,7 @@ describe("write-cli-startup-metadata", () => {
commandRenderCount += 1;
return "Usage: openclaw nodes\n";
},
renderSourceSubcommandHelpTextRecord: () => {
commandRenderCount += 1;
return {
doctor: "Usage: openclaw doctor\n",
gateway: "Usage: openclaw gateway\n",
models: "Usage: openclaw models\n",
plugins: "Usage: openclaw plugins\n",
sessions: "Usage: openclaw sessions\n",
tasks: "Usage: openclaw tasks\n",
};
},
renderSourceSubcommandHelpTextRecord: renderSubcommandHelp,
});
};
@@ -676,6 +683,7 @@ describe("write-cli-startup-metadata", () => {
await writeMetadata();
expect(renderCount).toBe(1);
expect(commandRenderCount).toBe(4);
expect(readFileSync(outputPath, "utf8")).toContain("OpenClaw 2026.7.2 (aaaaaaa)");
writeFixtureFile(
distDir,
@@ -684,7 +692,8 @@ describe("write-cli-startup-metadata", () => {
);
await writeMetadata();
expect(renderCount).toBe(2);
expect(commandRenderCount).toBe(4);
expect(commandRenderCount).toBe(8);
expect(readFileSync(outputPath, "utf8")).toContain("OpenClaw 2026.7.2 (bbbbbbb)");
writeFixtureFile(
distDir,
@@ -693,6 +702,10 @@ describe("write-cli-startup-metadata", () => {
);
await writeMetadata();
expect(renderCount).toBe(3);
expect(commandRenderCount).toBe(4);
expect(commandRenderCount).toBe(12);
const written = JSON.parse(readFileSync(outputPath, "utf8")) as {
subcommandHelpText: { models: string };
};
expect(written.subcommandHelpText.models).toContain("OpenClaw 2026.7.3 (bbbbbbb)");
});
});