From 0bb2ed321c2c37904cb719b8f2f60e796422ca0f Mon Sep 17 00:00:00 2001 From: Wynne668 Date: Fri, 17 Jul 2026 12:03:52 +0800 Subject: [PATCH] fix(doctor): preserve Unicode in migration issue reports (#109592) * fix(doctor): preserve Unicode in migration issue reports * fix(doctor): preserve migration report Unicode boundaries * test(doctor): stabilize migration Unicode boundary fixture --------- Co-authored-by: Peter Steinberger --- .../doctor-session-sqlite-migration-run.ts | 12 ++-- src/commands/doctor-session-sqlite.test.ts | 65 +++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/commands/doctor-session-sqlite-migration-run.ts b/src/commands/doctor-session-sqlite-migration-run.ts index 7537eb8bc7ab..4400b9cbd45a 100644 --- a/src/commands/doctor-session-sqlite-migration-run.ts +++ b/src/commands/doctor-session-sqlite-migration-run.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { z } from "zod"; import { resolveStateDir } from "../config/paths.js"; import * as replaceFile from "../infra/replace-file.js"; @@ -459,14 +460,13 @@ export function createSessionSqliteMigrationFailureIssue( "OpenClaw doctor generated this sanitized report from a local session SQLite migration recovery.", "", reportBody, - ] - .join("\n") - .slice(0, 20_000); + ].join("\n"); + const boundedBody = truncateUtf16Safe(body, 20_000); return { - body, + body: boundedBody, ...(bodyPath ? { bodyPath } : {}), title, - url: createPrefilledGithubIssueUrl(title, body), + url: createPrefilledGithubIssueUrl(title, boundedBody), }; } @@ -862,7 +862,7 @@ function manifestSortTime(manifest: SessionSqliteMigrationManifest): number { function createPrefilledGithubIssueUrl(title: string, body: string): string { const urlBody = body.length > 6_000 - ? `${body.slice(0, 6_000)}\n\n...(truncated for URL; see local failure report for the full sanitized body)` + ? `${truncateUtf16Safe(body, 6_000)}\n\n...(truncated for URL; see local failure report for the full sanitized body)` : body; const params = new URLSearchParams({ body: urlBody, diff --git a/src/commands/doctor-session-sqlite.test.ts b/src/commands/doctor-session-sqlite.test.ts index ce1f5f29d21a..fddedcf3f7f1 100644 --- a/src/commands/doctor-session-sqlite.test.ts +++ b/src/commands/doctor-session-sqlite.test.ts @@ -24,6 +24,7 @@ import { import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { assertSafeSessionSqliteMigrationMove, + createSessionSqliteMigrationFailureIssue, restoreSessionSqliteMigrationRun, type ActiveSessionSqliteMigrationRun, } from "./doctor-session-sqlite-migration-run.js"; @@ -1562,6 +1563,70 @@ describe("runDoctorSessionSqlite", () => { expect(recover.supportIssue?.url).toContain("github.com/openclaw/openclaw/issues/new"); }); + it("keeps truncated GitHub issue bodies on a valid UTF-16 boundary", () => { + const store = createLegacyStore(); + const manifestPath = path.join(store.tempDir, "failed-migration.json"); + const writeManifest = (messages: string[], targetCount = 1) => { + const manifest: SessionSqliteMigrationManifest = { + failedAt: "2030-01-01T00:00:00.000Z", + manifestVersion: 2, + openClawVersion: "test", + runId: "utf16-boundary", + startedAt: "2030-01-01T00:00:00.000Z", + targets: Array.from({ length: targetCount }, (_, index) => { + const targetMessages = index === targetCount - 1 ? messages : ["x".repeat(500)]; + return { + agentId: + targetCount === 1 + ? "agent-with-long-name-".repeat(10) + : `agent-${index}-${"long-name-".repeat(10)}`, + completedMoves: [], + issues: targetMessages.map((message) => ({ code: "startup_failure", message })), + plannedMoves: [], + sqlitePath: path.join(store.tempDir, "openclaw-agent.sqlite"), + storePath: store.storePath, + validationBeforeArchive: "failed", + }; + }), + }; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); + }; + + const baseMessages = Array.from({ length: 9 }, () => "x".repeat(500)); + writeManifest([...baseMessages, "MESSAGE_START"]); + const probe = createSessionSqliteMigrationFailureIssue(manifestPath); + const messageOffset = probe?.body.indexOf("MESSAGE_START") ?? -1; + expect(5_999 - messageOffset).toBeLessThan(500); + + writeManifest([...baseMessages, `${"x".repeat(5_999 - messageOffset)}🎉tail`]); + const issue = createSessionSqliteMigrationFailureIssue(manifestPath); + expect(issue?.body).toContain("🎉tail"); + const urlBody = new URL(issue?.url ?? "").searchParams.get("body"); + expect(urlBody).not.toContain("�"); + expect(urlBody).toContain("truncated for URL"); + + let bodyTargetCount = 0; + let bodyMessageOffset = -1; + for (let count = 1; count < 50; count += 1) { + writeManifest(["BODY_START"], count); + const candidateIssue = createSessionSqliteMigrationFailureIssue(manifestPath); + const candidateOffset = candidateIssue?.body.indexOf("BODY_START") ?? -1; + if (candidateOffset < 0) { + break; + } + bodyTargetCount = count; + bodyMessageOffset = candidateOffset; + } + expect(bodyMessageOffset).toBeGreaterThanOrEqual(0); + expect(19_999 - bodyMessageOffset).toBeLessThan(600); + + writeManifest([`${"x".repeat(19_999 - bodyMessageOffset)}🎉tail`], bodyTargetCount); + const bodyIssue = createSessionSqliteMigrationFailureIssue(manifestPath); + expect(bodyIssue?.body).not.toMatch( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { const store = createLegacyStore(); const importReport = await runDoctorSessionSqlite({