mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 00:41:35 +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>
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
// Codex tests cover SQLite-only trajectory plugin behavior.
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
|
import {
|
|
appendSqliteTrajectoryRuntimeEvents,
|
|
loadSqliteTrajectoryRuntimeEvents,
|
|
type SqliteTrajectoryRuntimeEventForTest,
|
|
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
type CodexHostTrajectoryRecorder,
|
|
createCodexTrajectoryRecorder,
|
|
recordCodexTrajectoryCompletion,
|
|
recordCodexTrajectoryContext,
|
|
} from "./trajectory.js";
|
|
|
|
type CodexTrajectoryRecorder = NonNullable<ReturnType<typeof createCodexTrajectoryRecorder>>;
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
function makeTempDir(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-trajectory-"));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function expectTrajectoryRecorder(
|
|
recorder: ReturnType<typeof createCodexTrajectoryRecorder>,
|
|
): CodexTrajectoryRecorder {
|
|
if (recorder === null) {
|
|
throw new Error("Expected Codex trajectory recorder");
|
|
}
|
|
return recorder;
|
|
}
|
|
|
|
function createMemoryHostTrajectoryRecorder(): {
|
|
events: Array<{ type: string; data?: Record<string, unknown> }>;
|
|
recorder: CodexHostTrajectoryRecorder;
|
|
} {
|
|
const events: Array<{ type: string; data?: Record<string, unknown> }> = [];
|
|
return {
|
|
events,
|
|
recorder: {
|
|
recordEvent: (type, data) => events.push({ type, data }),
|
|
flush: async () => undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createMemoryBackedRecorder(params: {
|
|
tmpDir: string;
|
|
attempt?: Record<string, unknown>;
|
|
tools?: Parameters<typeof createCodexTrajectoryRecorder>[0]["tools"];
|
|
}): {
|
|
events: Array<{ type: string; data?: Record<string, unknown> }>;
|
|
recorder: CodexTrajectoryRecorder;
|
|
} {
|
|
const sessionId = (params.attempt?.sessionId as string | undefined) ?? "session-1";
|
|
const host = createMemoryHostTrajectoryRecorder();
|
|
const recorder = createCodexTrajectoryRecorder({
|
|
cwd: params.tmpDir,
|
|
attempt: {
|
|
sessionFile: path.join(params.tmpDir, "session.jsonl"),
|
|
sessionId,
|
|
sessionKey: `agent:main:${sessionId}`,
|
|
runId: "run-1",
|
|
provider: "codex",
|
|
modelId: "gpt-5.4",
|
|
model: { api: "responses" },
|
|
...params.attempt,
|
|
} as never,
|
|
trajectoryRecorder: host.recorder,
|
|
trajectorySessionFile: `sqlite:main:${sessionId}:${path.join(params.tmpDir, "sessions.json")}`,
|
|
tools: params.tools,
|
|
env: {},
|
|
});
|
|
return { events: host.events, recorder: expectTrajectoryRecorder(recorder) };
|
|
}
|
|
|
|
function createSqliteHostTrajectoryRecorder(params: {
|
|
agentId: string;
|
|
sessionId: string;
|
|
storePath: string;
|
|
}): CodexHostTrajectoryRecorder {
|
|
const events: SqliteTrajectoryRuntimeEventForTest[] = [];
|
|
let seq = 0;
|
|
return {
|
|
recordEvent: (type, data) => {
|
|
events.push({
|
|
traceSchema: "openclaw-trajectory",
|
|
schemaVersion: 1,
|
|
traceId: `${params.sessionId}:test`,
|
|
source: "runtime",
|
|
type,
|
|
ts: new Date(0).toISOString(),
|
|
seq,
|
|
sessionId: params.sessionId,
|
|
...(data === undefined ? {} : { data }),
|
|
});
|
|
seq += 1;
|
|
},
|
|
flush: async () => {
|
|
appendSqliteTrajectoryRuntimeEvents(params, events);
|
|
events.length = 0;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("Codex trajectory recorder", () => {
|
|
it("rejects file-backed trajectory targets without creating sidecars", () => {
|
|
const tmpDir = makeTempDir();
|
|
const warn = vi.fn();
|
|
const recorder = createCodexTrajectoryRecorder({
|
|
cwd: tmpDir,
|
|
attempt: {
|
|
sessionFile: path.join(tmpDir, "session.jsonl"),
|
|
sessionId: "session-1",
|
|
model: { api: "responses" },
|
|
} as never,
|
|
env: {},
|
|
warn,
|
|
});
|
|
|
|
expect(recorder).toBeNull();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"codex trajectory capture requires a matching SQLite session target",
|
|
{ sessionId: "session-1", reason: "non-sqlite-session-target" },
|
|
);
|
|
expect(fs.existsSync(path.join(tmpDir, "session.trajectory.jsonl"))).toBe(false);
|
|
expect(fs.existsSync(path.join(tmpDir, "session.trajectory-path.json"))).toBe(false);
|
|
});
|
|
|
|
it("rejects a SQLite marker for a different session identity", () => {
|
|
const tmpDir = makeTempDir();
|
|
const warn = vi.fn();
|
|
const recorder = createCodexTrajectoryRecorder({
|
|
cwd: tmpDir,
|
|
attempt: {
|
|
sessionFile: "sqlite:main:other:/tmp/openclaw-agent.sqlite",
|
|
sessionId: "session-1",
|
|
model: { api: "responses" },
|
|
} as never,
|
|
trajectoryRecorder: createMemoryHostTrajectoryRecorder().recorder,
|
|
env: {},
|
|
warn,
|
|
});
|
|
|
|
expect(recorder).toBeNull();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"codex trajectory capture requires a matching SQLite session target",
|
|
{ sessionId: "session-1", reason: "session-id-mismatch" },
|
|
);
|
|
});
|
|
|
|
it("warns when the SQLite host recorder is unavailable", () => {
|
|
const warn = vi.fn();
|
|
const recorder = createCodexTrajectoryRecorder({
|
|
cwd: makeTempDir(),
|
|
attempt: {
|
|
sessionFile: "sqlite:main:session-1:/tmp/openclaw-agent.sqlite",
|
|
sessionId: "session-1",
|
|
model: { api: "responses" },
|
|
} as never,
|
|
env: {},
|
|
warn,
|
|
});
|
|
|
|
expect(recorder).toBeNull();
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"codex trajectory capture requires the SQLite host recorder",
|
|
{ sessionId: "session-1", reason: "sqlite-recorder-unavailable" },
|
|
);
|
|
});
|
|
|
|
it("stores SQLite-backed trajectory captures in the session database", async () => {
|
|
const tmpDir = makeTempDir();
|
|
const storePath = path.join(tmpDir, "sessions", "sessions.json");
|
|
const trajectorySessionFile = `sqlite:main:session-1:${storePath}`;
|
|
await upsertSessionEntry({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:session-1",
|
|
storePath,
|
|
entry: { sessionId: "session-1", sessionFile: trajectorySessionFile, updatedAt: 10 },
|
|
});
|
|
const recorder = createCodexTrajectoryRecorder({
|
|
cwd: tmpDir,
|
|
attempt: {
|
|
sessionFile: path.join(tmpDir, "sessions", "session.jsonl"),
|
|
sessionId: "session-1",
|
|
model: { api: "responses" },
|
|
} as never,
|
|
trajectoryRecorder: createSqliteHostTrajectoryRecorder({
|
|
agentId: "main",
|
|
sessionId: "session-1",
|
|
storePath,
|
|
}),
|
|
trajectorySessionFile,
|
|
env: {},
|
|
});
|
|
|
|
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
|
|
trajectoryRecorder.recordEvent("session.started");
|
|
await trajectoryRecorder.flush();
|
|
|
|
expect(fs.readdirSync(path.join(tmpDir, "sessions"))).not.toEqual(
|
|
expect.arrayContaining(["session.trajectory.jsonl", "session.trajectory-path.json"]),
|
|
);
|
|
await expect(
|
|
loadSqliteTrajectoryRuntimeEvents({ agentId: "main", sessionId: "session-1", storePath }),
|
|
).resolves.toEqual([expect.objectContaining({ type: "session.started" })]);
|
|
});
|
|
|
|
it("redacts secrets and keeps recorded strings UTF-16 safe", async () => {
|
|
const { events, recorder } = createMemoryBackedRecorder({ tmpDir: makeTempDir() });
|
|
recorder.recordEvent("model.output", {
|
|
text: `${"x".repeat(19_999)}😀`,
|
|
apiKey: "secret",
|
|
authorization: "Bearer sk-test-secret-token",
|
|
});
|
|
await recorder.flush();
|
|
|
|
expect(events[0]?.data?.text).toBe(`${"x".repeat(19_999)}…`);
|
|
expect(events[0]?.data?.apiKey).toBe("<redacted>");
|
|
expect(events[0]?.data?.authorization).toBe("<redacted>");
|
|
});
|
|
|
|
it("records namespace dynamic tools as callable trajectory definitions", async () => {
|
|
const tools = [
|
|
{
|
|
type: "namespace" as const,
|
|
name: "openclaw",
|
|
description: "",
|
|
tools: [
|
|
{
|
|
type: "function" as const,
|
|
name: "web_search",
|
|
description: "Search the web.",
|
|
inputSchema: { type: "object" },
|
|
deferLoading: true,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
const tmpDir = makeTempDir();
|
|
const init = createMemoryBackedRecorder({ tmpDir, tools });
|
|
|
|
recordCodexTrajectoryContext(init.recorder, { attempt: {} as never, cwd: tmpDir, tools });
|
|
await init.recorder.flush();
|
|
|
|
expect(init.events[0]?.data?.tools).toEqual([
|
|
{
|
|
name: "web_search",
|
|
description: "Search the web.",
|
|
parameters: { type: "object" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("honors explicit disablement without warning", () => {
|
|
const warn = vi.fn();
|
|
const recorder = createCodexTrajectoryRecorder({
|
|
cwd: makeTempDir(),
|
|
attempt: {
|
|
sessionFile: "sqlite:main:session-1:/tmp/openclaw-agent.sqlite",
|
|
sessionId: "session-1",
|
|
model: { api: "responses" },
|
|
} as never,
|
|
env: { OPENCLAW_TRAJECTORY: "0" },
|
|
warn,
|
|
});
|
|
|
|
expect(recorder).toBeNull();
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves usage when truncating oversized model completion events", async () => {
|
|
const attempt = {
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:session-1",
|
|
runId: "run-1",
|
|
provider: "codex",
|
|
modelId: "gpt-5.4",
|
|
model: { api: "responses" },
|
|
} as never;
|
|
const usage = {
|
|
input: 384_954,
|
|
output: 5_624,
|
|
cacheRead: 333_824,
|
|
reasoningTokens: 2_038,
|
|
total: 724_402,
|
|
};
|
|
const { events, recorder } = createMemoryBackedRecorder({
|
|
tmpDir: makeTempDir(),
|
|
attempt,
|
|
});
|
|
|
|
recordCodexTrajectoryCompletion(recorder, {
|
|
attempt,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
timedOut: false,
|
|
result: {
|
|
aborted: false,
|
|
attemptUsage: usage,
|
|
assistantTexts: ["done"],
|
|
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
|
|
role: index % 2 === 0 ? "user" : "assistant",
|
|
content: `message-${index} ${"x".repeat(32_000)}`,
|
|
})),
|
|
} as never,
|
|
});
|
|
await recorder.flush();
|
|
|
|
expect(events[0]?.data).toMatchObject({
|
|
truncated: true,
|
|
reason: "trajectory-event-size-limit",
|
|
usage,
|
|
});
|
|
expect(events[0]?.data?.messagesSnapshot).toBeUndefined();
|
|
expect(events[0]?.data?.droppedFields).toContain("messagesSnapshot");
|
|
});
|
|
});
|