Files
openclaw/src/sessions/user-turn-transcript.media-normalize.ts
Peter Steinberger 367ff3cea1 feat(sqlite): migrate persisted media to canonical facts and stop legacy writes (#113695)
* feat(sqlite): migrate persisted media to canonical facts and stop legacy writes

PR 3 of the media legacy retirement program — the operator-approved
canonical cutover.

- openclaw doctor --fix owns one idempotent migration: active
  transcript_events rows canonicalize to __openclaw.media (facts-first
  gap-fill, bare legacy kinds to fact.kind, transcribed indexes and
  workspace dirs onto per-fact fields) via the transcript replacement
  owner; cold plain/.zst archives rewrite through temp-file + codec
  readback + event/id verification + atomic replace; trajectory
  runtime snapshots canonicalize IN PLACE (telemetry preserved, never
  row deletion). Invalid JSON, genuinely ambiguous legacy-only sparse
  alignment, or a changed source aborts that owner without partial work;
  reruns are no-ops.
- Per-agent schema advances to v16 as a pure downgrade guard (main
  independently took v15 for board/session-sharing tables; no
  columns/tables/indexes change here, shared-state DB untouched). v15
  databases repair canonical indexes before the version assertion so
  repairable installations never strand.
- The user-turn builder stops writing top-level legacy Media* fields;
  shouldPersistStructuredMediaEntries and the aligned projection mode
  are deleted; the generic transcript append boundary canonicalizes
  every message role so SDK/mirror writers cannot mint new legacy rows.
- Internal persisted-reader legacy fallbacks are removed; the public
  SDK projection stays until retirement PR 4's window expires.

Hardening from three adversarial review rounds, each with fixture
regressions: in-place trajectory canonicalization instead of row
deletion; repair-before-assert on the v15 path; all-roles append
canonicalization; duplicate-preserving exact row rewrites; v0-v15
reopen guards; complete canonical facts bypass compact legacy
projections (PR-1 dual-write rows migrate cleanly); SQLite LIKE
underscore escaped so populated foreign databases are never claimed.

* fix(sqlite): align schema-support metadata and gates with the v16 cutover

package.json agent schema support advances to 16; verifier and board
parity fixtures run doctor migration before steady-state access (the
production guards were correct); two test-only exports removed; the
migration module registered in the doctor raw-SQLite allowlist.
2026-07-25 07:15:44 -07:00

65 lines
2.5 KiB
TypeScript

import path from "node:path";
import { mimeTypeFromFilePath } from "@openclaw/media-core/mime";
import type { MediaFactInput } from "../media/media-facts.js";
import type { PersistedUserTurnMediaInput } from "./user-turn-transcript.types.js";
const URL_LIKE_MEDIA_PATH_PATTERN = /^[a-z][a-z0-9+.-]*:/i;
const STRUCTURED_MEDIA_KINDS = new Set<NonNullable<MediaFactInput["kind"]>>([
"image",
"audio",
"video",
"document",
"sticker",
"unknown",
]);
const MIME_TYPE_PATTERN = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/iu;
function normalizeOptionalText(value: string | null | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function normalizeStructuredMediaKind(value: string | null | undefined): MediaFactInput["kind"] {
const kind = normalizeOptionalText(value);
return kind && STRUCTURED_MEDIA_KINDS.has(kind as NonNullable<MediaFactInput["kind"]>)
? (kind as NonNullable<MediaFactInput["kind"]>)
: undefined;
}
export function resolveTranscriptMediaPath(
pathValue: string,
workspaceDir: string | undefined,
): string {
// Relative staged media paths are anchored to the media workspace; absolute
// paths and URL-like refs are already stable transcript references.
if (!workspaceDir || path.isAbsolute(pathValue) || URL_LIKE_MEDIA_PATH_PATTERN.test(pathValue)) {
return pathValue;
}
return path.join(workspaceDir, pathValue);
}
export function normalizeStructuredMediaEntryForTranscript(
media: PersistedUserTurnMediaInput,
): MediaFactInput {
const mediaPath = normalizeOptionalText(media.path);
const mediaUrl = normalizeOptionalText(media.url);
const kind = normalizeStructuredMediaKind(media.kind);
const legacyKind = normalizeOptionalText(media.kind);
const messageId = normalizeOptionalText(media.messageId);
const workspaceDir = normalizeOptionalText(media.workspaceDir);
const contentType =
normalizeOptionalText(media.contentType) ??
(kind || !legacyKind || !MIME_TYPE_PATTERN.test(legacyKind) ? undefined : legacyKind) ??
mimeTypeFromFilePath(mediaPath ?? mediaUrl);
return {
...(mediaPath ? { path: mediaPath } : {}),
...(mediaUrl ? { url: mediaUrl } : {}),
...(contentType ? { contentType } : {}),
...(kind ? { kind } : {}),
...(media.transcribed === true ? { transcribed: true } : {}),
...(messageId ? { messageId } : {}),
...(workspaceDir ? { workspaceDir } : {}),
...(media.hydrationSuppressed === true ? { hydrationSuppressed: true } : {}),
};
}