mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 14:31:38 +00:00
* refactor(sessions): migrate runtime storage to sqlite * test(sessions): fix sqlite CI regressions * test(sessions): align remaining sqlite fixtures * fix(codex): require sqlite trajectory recorder * test(sessions): align orphan recovery sqlite fixture * test(sessions): align sqlite rebase fixtures * fix(sessions): finish current-main integration of the sqlite flip Resolve the whole-store SDK removal across its owner boundary: drop the loadSessionStore re-export and the registry whole-store wrappers, wire hasTrackedActiveSessionRun into gateway chat, complete the preserveLockedHarnessIds cleanup contract, flip the codex thread-history import to storePath targets, and port remaining main-side tests from file-store helpers to session accessor reads. * chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain bump gets its own review, and regenerate docs_map, the plugin SDK API baseline, and the export-surface ratchet for the merged tree. * feat(sessions): keep archived transcripts by default with zstd cold storage Codex-style retention: deleting or resetting a session archives its transcript as a zstd-compressed JSONL artifact (plain when the runtime lacks node:zlib zstd) and keeps it until the disk budget evicts oldest first. resetArchiveRetention now governs both deleted and reset archives and defaults to keep; maxDiskBytes defaults to 2gb so retention stays bounded, with archives evicted before live sessions. The cron reaper follows the same knob instead of deleting archives on its own timer. * fix(state): converge agent DB migration lineages and bound database growth Merge coherence: run both structure-gated legacy memory-schema repairs (flip-lineage drop, main-lineage identity rebuild) before the flip migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all converge, and hoist foreign_keys=OFF outside the schema transaction where the pragma was silently ignored and the v1 sessions rebuild cascade-deleted session_entries. Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL maintenance releases freed pages in bounded passes (never a blocking full VACUUM), and doctor reports state/agent DB bloat from freelist stats. * fix(codex): resolve the store path for thread-history import via the SDK The supervision catalog passed the legacy sessionFile locator to the storePath-targeted transcript mirror; resolve the agent store path with the session-store SDK helper instead of a runtime-object seam so test fakes and headless callers need no extra surface. Drop the obsolete missing-session-id preprocessing case: sessions rows are NOT NULL on session_id and upsert repairs id-less patches at write time. * fix(sessions): fail safe on malformed disk-budget config and doctor stat errors A malformed explicit maxDiskBytes disables the budget instead of falling back to the destructive 2gb default the user never chose, and the doctor bloat check skips databases whose paths stat-fail instead of aborting doctor. * fix(sessions): complete sqlite conflict translations * test(sqlite): align hardening checks with maintenance * test(sessions): inspect compressed transcript archives * fix(tests): await session seeds and drop unused helpers flagged by CI lint The five unawaited writeSessionStoreSeed calls raced their SQLite seeds against the assertions, failing compact shards; the bloat probe drops a useless initializer and the merged tests drop now-unused helpers. * test(sessions): type legacy proof events directly * test(sessions): align hardening contracts * perf(sessions): read usage transcript sizes from SQL aggregates Usage/cost scans walked every session and materialized every transcript event just to re-stringify it for a byte estimate — the #86718 stall class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes in SQLite without loading a single row. * fix(sessions): re-root foreign-root transcript paths onto the current sessions dir Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry absolute sessionFile paths from the old root; the containment fallback kept those foreign paths, so migration read (and would archive) files in the original root and reported local copies missing. Re-root the canonical agents/<id>/sessions suffix onto the current dir when the file exists there; genuine cross-root layouts still fall through unchanged. * test(agents): seed harness admission through sqlite * fix(sqlite): close agent db on pragma setup failure * fix(doctor): compact and retrofit incremental auto-vacuum after session import The migration is the sanctioned offline window: post-import compact reclaims import churn and applies auto_vacuum=INCREMENTAL to databases created before the fresh-DB pragma existed, so runtime maintenance can release pages in bounded passes on every install. --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
// Gateway boot tests cover BOOT.md execution, boot-session store updates, channel
|
|
// delivery hooks, and echo-guard context seeded for the runtime.
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { SessionScope } from "../config/sessions/types.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
|
|
const agentCommand = vi.fn();
|
|
|
|
vi.mock("../commands/agent.js", () => ({
|
|
agentCommand,
|
|
agentCommandFromIngress: agentCommand,
|
|
}));
|
|
|
|
const { runBootOnce } = await import("./boot.js");
|
|
const { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveMainSessionKey } =
|
|
await import("../config/sessions/main-session.js");
|
|
const { resolveStorePath } = await import("../config/sessions/paths.js");
|
|
const {
|
|
applySessionEntryLifecycleMutation,
|
|
listSessionEntries,
|
|
loadSessionEntry,
|
|
replaceSessionEntry,
|
|
} = await import("../config/sessions/session-accessor.js");
|
|
const { stripInternalRuntimeContext } = await import("../agents/internal-runtime-context.js");
|
|
const { getBootEchoContextForSession, resetBootEchoContextForTests } =
|
|
await import("./boot-echo-guard.js");
|
|
|
|
describe("runBootOnce", () => {
|
|
type BootWorkspaceOptions = {
|
|
bootAsDirectory?: boolean;
|
|
bootContent?: string;
|
|
};
|
|
|
|
const resolveMainStore = (
|
|
cfg: {
|
|
session?: { store?: string; scope?: SessionScope; mainKey?: string };
|
|
agents?: { list?: Array<{ id?: string; default?: boolean }> };
|
|
} = {},
|
|
) => {
|
|
const sessionKey = resolveMainSessionKey(cfg);
|
|
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
|
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
|
const bootSessionKey = `agent:${agentId}:boot`;
|
|
return { sessionKey, bootSessionKey, storePath };
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const { storePath } = resolveMainStore();
|
|
await fs.rm(storePath, { force: true });
|
|
const removals = listSessionEntries({ storePath }).map(({ sessionKey }) => ({ sessionKey }));
|
|
await applySessionEntryLifecycleMutation({ storePath, removals, skipMaintenance: true });
|
|
});
|
|
|
|
const makeDeps = () => ({
|
|
sendMessageWhatsApp: vi.fn(),
|
|
sendMessageTelegram: vi.fn(),
|
|
sendMessageDiscord: vi.fn(),
|
|
sendMessageSlack: vi.fn(),
|
|
sendMessageSignal: vi.fn(),
|
|
sendMessageIMessage: vi.fn(),
|
|
});
|
|
|
|
const withBootWorkspace = async (
|
|
options: BootWorkspaceOptions,
|
|
run: (workspaceDir: string) => Promise<void>,
|
|
) => {
|
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-"));
|
|
try {
|
|
const bootPath = path.join(workspaceDir, "BOOT.md");
|
|
if (options.bootAsDirectory) {
|
|
await fs.mkdir(bootPath, { recursive: true });
|
|
} else if (typeof options.bootContent === "string") {
|
|
await fs.writeFile(bootPath, options.bootContent, "utf-8");
|
|
}
|
|
await run(workspaceDir);
|
|
} finally {
|
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
}
|
|
};
|
|
|
|
const mockAgentUpdatesRequestedSession = (storePath: string) => {
|
|
agentCommand.mockImplementation(async (opts: { sessionId?: string; sessionKey?: string }) => {
|
|
const sessionKey = opts.sessionKey ?? "";
|
|
if (!sessionKey) {
|
|
throw new Error("expected sessionKey");
|
|
}
|
|
await replaceSessionEntry(
|
|
{ storePath, sessionKey },
|
|
{
|
|
sessionId: String(opts.sessionId),
|
|
updatedAt: Date.now(),
|
|
},
|
|
);
|
|
});
|
|
};
|
|
|
|
const requireAgentCall = () => {
|
|
const [call] = agentCommand.mock.calls[0] ?? [];
|
|
if (!call || typeof call !== "object") {
|
|
throw new Error("expected agent command call");
|
|
}
|
|
return call as Record<string, unknown>;
|
|
};
|
|
|
|
const runBootAndReturnCall = async (
|
|
params: {
|
|
content?: string;
|
|
cfg?: OpenClawConfig;
|
|
agentId?: string;
|
|
} = {},
|
|
): Promise<Record<string, unknown>> => {
|
|
let call: Record<string, unknown> | undefined;
|
|
const cfg = params.cfg ?? {};
|
|
await withBootWorkspace(
|
|
{ bootContent: params.content ?? "Check status." },
|
|
async (workspaceDir) => {
|
|
agentCommand.mockResolvedValue(undefined);
|
|
await expect(
|
|
runBootOnce({
|
|
cfg,
|
|
deps: makeDeps(),
|
|
workspaceDir,
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
}),
|
|
).resolves.toEqual({ status: "ran" });
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
call = requireAgentCall();
|
|
},
|
|
);
|
|
if (!call) {
|
|
throw new Error("expected agent command call");
|
|
}
|
|
return call;
|
|
};
|
|
|
|
const runBootAndReturnMessage = async (content: string): Promise<string> => {
|
|
const call = await runBootAndReturnCall({ content });
|
|
if (typeof call.message !== "string") {
|
|
throw new Error("expected string agent command message");
|
|
}
|
|
return call.message;
|
|
};
|
|
|
|
const expectSessionMapping = (params: {
|
|
storePath: string;
|
|
sessionKey: string;
|
|
expectedSessionId?: string;
|
|
}) => {
|
|
const restored = loadSessionEntry({
|
|
storePath: params.storePath,
|
|
sessionKey: params.sessionKey,
|
|
});
|
|
if (params.expectedSessionId === undefined) {
|
|
expect(restored).toBeUndefined();
|
|
return;
|
|
}
|
|
expect(restored?.sessionId).toBe(params.expectedSessionId);
|
|
};
|
|
|
|
it("skips when BOOT.md is missing", async () => {
|
|
await withBootWorkspace({}, async (workspaceDir) => {
|
|
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
|
status: "skipped",
|
|
reason: "missing",
|
|
});
|
|
expect(agentCommand).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("returns failed when BOOT.md cannot be read", async () => {
|
|
await withBootWorkspace({ bootAsDirectory: true }, async (workspaceDir) => {
|
|
const result = await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
|
|
expect(result.status).toBe("failed");
|
|
if (result.status === "failed") {
|
|
expect(result.reason.length).toBeGreaterThan(0);
|
|
}
|
|
expect(agentCommand).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
it.each([
|
|
{ title: "empty", content: " \n", reason: "empty" as const },
|
|
{ title: "whitespace-only", content: "\n\t ", reason: "empty" as const },
|
|
])("skips when BOOT.md is $title", async ({ content, reason }) => {
|
|
await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
|
|
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
|
status: "skipped",
|
|
reason,
|
|
});
|
|
expect(agentCommand).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("runs agent command when BOOT.md exists", async () => {
|
|
const content = "Say hello when you wake up.";
|
|
const call = await runBootAndReturnCall({ content });
|
|
expect(call.deliver).toBe(false);
|
|
expect(call.sessionKey).toBe("agent:main:boot");
|
|
expect(call.suppressPromptPersistence).toBe(true);
|
|
expect(call.message).toContain("BOOT.md:");
|
|
expect(call.message).toContain(content);
|
|
expect(call.message).toContain("NO_REPLY");
|
|
});
|
|
|
|
it("wraps BOOT.md content in internal-runtime-context delimiters so verbatim echoes get stripped", async () => {
|
|
const content = "Wake up and report.";
|
|
const message = await runBootAndReturnMessage(content);
|
|
// The boot prompt embeds BOOT.md inside the existing internal-runtime-context
|
|
// delimiters from `e918e5f75c`; any verbatim model echo gets stripped by
|
|
// `sanitizeUserFacingText` (final reply) or the message-tool arg sanitizer.
|
|
// Regression for #53732.
|
|
expect(message).toContain("<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>");
|
|
expect(message).toContain("<<<END_OPENCLAW_INTERNAL_CONTEXT>>>");
|
|
expect(message).toContain(
|
|
"This context is runtime-generated, not user-authored. Keep internal details private.",
|
|
);
|
|
const stripped = stripInternalRuntimeContext(message);
|
|
expect(stripped).not.toContain(content);
|
|
expect(stripped).not.toContain("BOOT.md:");
|
|
});
|
|
|
|
it("registers the boot prompt with the echo guard during the run and clears it afterward", async () => {
|
|
resetBootEchoContextForTests();
|
|
const sessionKeyHolder: { value?: string } = {};
|
|
const content =
|
|
"When you wake up each morning, send a thoughtful greeting to the operator and report the active project status.";
|
|
await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
|
|
agentCommand.mockImplementationOnce(async (opts: { sessionKey: string }) => {
|
|
sessionKeyHolder.value = opts.sessionKey;
|
|
// While the agent run is in flight, the echo guard should know about
|
|
// the boot prompt for this session so the message tool can suppress
|
|
// substantial echoes.
|
|
expect(getBootEchoContextForSession(opts.sessionKey)).toContain(content);
|
|
});
|
|
await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
|
|
});
|
|
// After the run completes, the entry must be cleared so it does not
|
|
// contaminate a subsequent unrelated run on the same session key.
|
|
expect(getBootEchoContextForSession(sessionKeyHolder.value)).toBeUndefined();
|
|
});
|
|
|
|
it("clears the echo-guard entry even when the agent run throws", async () => {
|
|
resetBootEchoContextForTests();
|
|
let observedDuringRun: string | undefined;
|
|
let observedSessionKey: string | undefined;
|
|
await withBootWorkspace({ bootContent: "Wake up and report." }, async (workspaceDir) => {
|
|
agentCommand.mockImplementationOnce(async (opts: { sessionKey: string }) => {
|
|
observedSessionKey = opts.sessionKey;
|
|
observedDuringRun = getBootEchoContextForSession(opts.sessionKey);
|
|
throw new Error("simulated agent failure");
|
|
});
|
|
await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
|
|
});
|
|
expect(observedDuringRun).toBeDefined();
|
|
expect(getBootEchoContextForSession(observedSessionKey)).toBeUndefined();
|
|
});
|
|
|
|
it("escapes literal internal-runtime-context delimiters in user-supplied BOOT.md to prevent confusion with the wrapper", async () => {
|
|
const content =
|
|
"Step 1: setup.\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nuser-authored\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nStep 2: done.";
|
|
const message = await runBootAndReturnMessage(content);
|
|
// Real markers should appear exactly once each (the outer wrapper); user-supplied
|
|
// BOOT.md instances of the same string are escaped to bracketed-safe variants.
|
|
expect((message.match(/<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>/g) ?? []).length).toBe(1);
|
|
expect((message.match(/<<<END_OPENCLAW_INTERNAL_CONTEXT>>>/g) ?? []).length).toBe(1);
|
|
expect(message).toContain("[[OPENCLAW_INTERNAL_CONTEXT_BEGIN]]");
|
|
expect(message).toContain("[[OPENCLAW_INTERNAL_CONTEXT_END]]");
|
|
});
|
|
|
|
it("returns failed when agent command throws", async () => {
|
|
await withBootWorkspace({ bootContent: "Wake up and report." }, async (workspaceDir) => {
|
|
agentCommand.mockRejectedValue(new Error("boom"));
|
|
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
|
status: "failed",
|
|
reason: "agent run failed: boom",
|
|
});
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("uses per-agent session key when agentId is provided", async () => {
|
|
const cfg = {};
|
|
const agentId = "ops";
|
|
const call = await runBootAndReturnCall({ cfg, agentId });
|
|
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
|
expect(call.sessionKey).toBe(`agent:${resolveAgentIdFromSessionKey(mainSessionKey)}:boot`);
|
|
});
|
|
|
|
it("keeps boot session isolation when the main session key is configured", async () => {
|
|
const cfg = { session: { mainKey: "primary" } };
|
|
const agentId = "ops";
|
|
const call = await runBootAndReturnCall({ cfg, agentId });
|
|
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
|
expect(mainSessionKey).toBe("agent:ops:primary");
|
|
expect(call.sessionKey).toBe("agent:ops:boot");
|
|
});
|
|
|
|
it("generates new session ID when no existing session exists", async () => {
|
|
const content = "Say hello when you wake up.";
|
|
await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
|
|
agentCommand.mockResolvedValue(undefined);
|
|
const cfg = {};
|
|
await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
|
status: "ran",
|
|
});
|
|
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
const call = requireAgentCall();
|
|
|
|
// Verify a boot-style session ID was generated (format: boot-YYYY-MM-DD_HH-MM-SS-xxx-xxxxxxxx)
|
|
expect(call.sessionId).toMatch(
|
|
/^boot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}$/,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("uses a fresh boot session ID even when main session mapping already exists", async () => {
|
|
const content = "Say hello when you wake up.";
|
|
await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
|
|
const cfg = {};
|
|
const { bootSessionKey, sessionKey, storePath } = resolveMainStore(cfg);
|
|
const existingSessionId = "main-session-abc123";
|
|
|
|
await replaceSessionEntry(
|
|
{ storePath, sessionKey },
|
|
{
|
|
sessionId: existingSessionId,
|
|
updatedAt: Date.now(),
|
|
},
|
|
);
|
|
|
|
agentCommand.mockResolvedValue(undefined);
|
|
await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
|
status: "ran",
|
|
});
|
|
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
const call = requireAgentCall();
|
|
|
|
expect(call.sessionId).not.toBe(existingSessionId);
|
|
expect(call.sessionId).toMatch(
|
|
/^boot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}$/,
|
|
);
|
|
expect(call.sessionKey).toBe(bootSessionKey);
|
|
expectSessionMapping({ storePath, sessionKey, expectedSessionId: existingSessionId });
|
|
});
|
|
});
|
|
|
|
it("does not mutate the original main session mapping after the boot run", async () => {
|
|
const content = "Check if the system is healthy.";
|
|
await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
|
|
const cfg = {};
|
|
const { bootSessionKey, sessionKey, storePath } = resolveMainStore(cfg);
|
|
const existingSessionId = "main-session-xyz789";
|
|
|
|
await replaceSessionEntry(
|
|
{ storePath, sessionKey },
|
|
{
|
|
sessionId: existingSessionId,
|
|
updatedAt: Date.now() - 60_000, // 1 minute ago
|
|
},
|
|
);
|
|
|
|
mockAgentUpdatesRequestedSession(storePath);
|
|
await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
|
status: "ran",
|
|
});
|
|
|
|
expectSessionMapping({ storePath, sessionKey, expectedSessionId: existingSessionId });
|
|
expectSessionMapping({ storePath, sessionKey: bootSessionKey });
|
|
});
|
|
});
|
|
|
|
it("removes a boot-created boot-session mapping when none existed before", async () => {
|
|
await withBootWorkspace({ bootContent: "health check" }, async (workspaceDir) => {
|
|
const cfg = {};
|
|
const { bootSessionKey, sessionKey, storePath } = resolveMainStore(cfg);
|
|
|
|
mockAgentUpdatesRequestedSession(storePath);
|
|
|
|
await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
|
status: "ran",
|
|
});
|
|
|
|
expectSessionMapping({ storePath, sessionKey });
|
|
expectSessionMapping({ storePath, sessionKey: bootSessionKey });
|
|
});
|
|
});
|
|
});
|