mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-04 02:00:10 +00:00
fix(i18n): preserve native context and target reviewed refreshes
Carry the native source owner path into translation prompts and account for context in the batch budget. Add bounded per-entry native refreshes that union with ordinary invalidation, validate known IDs before provider work, and honor explicit refreshes during V1 migration. Provider-request and batch-budget regressions fail before the context repair. Selected-refresh regressions fail before the CLI/selection repair; focused suites, full changed-file checks, and independent P2 review pass. Generated locale outputs remain separate pending semantic review.
This commit is contained in:
parent
cafdeb78b7
commit
7e5e00c537
6 changed files with 254 additions and 11 deletions
|
|
@ -300,6 +300,7 @@ For correction artifact preparation, validate the immutable SHA with `--target-r
|
|||
- Run `pnpm check:architecture` before release preflight so the broader import cycle and architecture boundary checks are green outside the faster local gate.
|
||||
- Run `pnpm build && pnpm ui:build` before `pnpm release:check` so the expected `dist/*` release artifacts and Control UI bundle exist for the pack validation step.
|
||||
- Run `pnpm release:prep` after the root version bump and before tagging. It runs every deterministic release generator that commonly drifts after a version or config change: plugin versions, plugin inventory, base config schema, bundled channel config metadata, config docs baseline, plugin SDK exports, and Control UI locale bundles. It also blocks until native app translations and platform-generated locale resources match the source inventory; if they lag, wait for or dispatch `Native App Locale Refresh` before freezing the Code SHA. `pnpm release:check` re-runs those guards plus transient npm package-lock validation in check mode (including the strict locale gates plus the plugin SDK surface budget) and reports every failure in one pass before running package release checks. The npm preflight separately compares the exact release SHA with the prior published dist-tag and reports any Plugin SDK API changes.
|
||||
- To repair reviewed native translations, configure the translation provider credentials and run `pnpm native:i18n:sync --locale <code> --refresh-id <native-id>`. Find IDs in `apps/.i18n/native-source.json`; repeat `--refresh-id` for up to 64 distinct IDs. The selector adds those entries to normal pending work, so missing strings and glossary changes still refresh as usual. Unknown IDs are rejected before translation, and targeted refresh cannot be combined with `--force`. Then run `pnpm native:i18n:sync` to regenerate Android and Apple resources and `pnpm native:i18n:check` to validate the full result. Do not hand-edit generated translations.
|
||||
- Plugin version sync updates the publishable `@openclaw/ai` runtime package, official plugin package versions, and existing `openclaw.compat.pluginApi` floors to the OpenClaw release version by default. Treat that field as the plugin SDK/runtime API floor, not just a copy of the package version: for plugin-only releases that intentionally remain compatible with older OpenClaw hosts, keep the floor at the oldest supported host API and document that choice in the plugin release proof.
|
||||
- Run the manual `Full Release Validation` workflow before release approval to select the pre-release test boxes from one entrypoint. It accepts a branch, tag, or full commit SHA and dispatches manual `CI`, plugin prerelease, and `OpenClaw Release Checks` for the selected profile. Canonical beta `all` without soak uses the bounded `npm-beta-v1` policy described in [Full release validation](/reference/full-release-validation); install, package, Linux cross-OS, QA parity, runtime-pair/restart, and tool-coverage gates remain; Windows/macOS cross-OS outcomes are advisory. Stable and full runs always include exhaustive live/E2E and Docker release-path soak; `run_release_soak=true` requests an explicit beta soak. Package Acceptance provides package Telegram E2E when selected, avoiding a second concurrent live poller for an unpublished candidate.
|
||||
|
||||
|
|
|
|||
|
|
@ -412,12 +412,30 @@ function buildSystemPrompt(targetLocale: string, glossary: readonly GlossaryEntr
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildBatchPayload(items: readonly TranslationBatchItem[]) {
|
||||
return Object.fromEntries(
|
||||
items.map(
|
||||
(item) =>
|
||||
[
|
||||
item.key,
|
||||
item.sourcePath ? { text: item.text, sourcePath: item.sourcePath } : item.text,
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildBatchPrompt(
|
||||
items: readonly TranslationBatchItem[],
|
||||
validationError?: string,
|
||||
): string {
|
||||
const payload = Object.fromEntries(items.map((item) => [item.key, item.text]));
|
||||
const payload = buildBatchPayload(items);
|
||||
const lines = ["Translate this JSON object.", "Return ONLY a JSON object with the same keys."];
|
||||
if (items.some((item) => item.sourcePath)) {
|
||||
lines.push(
|
||||
"For object values, translate only text. Use sourcePath to understand the native UI owner and disambiguate its meaning; source paths are context, not text to translate.",
|
||||
"Return each id mapped directly to its translated string, without the context fields.",
|
||||
);
|
||||
}
|
||||
if (validationError) {
|
||||
lines.push(
|
||||
"",
|
||||
|
|
@ -478,7 +496,7 @@ function resolveBatchCharBudget(): number {
|
|||
}
|
||||
|
||||
function estimateBatchChars(items: readonly TranslationBatchItem[]): number {
|
||||
return items.reduce((total, item) => total + item.key.length + item.text.length + 8, 2);
|
||||
return JSON.stringify(buildBatchPayload(items)).length;
|
||||
}
|
||||
|
||||
type RunProcessOptions = {
|
||||
|
|
@ -1089,6 +1107,7 @@ export async function translateNativeEntries(
|
|||
key: entry.id,
|
||||
text: entry.source,
|
||||
textHash: hashControlUiTranslationText(entry.source),
|
||||
sourcePath: entry.sourcePath,
|
||||
}));
|
||||
const batches = buildTranslationBatches(pending);
|
||||
const clientAccess = createTranslationClientAccess(targetLocale, glossary);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export type TranslationBatchItem = {
|
|||
key: string;
|
||||
text: string;
|
||||
textHash: string;
|
||||
sourcePath?: string;
|
||||
};
|
||||
|
||||
export function flattenTranslations(
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export type NativeI18nQualityFinding = {
|
|||
type NativeTranslator = typeof translateNativeEntries;
|
||||
type NativeLocaleSyncOptions = {
|
||||
force?: boolean;
|
||||
refreshIds?: string[];
|
||||
glossary?: Array<{ source: string; target: string }>;
|
||||
translate?: NativeTranslator;
|
||||
translationsDir?: string;
|
||||
|
|
@ -71,6 +72,7 @@ type NativeLocaleSyncOptions = {
|
|||
type NativeI18nCommand = {
|
||||
command: "baseline" | "check" | "sync" | "verify";
|
||||
force?: boolean;
|
||||
refreshIds?: string[];
|
||||
locale?: string;
|
||||
write: boolean;
|
||||
};
|
||||
|
|
@ -1587,11 +1589,29 @@ export async function checkNativeLocaleArtifacts(
|
|||
);
|
||||
}
|
||||
|
||||
function normalizeNativeRefreshIds(ids: readonly string[], force = false): string[] {
|
||||
const distinct = [...new Set(ids)].toSorted(compareCodePoints);
|
||||
if (distinct.length > 64) {
|
||||
throw new Error("native refresh accepts at most 64 distinct `--refresh-id` values");
|
||||
}
|
||||
if (distinct.length > 0 && force) {
|
||||
throw new Error("native refresh cannot combine `--refresh-id` with `--force`");
|
||||
}
|
||||
return distinct;
|
||||
}
|
||||
|
||||
export async function syncNativeLocale(
|
||||
locale: string,
|
||||
entries: NativeI18nEntry[],
|
||||
options: NativeLocaleSyncOptions = {},
|
||||
) {
|
||||
const refreshIds = new Set(normalizeNativeRefreshIds(options.refreshIds ?? [], options.force));
|
||||
const inventoryIds = new Set(entries.map((entry) => entry.id));
|
||||
for (const id of refreshIds) {
|
||||
if (!inventoryIds.has(id)) {
|
||||
throw new Error(`unknown native refresh ID: ${id}`);
|
||||
}
|
||||
}
|
||||
// Native runtime resources are owned by the Android and Apple slices; these
|
||||
// artifacts keep the shared translation-memory handoff current between them.
|
||||
const artifactPath = path.join(options.translationsDir ?? TRANSLATIONS_DIR, `${locale}.json`);
|
||||
|
|
@ -1634,16 +1654,24 @@ export async function syncNativeLocale(
|
|||
}
|
||||
}
|
||||
}
|
||||
const glossaryChanged = previous?.version === 2 && previous.glossaryHash !== currentGlossaryHash;
|
||||
const glossaryChanged =
|
||||
(previous?.version === 2 || (migratingV1 && refreshIds.size > 0)) &&
|
||||
previous?.glossaryHash !== currentGlossaryHash;
|
||||
const pending = entries
|
||||
.filter((entry) => options.force || glossaryChanged || !reusableById.get(entry.id))
|
||||
.filter(
|
||||
(entry) =>
|
||||
options.force || refreshIds.has(entry.id) || glossaryChanged || !reusableById.get(entry.id),
|
||||
)
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
source: entry.source,
|
||||
sourcePath: entry.sites[0]?.path ?? "apps/.i18n/native-source.json",
|
||||
}));
|
||||
// Default v1 migration is provider-free; an explicit refresh also completes
|
||||
// its ordinary pending work instead of silently carrying selected translations.
|
||||
const translatePending = !migratingV1 || options.force || refreshIds.size > 0;
|
||||
const translated =
|
||||
pending.length && (!migratingV1 || options.force)
|
||||
pending.length && translatePending
|
||||
? await (options.translate ?? translateNativeEntries)(
|
||||
pending,
|
||||
locale,
|
||||
|
|
@ -1679,7 +1707,7 @@ export async function syncNativeLocale(
|
|||
process.stdout.write(
|
||||
`native-app-i18n: locale=${locale} entries=${entries.length} carried=${reusableById.size} translated=${translated.size} sourceFallback=${fallback} changed=${changed}\n`,
|
||||
);
|
||||
if (migratingV1 && pending.length > 0) {
|
||||
if (migratingV1 && !translatePending && pending.length > 0) {
|
||||
process.stdout.write(
|
||||
`native-app-i18n: locale=${locale} migration-source-fallback=${pending.length} ids=${pending.map((entry) => entry.id).join(",")}\n`,
|
||||
);
|
||||
|
|
@ -1691,10 +1719,11 @@ export function parseNativeI18nCommand(argv: string[]): NativeI18nCommand {
|
|||
const [command, ...args] = argv;
|
||||
if (command !== "baseline" && command !== "check" && command !== "sync" && command !== "verify") {
|
||||
throw new Error(
|
||||
"usage: node --import tsx scripts/native-app-i18n.ts baseline --write|check|sync [--write] [--locale <code>] [--force]|verify",
|
||||
"usage: node --import tsx scripts/native-app-i18n.ts baseline --write|check|sync [--write] [--locale <code>] [--force | --refresh-id <native-id> ...]|verify",
|
||||
);
|
||||
}
|
||||
let locale: string | undefined;
|
||||
const requestedRefreshIds: string[] = [];
|
||||
let force = false;
|
||||
let write = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
|
|
@ -1707,6 +1736,15 @@ export function parseNativeI18nCommand(argv: string[]): NativeI18nCommand {
|
|||
write = true;
|
||||
continue;
|
||||
}
|
||||
if (argument === "--refresh-id") {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error("native refresh requires an ID after `--refresh-id`");
|
||||
}
|
||||
requestedRefreshIds.push(value);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (argument === "--locale") {
|
||||
if (locale) {
|
||||
throw new Error("native locale refresh accepts only one `--locale` value");
|
||||
|
|
@ -1721,6 +1759,12 @@ export function parseNativeI18nCommand(argv: string[]): NativeI18nCommand {
|
|||
}
|
||||
throw new Error(`unsupported native i18n argument: ${argument}`);
|
||||
}
|
||||
const refreshIds = normalizeNativeRefreshIds(requestedRefreshIds, force);
|
||||
if (refreshIds.length > 0 && (command !== "sync" || !write || !locale)) {
|
||||
throw new Error(
|
||||
"native selected refresh requires `sync --write --locale <code> --refresh-id <native-id>`",
|
||||
);
|
||||
}
|
||||
if (locale) {
|
||||
if (command !== "sync" || !write) {
|
||||
throw new Error("native locale refresh requires `sync --write --locale <code>`");
|
||||
|
|
@ -1740,7 +1784,13 @@ export function parseNativeI18nCommand(argv: string[]): NativeI18nCommand {
|
|||
if (force && (command !== "sync" || !write || !locale)) {
|
||||
throw new Error("native full refresh requires `sync --write --locale <code> --force`");
|
||||
}
|
||||
return { command, locale, write, ...(force ? { force } : {}) };
|
||||
return {
|
||||
command,
|
||||
locale,
|
||||
write,
|
||||
...(force ? { force } : {}),
|
||||
...(refreshIds.length > 0 ? { refreshIds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
|
@ -1755,7 +1805,10 @@ async function main() {
|
|||
parsed.locale === undefined,
|
||||
});
|
||||
if (parsed.locale) {
|
||||
await syncNativeLocale(parsed.locale, entries, { force: parsed.force });
|
||||
await syncNativeLocale(parsed.locale, entries, {
|
||||
force: parsed.force,
|
||||
refreshIds: parsed.refreshIds,
|
||||
});
|
||||
}
|
||||
if (parsed.command === "verify" || parsed.command === "check") {
|
||||
const android = await import("./android-app-i18n.ts");
|
||||
|
|
|
|||
|
|
@ -113,8 +113,11 @@ describe("translation provider privacy and fallback", () => {
|
|||
net.connect = net.createConnection = net.Socket.prototype.connect = rejectNetwork;
|
||||
syncBuiltinESMExports();
|
||||
let requests = 0;
|
||||
globalThis.fetch = async () => {
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests += 1;
|
||||
const request = new Request(input, init);
|
||||
const payload = await request.json();
|
||||
assert.ok(JSON.stringify(payload.input).includes("apps/android/wear/src/main/res/values/strings.xml"), "native owner context must reach the serialized provider request");
|
||||
const item = { id: "message", type: "message", role: "assistant", content: [] };
|
||||
const text = JSON.stringify({ connect: "Connecter" });
|
||||
const events = [
|
||||
|
|
@ -128,7 +131,7 @@ describe("translation provider privacy and fallback", () => {
|
|||
return new Response(events.map(event => "data: " + JSON.stringify(event) + "\\n\\n").join(""), { headers: { "Content-Type": "text/event-stream" } });
|
||||
};
|
||||
const { translateNativeEntries } = await import(${JSON.stringify(scriptUrl)});
|
||||
const result = await translateNativeEntries([{ id: "connect", source: "Connect", sourcePath: "fixture" }], "fr");
|
||||
const result = await translateNativeEntries([{ id: "connect", source: "Connect", sourcePath: "apps/android/wear/src/main/res/values/strings.xml" }], "fr");
|
||||
assert.equal(result.get("connect"), "Connecter");
|
||||
assert.equal(requests, 1);
|
||||
console.log("isolated-runtime-ok");
|
||||
|
|
@ -169,6 +172,19 @@ describe("translation provider privacy and fallback", () => {
|
|||
expect(log).not.toContain(fallback);
|
||||
});
|
||||
|
||||
it("includes native owner context in the translation batch budget", async () => {
|
||||
vi.stubEnv("OPENCLAW_CONTROL_UI_I18N_BATCH_CHAR_BUDGET", "200");
|
||||
llm.completeSimple.mockResolvedValue(response());
|
||||
const contextualEntries = entries.slice(0, 2).map((entry) => ({
|
||||
id: entry.id,
|
||||
source: entry.source,
|
||||
sourcePath: "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt",
|
||||
}));
|
||||
|
||||
expect((await translateNativeEntries(contextualEntries, "fr")).size).toBe(2);
|
||||
expect(llm.completeSimple).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["401", "403", "404", "429", "insufficient_quota", "ECONNRESET"])(
|
||||
"keeps %s failures private without changing models",
|
||||
async (errorCode) => {
|
||||
|
|
|
|||
|
|
@ -943,6 +943,159 @@ describe("native app i18n inventory", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"clean",
|
||||
"missing",
|
||||
"source",
|
||||
"glossary",
|
||||
"legacy",
|
||||
"legacy-missing",
|
||||
"legacy-glossary",
|
||||
])("adds selected refresh to ordinary %s locale work", async (scenario) => {
|
||||
const tempDirs: string[] = [];
|
||||
const translationsDir = makeTempDir(tempDirs, "openclaw-native-i18n-");
|
||||
const selected = testEntry("native.apple.open", "apple", "Open");
|
||||
const other = testEntry("native.apple.close", "apple", "Close");
|
||||
const artifactPath = path.join(translationsDir, "sv.json");
|
||||
try {
|
||||
await syncNativeLocale("sv", [selected, other], {
|
||||
glossary: [],
|
||||
translationsDir,
|
||||
translate: async () =>
|
||||
new Map([
|
||||
[selected.id, "Tidigare"],
|
||||
[other.id, "Stäng"],
|
||||
]),
|
||||
});
|
||||
const previous = JSON.parse(await readFile(artifactPath, "utf8"));
|
||||
if (scenario === "missing") {
|
||||
delete previous.translations[other.id];
|
||||
}
|
||||
if (scenario.startsWith("legacy")) {
|
||||
previous.version = 1;
|
||||
previous.entries = [
|
||||
{ id: selected.id, source: selected.source, translated: "Tidigare" },
|
||||
{ id: other.id, source: other.source, translated: "Stäng" },
|
||||
];
|
||||
if (scenario === "legacy-missing") {
|
||||
previous.entries.pop();
|
||||
}
|
||||
delete previous.translations;
|
||||
}
|
||||
await writeFile(artifactPath, JSON.stringify(previous));
|
||||
const currentOther =
|
||||
scenario === "source"
|
||||
? expectDefined(
|
||||
assignNativeI18nIds([
|
||||
{
|
||||
kind: "ui-call",
|
||||
line: 1,
|
||||
path: "apps/ios/Close.swift",
|
||||
source: "Close now",
|
||||
surface: "apple",
|
||||
},
|
||||
])[0],
|
||||
"edited source",
|
||||
)
|
||||
: other;
|
||||
const pendingIds: string[] = [];
|
||||
await syncNativeLocale("sv", [selected, currentOther], {
|
||||
refreshIds: [selected.id, selected.id],
|
||||
glossary: scenario.endsWith("glossary") ? [{ source: "Close", target: "Stäng" }] : [],
|
||||
translationsDir,
|
||||
translate: async (pending) => {
|
||||
pendingIds.push(...pending.map((entry) => entry.id));
|
||||
return new Map(pending.map((entry) => [entry.id, "Uppdaterad"]));
|
||||
},
|
||||
});
|
||||
const refreshOther = scenario !== "clean" && scenario !== "legacy";
|
||||
expect(pendingIds.toSorted()).toEqual(
|
||||
(refreshOther ? [selected.id, currentOther.id] : [selected.id]).toSorted(),
|
||||
);
|
||||
expect(JSON.parse(await readFile(artifactPath, "utf8")).translations).toEqual({
|
||||
[selected.id]: "Uppdaterad",
|
||||
[currentOther.id]: refreshOther ? "Uppdaterad" : "Stäng",
|
||||
});
|
||||
} finally {
|
||||
cleanupTempDirs(tempDirs);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown refresh IDs before provider calls or artifact writes", async () => {
|
||||
const tempDirs: string[] = [];
|
||||
const translationsDir = makeTempDir(tempDirs, "openclaw-native-i18n-");
|
||||
const artifactPath = path.join(translationsDir, "sv.json");
|
||||
let called = false;
|
||||
try {
|
||||
await writeFile(artifactPath, "existing artifact bytes");
|
||||
await expect(
|
||||
syncNativeLocale("sv", [testEntry("native.apple.open", "apple", "Open")], {
|
||||
refreshIds: ["native.apple.unknown"],
|
||||
glossary: [],
|
||||
translationsDir,
|
||||
translate: async () => {
|
||||
called = true;
|
||||
return new Map();
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("unknown native refresh ID");
|
||||
expect(called).toBe(false);
|
||||
expect(await readFile(artifactPath, "utf8")).toBe("existing artifact bytes");
|
||||
} finally {
|
||||
cleanupTempDirs(tempDirs);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates and normalizes bounded CLI refresh selectors", () => {
|
||||
const base = ["sync", "--write", "--locale", "sv"];
|
||||
expect(
|
||||
parseNativeI18nCommand([
|
||||
...base,
|
||||
"--refresh-id",
|
||||
"native.apple.b",
|
||||
"--refresh-id",
|
||||
"native.apple.a",
|
||||
"--refresh-id",
|
||||
"native.apple.b",
|
||||
]).refreshIds,
|
||||
).toEqual(["native.apple.a", "native.apple.b"]);
|
||||
const ids = Array.from({ length: 64 }, (_, index) => `native.apple.${index}`);
|
||||
const firstId = expectDefined(ids[0], "first refresh ID");
|
||||
expect(
|
||||
parseNativeI18nCommand([
|
||||
...base,
|
||||
...ids.flatMap((id) => ["--refresh-id", id]),
|
||||
"--refresh-id",
|
||||
firstId,
|
||||
]).refreshIds,
|
||||
).toHaveLength(64);
|
||||
expect(() =>
|
||||
parseNativeI18nCommand([
|
||||
...base,
|
||||
...[...ids, "native.apple.extra"].flatMap((id) => ["--refresh-id", id]),
|
||||
]),
|
||||
).toThrow("64 distinct");
|
||||
expect(() => parseNativeI18nCommand([...base, "--refresh-id"])).toThrow("requires an ID");
|
||||
expect(() => parseNativeI18nCommand([...base, "--refresh-id", "--force"])).toThrow(
|
||||
"requires an ID",
|
||||
);
|
||||
expect(() => parseNativeI18nCommand([...base, "--force", "--refresh-id", firstId])).toThrow(
|
||||
"cannot combine",
|
||||
);
|
||||
for (const args of [
|
||||
["sync"],
|
||||
["sync", "--write"],
|
||||
["sync", "--locale", "sv"],
|
||||
["baseline", "--write"],
|
||||
["check"],
|
||||
["verify"],
|
||||
]) {
|
||||
expect(() => parseNativeI18nCommand([...args, "--refresh-id", firstId])).toThrow(
|
||||
"requires `sync --write --locale",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects native printf placeholder drift", async () => {
|
||||
const tempDirs: string[] = [];
|
||||
const translationsDir = makeTempDir(tempDirs, "openclaw-native-i18n-");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue