Files
openclaw/extensions/copilot/src/dual-write-transcripts.ts
Josh Lehman 0a8e3604ba refactor: flip sessions and transcripts to sqlite storage (#98236)
* 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>
2026-07-11 14:50:37 -07:00

245 lines
9.0 KiB
TypeScript
Executable File

/**
* Mirrors the AgentMessages produced by the copilot agent runtime into the
* OpenClaw audit transcript that sits next to (but is distinct from) the
* SDK's own session storage.
*
* The OpenClaw shell (src/agents/command/attempt-execution.ts) already
* writes the user prompt and the terminal assistant text into the
* transcript at the end of each attempt. That is the bare minimum to
* keep `/history` working. It does NOT capture tool calls, tool
* results, or intermediate assistant turns — those live only in the
* SDK's own session file.
*
* For audit/compliance and for the codex-parity guarantees we promised
* in the proposal, we mirror the full `messagesSnapshot` (user +
* assistant + toolResult) into the OpenClaw transcript via the same
* plugin-sdk primitives that the codex extension uses
* (extensions/codex/src/app-server/transcript-mirror.ts). Both writers
* cooperate via idempotency-key dedupe: user entries retain a gateway
* turn key when present; other entries use `${idempotencyScope}:${identity}`.
* We skip any key already present in the transcript on disk. Both
* attempt-execution's untagged entries (no idempotencyKey) and our
* tagged mirror entries can coexist; attempt-execution dedupes its own
* final-assistant append via `embeddedAssistantGapFill` content match.
*
* Failures (lock contention, fs errors, etc.) are swallowed by the
* caller-side `dualWriteCopilotTranscriptBestEffort` wrapper used
* in attempt.ts so they cannot break the attempt; this module itself
* throws on infrastructure failure so callers can choose policy.
*/
import { createHash } from "node:crypto";
import {
runAgentHarnessBeforeMessageWriteHook,
type AgentMessage,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
withSessionTranscriptWriteLock,
type SessionTranscriptTargetParams,
type SessionTranscriptWriteLockContext,
type SessionTranscriptWriteLockParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const;
/**
* Tag a message with a stable logical identity for mirror dedupe.
* Callers should use a value that is invariant for the same logical
* message across re-emits (e.g. `${sdkSessionId}:assistant:${turnIndex}`)
* but distinct for genuinely-distinct messages. When present this
* identity replaces the role/content fingerprint in the idempotency
* key, so the dedupe survives caller-scope rotation without collapsing
* distinct same-content turns. Symmetric to
* `attachCodexMirrorIdentity` in the codex extension.
*/
export function attachCopilotMirrorIdentity<T extends AgentMessage>(
message: T,
identity: string,
): T {
const record = message as unknown as Record<string, unknown>;
const existing = record["__openclaw"];
const baseMeta =
existing && typeof existing === "object" && !Array.isArray(existing)
? (existing as Record<string, unknown>)
: {};
return {
...record,
__openclaw: { ...baseMeta, [MIRROR_IDENTITY_META_KEY]: identity },
} as unknown as T;
}
function readMirrorIdentity(message: MirroredAgentMessage): string | undefined {
const record = message as unknown as { __openclaw?: unknown };
const meta = record["__openclaw"];
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
return undefined;
}
const id = (meta as Record<string, unknown>)[MIRROR_IDENTITY_META_KEY];
return typeof id === "string" && id.length > 0 ? id : undefined;
}
function fingerprintMirrorMessageContent(message: MirroredAgentMessage): string {
const payload = JSON.stringify({ role: message.role, content: message.content });
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
}
function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
const explicit = readMirrorIdentity(message);
if (explicit) {
return explicit;
}
return `${message.role}:${fingerprintMirrorMessageContent(message)}`;
}
interface MirrorCopilotTranscriptParams {
sessionId: string;
sessionKey?: string;
agentId?: string;
storePath?: string;
messages: AgentMessage[];
/**
* Stable per-harness/per-thread scope. The codex equivalent uses
* `codex-app-server:${threadId}`; we use `copilot:${sessionId}`
* by convention (see attempt.ts call site). Keeping the scope
* thread-stable (not per-turn) is what lets a re-emitted prior-turn
* entry collide with its existing on-disk key and be a true no-op.
*/
idempotencyScope?: string;
config?: SessionTranscriptWriteLockParams["config"];
}
export async function mirrorCopilotTranscript(
params: MirrorCopilotTranscriptParams,
): Promise<void> {
const messages = params.messages.filter(
(message): message is MirroredAgentMessage =>
message.role === "user" || message.role === "assistant" || message.role === "toolResult",
);
if (messages.length === 0) {
return;
}
const transcriptTarget = resolveCopilotMirrorTranscriptTarget(params);
await withCopilotMirrorTranscriptWriteLock(
{ ...transcriptTarget, config: params.config },
async (transcript) => {
let didAppendMessage = false;
const existingIdempotencyKeys = readTranscriptIdempotencyKeys(await transcript.readEvents());
for (const message of messages) {
const dedupeIdentity = buildMirrorDedupeIdentity(message);
const sourceIdempotencyKey = (message as unknown as { idempotencyKey?: unknown })
.idempotencyKey;
const sourceUserIdempotencyKey =
message.role === "user" &&
typeof sourceIdempotencyKey === "string" &&
sourceIdempotencyKey.trim()
? sourceIdempotencyKey.trim()
: undefined;
const idempotencyKey =
sourceUserIdempotencyKey ??
(params.idempotencyScope ? `${params.idempotencyScope}:${dedupeIdentity}` : undefined);
if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) {
continue;
}
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as AgentMessage;
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!nextMessage) {
continue;
}
const messageToAppend = (
idempotencyKey
? {
...(nextMessage as unknown as Record<string, unknown>),
idempotencyKey,
}
: nextMessage
) as AgentMessage;
const appended = await transcript.appendMessage({
message: messageToAppend,
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
});
if (!appended) {
continue;
}
didAppendMessage = true;
if (idempotencyKey) {
existingIdempotencyKeys.add(idempotencyKey);
}
}
if (didAppendMessage) {
await transcript.publishUpdate(
params.sessionKey ? { sessionKey: params.sessionKey } : undefined,
);
}
return didAppendMessage;
},
);
}
function resolveCopilotMirrorTranscriptTarget(params: {
agentId?: string;
sessionId: string;
sessionKey?: string;
storePath?: string;
}): SessionTranscriptTargetParams {
const sessionKey = params.sessionKey?.trim();
const storePath = params.storePath?.trim();
if (!sessionKey || !storePath) {
throw new Error("Copilot transcript mirror requires a runtime session identity");
}
return {
...(params.agentId ? { agentId: params.agentId } : {}),
sessionId: params.sessionId,
sessionKey,
storePath,
};
}
function withCopilotMirrorTranscriptWriteLock<T>(
params: SessionTranscriptTargetParams & { config?: SessionTranscriptWriteLockParams["config"] },
run: (context: SessionTranscriptWriteLockContext) => Promise<T> | T,
): Promise<T> {
return withSessionTranscriptWriteLock(params, run);
}
function readTranscriptIdempotencyKeys(events: unknown[]): Set<string> {
const keys = new Set<string>();
for (const event of events) {
if (!event || typeof event !== "object" || Array.isArray(event)) {
continue;
}
const parsed = event as { message?: { idempotencyKey?: unknown } };
if (typeof parsed.message?.idempotencyKey === "string") {
keys.add(parsed.message.idempotencyKey);
}
}
return keys;
}
/**
* Caller-side wrapper that swallows mirror failures. attempt.ts uses
* this so that a transient transcript-mirror failure (lock contention,
* disk full, etc.) never breaks an otherwise-successful attempt. The
* SDK's own session file remains the source of truth in that case;
* the OpenClaw audit trail just misses the intermediate messages for
* this turn.
*/
export async function dualWriteCopilotTranscriptBestEffort(
params: MirrorCopilotTranscriptParams,
): Promise<void> {
try {
await mirrorCopilotTranscript(params);
} catch (error) {
console.warn("[copilot-attempt] dual-write transcript mirror failed", error);
}
}