mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 17:11:37 +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>
453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
import { redactTranscriptMessage } from "../agents/transcript-redact.js";
|
|
import {
|
|
appendTranscriptMessage,
|
|
loadSessionEntry,
|
|
loadTranscriptEvents,
|
|
publishTranscriptUpdate,
|
|
readLatestTranscriptAssistantText,
|
|
resolveSessionTranscriptRuntimeReadTarget,
|
|
resolveSessionTranscriptRuntimeTarget,
|
|
withTranscriptWriteLock,
|
|
type TranscriptMessageAppendOptions,
|
|
type TranscriptMessageAppendResult,
|
|
type TranscriptUpdatePayload,
|
|
} from "../config/sessions/session-accessor.js";
|
|
import { resolveMirroredTranscriptText } from "../config/sessions/transcript-mirror.js";
|
|
import {
|
|
selectVisibleTranscriptEventEntries,
|
|
selectVisibleTranscriptEvents,
|
|
} from "../config/sessions/transcript-visible-events.js";
|
|
import type {
|
|
LatestAssistantTranscriptText,
|
|
SessionTranscriptAppendResult,
|
|
SessionTranscriptAssistantMessage,
|
|
SessionTranscriptDeliveryMirror,
|
|
SessionTranscriptUpdateMode,
|
|
} from "../config/sessions/transcript.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { normalizeAgentId } from "../routing/session-key.js";
|
|
import { extractAssistantVisibleText } from "../shared/chat-message-content.js";
|
|
import type { AgentMessage } from "./agent-core.js";
|
|
import {
|
|
formatSessionTranscriptMemoryHitKey,
|
|
parseSessionTranscriptMemoryHitKey,
|
|
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
|
|
type ResolveSessionTranscriptMemoryHitKeyParams,
|
|
type SessionTranscriptIdentity,
|
|
type SessionTranscriptMemoryHitIdentity,
|
|
type SessionTranscriptMemoryHitKey,
|
|
type SessionTranscriptMemoryHitKeyParams,
|
|
type SessionTranscriptReadParams,
|
|
} from "./session-transcript-memory-hit.js";
|
|
|
|
export {
|
|
formatSessionTranscriptMemoryHitKey,
|
|
parseSessionTranscriptMemoryHitKey,
|
|
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
|
|
};
|
|
export type {
|
|
ResolveSessionTranscriptMemoryHitKeyParams,
|
|
SessionTranscriptIdentity,
|
|
SessionTranscriptMemoryHitIdentity,
|
|
SessionTranscriptMemoryHitKey,
|
|
SessionTranscriptMemoryHitKeyParams,
|
|
SessionTranscriptReadParams,
|
|
};
|
|
|
|
export type SessionTranscriptEvent = unknown;
|
|
|
|
export type SessionTranscriptTargetParams = SessionTranscriptReadParams;
|
|
|
|
export type SessionTranscriptMessageEntry = {
|
|
/** Stable transcript event id for this message entry. */
|
|
entryId: string;
|
|
/** Parent id after active-branch normalization; null when this is a visible root. */
|
|
parentId: string | null;
|
|
/** Ordered read metadata for this full transcript read, not a resumable cursor. */
|
|
seq: number;
|
|
/** Redacted agent message payload as persisted by the runtime. */
|
|
message: AgentMessage;
|
|
/** Convenience mirror of message.role. */
|
|
role: AgentMessage["role"];
|
|
/** Entry timestamp recorded by the transcript store, when present. */
|
|
createdAt?: string;
|
|
/** Message idempotency key, when the persisted message has one. */
|
|
idempotencyKey?: string;
|
|
};
|
|
|
|
export type SessionTranscriptTarget = SessionTranscriptIdentity & {
|
|
targetKind: "runtime-session";
|
|
};
|
|
|
|
export type SessionTranscriptAppendMessageParams<TMessage> = SessionTranscriptTargetParams &
|
|
TranscriptMessageAppendOptions<TMessage>;
|
|
|
|
export type SessionTranscriptAssistantMirrorAppendParams = SessionTranscriptReadParams & {
|
|
config?: OpenClawConfig;
|
|
deliveryMirror?: SessionTranscriptDeliveryMirror;
|
|
idempotencyKey?: string;
|
|
mediaUrls?: string[];
|
|
text?: string;
|
|
updateMode?: SessionTranscriptUpdateMode;
|
|
};
|
|
|
|
export type SessionTranscriptWriteLockParams = SessionTranscriptTargetParams & {
|
|
config?: TranscriptMessageAppendOptions<unknown>["config"];
|
|
};
|
|
|
|
export type SessionTranscriptWriteLockContext = {
|
|
appendMessage: <TMessage>(
|
|
options: Omit<TranscriptMessageAppendOptions<TMessage>, "config">,
|
|
) => Promise<TranscriptMessageAppendResult<TMessage> | undefined>;
|
|
publishUpdate: (update?: TranscriptUpdatePayload) => Promise<void>;
|
|
readEvents: () => Promise<SessionTranscriptEvent[]>;
|
|
target: SessionTranscriptTarget;
|
|
};
|
|
|
|
type SessionTranscriptMirrorAppendResult =
|
|
| { ok: true; messageId: string }
|
|
| Extract<SessionTranscriptAppendResult, { ok: false }>;
|
|
|
|
/**
|
|
* Resolves the public identity for a transcript without returning its file path.
|
|
*/
|
|
export async function resolveSessionTranscriptIdentity(
|
|
params: SessionTranscriptReadParams,
|
|
): Promise<SessionTranscriptIdentity> {
|
|
const target = await resolveSessionTranscriptRuntimeReadTarget(params);
|
|
const agentId = normalizeAgentId(target.agentId);
|
|
return {
|
|
agentId,
|
|
memoryKey: formatSessionTranscriptMemoryHitKey({ agentId, sessionId: target.sessionId }),
|
|
sessionId: target.sessionId,
|
|
sessionKey: target.sessionKey,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolves the public target for transcript operations without exposing the
|
|
* current storage path as identity.
|
|
*/
|
|
export async function resolveSessionTranscriptTarget(
|
|
params: SessionTranscriptTargetParams,
|
|
): Promise<SessionTranscriptTarget> {
|
|
const target = await resolveSessionTranscriptRuntimeReadTarget(params);
|
|
return projectPublicTarget({
|
|
...target,
|
|
targetKind: "runtime-session",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Reads transcript events by public session identity instead of file path.
|
|
*/
|
|
export async function readSessionTranscriptEvents(
|
|
params: SessionTranscriptTargetParams,
|
|
): Promise<SessionTranscriptEvent[]> {
|
|
return await loadTranscriptEvents(params);
|
|
}
|
|
|
|
/**
|
|
* Reads visible transcript message entries by scoped identity.
|
|
*
|
|
* This is a branch-safe message projection over the current full transcript
|
|
* read. `seq` is ordered read metadata, not a resumable cursor.
|
|
*/
|
|
export async function readVisibleSessionTranscriptMessageEntries(
|
|
params: SessionTranscriptTargetParams,
|
|
): Promise<SessionTranscriptMessageEntry[]> {
|
|
return selectVisibleTranscriptEventEntries(await loadTranscriptEvents(params)).flatMap(
|
|
projectVisibleMessageEntry,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reads the latest visible assistant text by scoped identity.
|
|
*/
|
|
export async function readLatestAssistantTextByIdentity(
|
|
params: SessionTranscriptTargetParams,
|
|
): Promise<LatestAssistantTranscriptText | undefined> {
|
|
return readLatestTranscriptAssistantText(params);
|
|
}
|
|
|
|
/**
|
|
* Appends a delivery-mirror assistant message through the SQLite transcript accessor.
|
|
*/
|
|
export async function appendAssistantMirrorMessageByIdentity(
|
|
params: SessionTranscriptAssistantMirrorAppendParams,
|
|
): Promise<SessionTranscriptMirrorAppendResult> {
|
|
const text = resolveMirroredTranscriptText({
|
|
...(params.mediaUrls !== undefined ? { mediaUrls: params.mediaUrls } : {}),
|
|
...(params.text !== undefined ? { text: params.text } : {}),
|
|
});
|
|
if (!text) {
|
|
return { ok: false, reason: "empty message" };
|
|
}
|
|
const message = createAssistantMirrorMessage({
|
|
...(params.deliveryMirror !== undefined ? { deliveryMirror: params.deliveryMirror } : {}),
|
|
...(params.idempotencyKey !== undefined ? { idempotencyKey: params.idempotencyKey } : {}),
|
|
text,
|
|
});
|
|
return await withTranscriptWriteLock(params, async (locked) => {
|
|
const currentEntry = loadSessionEntry(params);
|
|
if (!currentEntry?.sessionId) {
|
|
return { ok: false, reason: "missing active session", code: "blocked" };
|
|
}
|
|
if (params.sessionId && currentEntry.sessionId !== params.sessionId) {
|
|
return { ok: false, reason: "session changed", code: "session-rebound" };
|
|
}
|
|
const scope = {
|
|
...params,
|
|
sessionId: currentEntry.sessionId,
|
|
};
|
|
const target = await resolveSessionTranscriptRuntimeReadTarget(scope);
|
|
const latestEquivalentAssistantId =
|
|
!params.idempotencyKey && isDeliveryMirrorAssistantMessage(message)
|
|
? findLatestEquivalentAssistantMessageId(
|
|
selectVisibleTranscriptEvents(await locked.readEvents()),
|
|
message,
|
|
params.config,
|
|
)
|
|
: undefined;
|
|
if (latestEquivalentAssistantId) {
|
|
return {
|
|
ok: true,
|
|
messageId: latestEquivalentAssistantId,
|
|
};
|
|
}
|
|
const appendResult = await locked.appendMessage({
|
|
...(params.config !== undefined ? { config: params.config } : {}),
|
|
...(params.idempotencyKey ? { idempotencyLookup: "scan" as const } : {}),
|
|
message,
|
|
});
|
|
if (!appendResult) {
|
|
return { ok: false, reason: "message skipped", code: "blocked" };
|
|
}
|
|
if (params.updateMode !== "none" && appendResult.appended) {
|
|
await publishTranscriptUpdate(scope, {
|
|
agentId: target.agentId,
|
|
messageId: appendResult.messageId,
|
|
sessionKey: target.sessionKey,
|
|
target: {
|
|
agentId: target.agentId,
|
|
sessionId: target.sessionId,
|
|
sessionKey: target.sessionKey,
|
|
},
|
|
});
|
|
}
|
|
return {
|
|
ok: true,
|
|
messageId: appendResult.messageId,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Appends a transcript message by scoped transcript target.
|
|
*/
|
|
export async function appendSessionTranscriptMessageByIdentity<TMessage>(
|
|
params: SessionTranscriptAppendMessageParams<TMessage>,
|
|
): Promise<TranscriptMessageAppendResult<TMessage> | undefined> {
|
|
return await appendTranscriptMessage(params, params);
|
|
}
|
|
|
|
/**
|
|
* Publishes a transcript update by scoped transcript target.
|
|
*/
|
|
export async function publishSessionTranscriptUpdateByIdentity(
|
|
params: SessionTranscriptTargetParams & { update?: TranscriptUpdatePayload },
|
|
): Promise<void> {
|
|
const target = await resolveSessionTranscriptRuntimeTarget(params);
|
|
await publishTranscriptUpdate(
|
|
{
|
|
...params,
|
|
sessionId: target.sessionId,
|
|
sessionKey: target.sessionKey,
|
|
},
|
|
{
|
|
...params.update,
|
|
agentId: target.agentId,
|
|
sessionKey: target.sessionKey,
|
|
target: {
|
|
agentId: target.agentId,
|
|
sessionId: target.sessionId,
|
|
sessionKey: target.sessionKey,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Runs transcript work under the write lock for the resolved scoped target.
|
|
*/
|
|
export async function withSessionTranscriptWriteLock<T>(
|
|
params: SessionTranscriptWriteLockParams,
|
|
run: (context: SessionTranscriptWriteLockContext) => Promise<T> | T,
|
|
): Promise<T> {
|
|
const storageTarget = await resolveSessionTranscriptRuntimeTarget(params);
|
|
const target = projectPublicTarget({
|
|
...storageTarget,
|
|
targetKind: "runtime-session",
|
|
});
|
|
const boundScope = {
|
|
...params,
|
|
sessionId: storageTarget.sessionId,
|
|
sessionKey: storageTarget.sessionKey,
|
|
};
|
|
// Treat publishUpdate as a post-commit callback: future transactional stores
|
|
// must not expose updates when the scoped write callback fails.
|
|
const queuedUpdates: Array<TranscriptUpdatePayload | undefined> = [];
|
|
const result = await withTranscriptWriteLock(
|
|
boundScope,
|
|
async (locked) =>
|
|
await run({
|
|
target,
|
|
readEvents: locked.readEvents,
|
|
appendMessage: (options) =>
|
|
locked.appendMessage({
|
|
...options,
|
|
...(params.config !== undefined ? { config: params.config } : {}),
|
|
}),
|
|
publishUpdate: async (update) => {
|
|
queuedUpdates.push(update ? { ...update } : undefined);
|
|
},
|
|
}),
|
|
);
|
|
for (const update of queuedUpdates) {
|
|
await publishSessionTranscriptUpdateByIdentity({
|
|
...boundScope,
|
|
update,
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function createAssistantMirrorMessage(params: {
|
|
deliveryMirror?: SessionTranscriptDeliveryMirror;
|
|
idempotencyKey?: string;
|
|
text: string;
|
|
}): SessionTranscriptAssistantMessage {
|
|
return {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: params.text }],
|
|
api: "openai-responses",
|
|
provider: "openclaw",
|
|
model: "delivery-mirror",
|
|
usage: {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
},
|
|
stopReason: "stop",
|
|
timestamp: Date.now(),
|
|
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
|
|
...(params.deliveryMirror ? { openclawDeliveryMirror: params.deliveryMirror } : {}),
|
|
};
|
|
}
|
|
|
|
function findLatestEquivalentAssistantMessageId(
|
|
events: readonly SessionTranscriptEvent[],
|
|
message: SessionTranscriptAssistantMessage,
|
|
config: OpenClawConfig | undefined,
|
|
): string | undefined {
|
|
const expectedText = extractAssistantMirrorComparableText(message, config);
|
|
if (!expectedText) {
|
|
return undefined;
|
|
}
|
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
const event = events[index];
|
|
if (!event || typeof event !== "object") {
|
|
continue;
|
|
}
|
|
const record = event as { id?: unknown; message?: unknown };
|
|
const candidate = record.message as SessionTranscriptAssistantMessage | undefined;
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
if (candidate.role !== "assistant") {
|
|
return undefined;
|
|
}
|
|
return extractAssistantMirrorComparableText(candidate, config) === expectedText &&
|
|
typeof record.id === "string" &&
|
|
record.id
|
|
? record.id
|
|
: undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function extractAssistantMirrorComparableText(
|
|
message: SessionTranscriptAssistantMessage,
|
|
config: OpenClawConfig | undefined,
|
|
): string | undefined {
|
|
const redacted = redactTranscriptMessage(
|
|
message as Parameters<typeof redactTranscriptMessage>[0],
|
|
config,
|
|
) as SessionTranscriptAssistantMessage;
|
|
return extractAssistantVisibleText(redacted)?.trim() || undefined;
|
|
}
|
|
|
|
function isDeliveryMirrorAssistantMessage(message: SessionTranscriptAssistantMessage): boolean {
|
|
return message.provider === "openclaw" && message.model === "delivery-mirror";
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function readNonEmptyString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
}
|
|
|
|
function isAgentMessageRecord(value: unknown): value is AgentMessage & Record<string, unknown> {
|
|
return isRecord(value) && readNonEmptyString(value.role) !== undefined;
|
|
}
|
|
|
|
function projectVisibleMessageEntry(entry: {
|
|
event: SessionTranscriptEvent;
|
|
parentId: string | null;
|
|
seq: number;
|
|
}): SessionTranscriptMessageEntry[] {
|
|
const event = entry.event;
|
|
if (!isRecord(event) || event.type !== "message") {
|
|
return [];
|
|
}
|
|
const entryId = readNonEmptyString(event.id);
|
|
const message = event.message;
|
|
if (!entryId || !isAgentMessageRecord(message)) {
|
|
return [];
|
|
}
|
|
const createdAt = readNonEmptyString(event.timestamp);
|
|
const idempotencyKey = readNonEmptyString(message.idempotencyKey);
|
|
return [
|
|
{
|
|
entryId,
|
|
parentId: entry.parentId,
|
|
seq: entry.seq,
|
|
message,
|
|
role: message.role,
|
|
...(createdAt ? { createdAt } : {}),
|
|
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
},
|
|
];
|
|
}
|
|
|
|
function projectPublicTarget(target: {
|
|
agentId: string;
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
targetKind: SessionTranscriptTarget["targetKind"];
|
|
}): SessionTranscriptTarget {
|
|
const agentId = normalizeAgentId(target.agentId);
|
|
return {
|
|
agentId,
|
|
memoryKey: formatSessionTranscriptMemoryHitKey({ agentId, sessionId: target.sessionId }),
|
|
sessionId: target.sessionId,
|
|
sessionKey: target.sessionKey,
|
|
targetKind: target.targetKind,
|
|
};
|
|
}
|