openclaw/scripts/lib/sqlite-transcript-payload.mjs
Peter Steinberger 379ca86a74
fix(doctor): preserve current sessions after restored-index replay (#157615)
* fix(doctor): preserve current sessions after restored-index replay

Use recorded restore provenance at the Doctor import boundary so an
unchanged restored index reconciles history without replacing current
SQLite metadata. Revalidate source and receipt after staging and preserve
fresh-index imports; unreadable recovery evidence refuses before writes.

Keep config selection with the existing target-discovery owner without
changing its behavior. Preserve archive mutation coverage at its actual
publication boundary rather than an identity-read count.

Refs #156240.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>

* fix(doctor): admit fresh imports with unrelated unreadable history

Check the empty-destination requirement inside the existing SQLite import
transaction before any row writes. Use one disk-backed staged batch only
for this exceptional recovery path; retain normal 256-row batching.

Prove more than 256 fresh rows import and a destination occupied after
staging refuses atomically. Preserve current-state refusal and existing
source, authority, transcript and rollback checks.

* fix(doctor): bind restored indexes to their selected import owner

Reuse explicit restore target admission instead of cleanup discovery
eligibility. A shared original may have multiple owners; require the
selected owner's receipt and revalidate every matching manifest.

Preserve supported external custom stores without extending cleanup
ownership. Cover current metadata for both shared owners, missing selected
receipts, and external completed restore/reimport.

* test(update): qualify restored sessions through the published updater

* test(update): retain restored-index evidence in hosted diagnostics

* test(update): clear stale restored-index observations before capture

* chore: register the restored-index fixture executable

* fix(doctor): preserve verified sources during file-era repair

After pending plugin migrations cleared, legacy session normalization could rewrite an index still bound by a deferred-import receipt. Keep those originals with the existing receipt owner until verified archival completes, including configured SQLite bindings for a shared legacy root.

Extend the existing deferred-import cases through registered Doctor migration and preserve archival, current metadata, and deleted-session assertions. Move the unchanged index-reverification case into its retained-source suite and simplify the equivalent account-id trim guard to keep existing line caps.

The published 9.4 failure remains evidence: canonical metadata survived, but archive settlement did not. The corrected package still requires its installed upgrade/reimport/rollback cell.

* fix(doctor): retain the migration caller environment

Resolve configured legacy targets using the same environment already held by Doctor. Reconstructing process.env missed a durable receipt when a supplied home expanded the same custom store through a tilde path. Keep the detected state-directory override.

The existing custom-store case now proves that explicit environment through registered migration; it failed before this correction. All eight layout cases pass with archival and no-replay assertions unchanged.

* test(update): follow retained archive receipts after restore

The schema observer selected a completed move whose archive had already been consumed by official restore, hiding the later verified reimport receipt. Filter only each manifest's consumed moves while keeping owner, existence, content, session and schema assertions intact.

Move the existing schema suite into its own file and prove both restored/reimported success and refusal of consumed-only evidence. The installed first-hop and explicit reimport passed; the failed schema observation and unfinished rollback remain recorded.

* test(update): honor published backup transcript exclusions

* fix(e2e): stage the complete survivor diagnostics closure

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-09-24 23:43:23 -07:00

87 lines
2.9 KiB
JavaScript

import { createHash } from "node:crypto";
import zlib from "node:zlib";
// Release proofs decode the persisted contract independently of candidate runtime code.
const MAX_COMPRESSED_EVENT_BYTES = 4 * 1024 * 1024;
const payloadSchemas = new WeakMap();
const utf8Decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
function hasCompressedPayloads(database) {
if (!payloadSchemas.has(database)) {
payloadSchemas.set(
database,
Boolean(
database
.prepare("SELECT 1 FROM pragma_table_info('transcript_events') WHERE name = 'event_zstd'")
.get(),
),
);
}
return payloadSchemas.get(database);
}
/** Read published legacy fixtures and current candidate databases without migrating either. */
export function sqliteTranscriptPayloadColumns(database) {
return hasCompressedPayloads(database)
? "event_json, event_zstd, event_utf8_bytes"
: "event_json";
}
/** Bound materialization before decoding any compressed event. */
export function sqliteTranscriptPayloadBytesSql(database) {
return hasCompressedPayloads(database)
? "coalesce(event_utf8_bytes, octet_length(event_json))"
: "octet_length(event_json)";
}
/** Preserve exact JSON bytes for survivor, backup, and cold-restoration comparisons. */
export function readSqliteTranscriptPayload(row) {
if (typeof row.event_json === "string" && row.event_zstd == null) {
return row.event_json;
}
const bytes = row.event_zstd;
const expectedBytes = row.event_utf8_bytes;
if (
row.event_json !== null ||
!(bytes instanceof Uint8Array) ||
bytes.byteLength === 0 ||
bytes.byteLength > MAX_COMPRESSED_EVENT_BYTES ||
!Number.isSafeInteger(expectedBytes) ||
expectedBytes < 1 ||
expectedBytes > MAX_COMPRESSED_EVENT_BYTES
) {
throw new Error("Invalid persisted transcript payload");
}
if (typeof zlib.zstdDecompressSync !== "function") {
throw new Error("Transcript verification requires a runtime with Zstd support");
}
const decoded = zlib.zstdDecompressSync(bytes, { maxOutputLength: expectedBytes });
if (decoded.byteLength !== expectedBytes) {
throw new Error("Persisted transcript payload differs from its recorded UTF-8 size");
}
return utf8Decoder.decode(decoded);
}
export function transcriptIdentity(event) {
// Doctor repairs metadata; the fixture's text-only turn must retain event IDs and messages.
return {
type: event.type,
id: event.id,
...(event.type === "message"
? {
role: event.message.role,
textHash: createHash("sha256")
.update(
JSON.stringify(
typeof event.message.content === "string"
? [event.message.content]
: event.message.content
.filter((part) => part.type === "text")
.map((part) => part.text),
),
)
.digest("hex"),
}
: {}),
};
}