From c87088c1af54e2e38950116f756419d0e8e309fc Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Wed, 19 Aug 2026 12:03:06 +0530 Subject: [PATCH 1/5] Publish the translations that worked, and resample the page that looped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nightly job translated 782 pages, hit one failure, exited 1 and pushed nothing — 2.7M tokens discarded over a single page. --allow-partial (box job only) publishes what succeeded and carries the failed pages into the PR body, the Slack note and the run stamp. A partial publish is dangerous precisely because the PR looks complete, so the failures travel with it rather than sitting in a 700-line log. The failure was also misdiagnosed. reference/cloud-cli.mdx [vi] is a 16 KB source that emitted 64000 output tokens: a repetition loop, not a page too big to translate. max_tokens was treated as a size problem no resample could fix, so it propagated uncaught and consumed no attempt. Truncation is now split by output-against-source ratio — a runaway retries through the existing validity loop, a genuinely oversized source still fails loudly. --- CHANGELOG.md | 2 + .../integration-suite/local-runner.test.ts | 20 +++++ .../scripts/translate-docs/translator.test.ts | 76 +++++++++++++++++-- integration-suite/local/jobs/translate.sh | 36 ++++++++- scripts/translate-docs/cli.ts | 21 +++++ scripts/translate-docs/translator.ts | 65 +++++++++++++--- 6 files changed, 201 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b472798eb..f09aa7e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ ### Fixes +- Stop one bad page discarding a whole night's translation, and resample the draw that caused it. On 2026-08-18 the nightly job translated 782 pages, hit ONE failure, exited 1, and pushed nothing — 2.7M tokens in the bin over a single page. Two things were wrong. **`--allow-partial`** (used by the box job, off everywhere else) publishes what succeeded and carries the failures into the PR body, the Slack note and the run stamp, because the real hazard of a partial publish is a PR that looks complete. And the failure itself was misdiagnosed: `reference/cloud-cli.mdx [vi]` is a 16 KB source that emitted 64 000 output tokens, which is a repetition loop, not a page too large to translate. `stop_reason: "max_tokens"` was treated as a size problem no resample could fix, so it propagated uncaught and consumed no attempt. It is now split by the one signal that separates the two — output against the source's token estimate — and a runaway is retried through the existing validity loop with feedback telling the model to translate once and stop, while a source that genuinely approaches the ceiling keeps failing loudly rather than burning three attempts to arrive at the same place. (#PR) + - Let the box pick the translation model per tier, and stop a re-install double-scheduling the box. `getModelForTier` now reads `TRANSLATE_MODEL_TIER1` / `TRANSLATE_MODEL_TIER23`, so the seven languages most readers actually arrive in can keep a strong model while the long tail runs on something cheap — the CLI's `--model` flag flattens every tier to one model, which is the opposite of what the tier split exists for. Any id the gateway serves over the Anthropic `/v1/messages` shape works, since that is the API the translator speaks (verified: `deepseek-v4-pro` and `deepseek-v4-flash` both answer there). Separately, `install.sh` now strips the pre-marker cron form as well as its own marker: a box set up before the marker existed carries a long-form inline `docker run … -e CANARY_JOB=` line, and matching only the marker left it in place — six entries, every job scheduled twice, one on the old image and one on the new. The per-job flock keeps that from doing damage and turns it into something worse to diagnose: which image runs becomes a coin toss. Found on the real box, whose crontab is exactly that shape. (#705) - Make a non-PASS canary verdict explain itself. `probe-cli.sh` captured each agent's stdout and stderr into `$OUTA`/`$OUTB`, used them for two greps, and threw them away; `run.sh` then echoed `tail -20` of the probe on any non-PASS verdict — and the last 20 lines of that probe are the verdict block, so the log restated the verdict instead of giving the cause. Four CLIs sat yellow on the box for three consecutive days with nothing recorded anywhere but the word INCONCLUSIVE, and re-running produced the same nothing because the evidence was discarded both times. Each failing probe now prints the last 25 lines of what the CLI actually said, plus whether a hook fired at all, and the tail window widens to 80 so the explanation lands inside it. The daemon note is corrected in the same breath: `daemon: routed, no fail-closed denies` was printed whenever the grep for `daemon-unreachable` found nothing, which is equally what **no hook log at all** looks like — a run where the daemon was never asked anything now says so instead of claiming a real evaluation. (#705) diff --git a/__tests__/integration-suite/local-runner.test.ts b/__tests__/integration-suite/local-runner.test.ts index 9ee58ace8..0b8214004 100644 --- a/__tests__/integration-suite/local-runner.test.ts +++ b/__tests__/integration-suite/local-runner.test.ts @@ -546,6 +546,26 @@ describe("translate job", () => { expect(translateSh).toMatch(/\[ "\$drift" = 0 \] \|\| die/); }); + it("publishes what succeeded instead of discarding it on one bad page", () => { + // 2026-08-18: ONE page overran the output limit, cli.ts exited 1, this job + // died before its push, and 782 completed translations (2.7M tokens) went + // in the bin. --allow-partial makes the run publish and REPORT instead. + expect(translateSh).toMatch(/--allow-partial/); + // PIPESTATUS, not $? — `bun … | tee` would otherwise report tee's status + // and a real failure would sail through as success. + expect(translateSh).toMatch(/PIPESTATUS\[0\]/); + }); + + it("carries a partial run into the PR body and Slack, never silently", () => { + // The danger of publishing partial output is that the PR LOOKS complete. + // The failed pages have to travel with it. + expect(translateSh).toMatch(/grep -q "\^PARTIAL RUN"/); + expect(translateSh).toMatch(/This run was PARTIAL/); + expect(translateSh).toMatch(/published a PARTIAL run/); + // and the stamp says partial, so the weekly audit does not read it as ok + expect(translateSh).toMatch(/stamp partial/); + }); + it("stamps every exit so 'never ran' is detectable from outside", () => { // The one failure no error handler can report is the job not starting, so // the signal has to be a file whose AGE another job can read. diff --git a/__tests__/scripts/translate-docs/translator.test.ts b/__tests__/scripts/translate-docs/translator.test.ts index 4ce1dd1ec..d3aa752a5 100644 --- a/__tests__/scripts/translate-docs/translator.test.ts +++ b/__tests__/scripts/translate-docs/translator.test.ts @@ -44,6 +44,45 @@ describe("translateContent", () => { streamMock.mockReset(); }); + it("marks a truncation whose output dwarfs the source as retryable", async () => { + // reference/cloud-cli.mdx [vi], on the box, 2026-08-18: a 16 KB source + // emitted 64 000 output tokens. That is a repetition loop, not a page too + // big to translate — and it failed the whole nightly run because the throw + // was treated as a size problem no resample could fix. + mockFinalMessage({ + stop_reason: "max_tokens", + content: [{ type: "text", text: "loop…" }], + usage: { input_tokens: 5000, output_tokens: 64000 }, + }); + let err!: Error & { retryable?: boolean }; + try { + await translateContent("x".repeat(16000), "vi", "Vietnamese"); + } catch (e) { + err = e as Error & { retryable?: boolean }; + } + expect(err.message).toMatch(/runaway sample/); + expect(err.retryable).toBe(true); + }); + + it("leaves a genuinely oversized source NOT retryable", async () => { + // Output within a small multiple of the source is a page that really does + // not fit. Retrying it would burn the whole attempt budget to land in the + // same place, so the old fail-loud behaviour stands. + mockFinalMessage({ + stop_reason: "max_tokens", + content: [{ type: "text", text: "big…" }], + usage: { input_tokens: 60000, output_tokens: 64000 }, + }); + let err!: Error & { retryable?: boolean }; + try { + await translateContent("y".repeat(200000), "vi", "Vietnamese"); + } catch (e) { + err = e as Error & { retryable?: boolean }; + } + expect(err.message).toMatch(/source too large/); + expect(err.retryable).toBe(false); + }); + it("throws when the model truncates the output at max_tokens", async () => { // A truncated response leaves malformed MDX (unbalanced braces) that would // otherwise be written to disk and cached, then fail `mintlify validate`. @@ -210,26 +249,53 @@ describe("translateValidated", () => { expect(result.attempts).toBe(2); }); - it("does not retry a response truncated at max_tokens", async () => { - // translateContent throws on max_tokens before returning; that is not a - // validity failure, so translateValidated must let it propagate uncaught. + it("does not retry a truncation caused by a genuinely oversized source", async () => { + // Output within a small multiple of the source is a page that really does + // not fit; retrying spends the whole budget to land in the same place. Note + // `base.source` is what sets the ratio — a large source with a large output + // is the not-retryable shape. mockFinalMessage({ stop_reason: "max_tokens", content: [{ type: "text", text: "partial…" }], - usage: { input_tokens: 1, output_tokens: 64000 }, + usage: { input_tokens: 60000, output_tokens: 64000 }, }); await expect( translateValidated({ ...base, + source: "z".repeat(200000), lang: "he", langName: "Hebrew", validate: async () => null, }), - ).rejects.toThrow(/truncated at max_tokens/); + ).rejects.toThrow(/source too large/); expect(streamMock).toHaveBeenCalledTimes(1); }); + it("DOES retry a truncation whose output dwarfs the source", async () => { + // The narrowing of the rule above. A small source that emitted 64k tokens + // is a repetition loop, and a resample is exactly what fixes it — the + // failure that killed the 2026-08-18 nightly run and discarded 782 good + // pages with it. First draw loops, second draw succeeds. + streamMock.mockReturnValueOnce({ + finalMessage: async () => ({ + stop_reason: "max_tokens", + content: [{ type: "text", text: "loop…" }], + usage: { input_tokens: 10, output_tokens: 64000 }, + }), + }); + queueFinalMessage("# translated", { input_tokens: 10, output_tokens: 40 }); + + const result = await translateValidated({ + ...base, + lang: "vi", + langName: "Vietnamese", + validate: async () => null, + }); + expect(result.rendered).toContain("# translated"); + expect(streamMock).toHaveBeenCalledTimes(2); + }); + it("does not retry when the request itself throws", async () => { streamMock.mockReturnValue({ finalMessage: async () => { diff --git a/integration-suite/local/jobs/translate.sh b/integration-suite/local/jobs/translate.sh index 12f7dac97..89d0c484a 100644 --- a/integration-suite/local/jobs/translate.sh +++ b/integration-suite/local/jobs/translate.sh @@ -172,8 +172,22 @@ bun install --frozen-lockfile --ignore-scripts || die "bun install failed" step "translate" FORCE_FLAG="" [ "${TRANSLATE_FORCE:-0}" = "1" ] && FORCE_FLAG="--force" +# --allow-partial: publish what succeeded. Without it ONE failing page exits 1 +# and this job dies before its push — which on 2026-08-18 discarded 782 good +# translations (2.7M tokens) over a single page that overran the output limit. +# The failures are not swallowed: cli.ts prints FAILED PAGES + a PARTIAL RUN +# marker, and both are carried into Slack and the PR body below. +TR_LOG="$(mktemp)" # shellcheck disable=SC2086 -bun run translate --languages "$LANGS" $FORCE_FLAG || die "translation failed" +bun run translate --languages "$LANGS" $FORCE_FLAG --allow-partial 2>&1 | tee "$TR_LOG" +# PIPESTATUS, not $? — that would be tee's. +[ "${PIPESTATUS[0]}" = 0 ] || die "translation failed" +PARTIAL="" +if grep -q "^PARTIAL RUN" "$TR_LOG"; then + PARTIAL="$(sed -n '/^FAILED PAGES/,/^PARTIAL RUN/p' "$TR_LOG")" + echo "⚠ partial run — carrying the failures into the report" +fi +rm -f "$TR_LOG" # Parses every page AND checks image references resolve on disk — a broken # image path is valid MDX, so `mintlify validate` passes it to a reader's @@ -312,7 +326,12 @@ if [ -z "$PR_NUMBER" ]; then - Only changed pages were re-translated (content-hash cache) - All 14 languages across 3 tiers -- Box run \`$TS\` against \`$REF_DESC\` @ \`$FP_SHA\`" +- Box run \`$TS\` against \`$REF_DESC\` @ \`$FP_SHA\`${PARTIAL:+ + +**This run was PARTIAL — the pages below are missing from it and still need a translation:** +\`\`\` +$PARTIAL +\`\`\`}" CREATED="$(api POST /pulls "$(node -e 'process.stdout.write(JSON.stringify({title:process.argv[1],body:process.argv[2],base:process.argv[3],head:process.argv[4]}))' \ "$PR_TITLE" "$BODY" "$BASE_BRANCH" "$BRANCH")" \ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const p=JSON.parse(s);process.stdout.write(p.number?String(p.number):"")}catch{process.stdout.write("")}})')" @@ -323,5 +342,16 @@ else echo "pushed $CHANGED files to https://github.com/$REPO/pull/$PR_NUMBER" fi -stamp ok "PR #$PR_NUMBER on $BRANCH" +if [ -n "$PARTIAL" ]; then + stamp partial "PR #$PR_NUMBER on $BRANCH, with failures" + # Quiet-on-success does not extend to a run that silently dropped pages: the + # PR looks complete, and only this line says it is not. + slack_note "⚠️ *nightly translation published a PARTIAL run* — \`$REF_DESC\` @ \`$FP_SHA\` +PR #$PR_NUMBER on \`$BRANCH\` is missing the pages below. +\`\`\` +$PARTIAL +\`\`\`" +else + stamp ok "PR #$PR_NUMBER on $BRANCH" +fi echo "── done: PR #$PR_NUMBER on $BRANCH ──" diff --git a/scripts/translate-docs/cli.ts b/scripts/translate-docs/cli.ts index 5096d0d77..03a7a66bd 100644 --- a/scripts/translate-docs/cli.ts +++ b/scripts/translate-docs/cli.ts @@ -39,6 +39,11 @@ const { values: args } = parseArgs({ validate: { type: "boolean", default: false }, prune: { type: "boolean", default: false }, "no-prune": { type: "boolean", default: false }, + // Publish what succeeded instead of discarding it. One page failing used to + // exit 1, which made the box job die before its push — on 2026-08-18 that + // threw away 782 good translations (2.7M tokens) because ONE page overran + // the output limit. Opt-in, so CI and hand runs keep failing loudly. + "allow-partial": { type: "boolean", default: false }, model: { type: "string", short: "m" }, help: { type: "boolean", short: "h", default: false }, }, @@ -56,6 +61,8 @@ Options: --readme-only Only translate the README --docs-only Only translate Mintlify docs --dry-run Show what would be translated without calling the API + --allow-partial Exit 0 when SOME pages translated, listing the failures + (default: any failure exits 1 and publishes nothing) -f, --force Ignore cache, re-translate everything --update-nav Regenerate docs.json navigation after translation --validate Check all nav references resolve to files @@ -441,6 +448,20 @@ async function main() { } if (errors.length > 0) { + // Printed either way — a partial run must never read as a clean one. + console.log(`\nFAILED PAGES (${errors.length}):`); + for (const e of errors) { + console.log(` ${e.source} [${e.lang}]: ${e.error}`); + } + if (args["allow-partial"] && translated.length > 0) { + // A stable marker the box job greps for, so the failure reaches Slack and + // the PR body instead of being buried in a 700-line log. + console.log( + `\nPARTIAL RUN — ${translated.length} page(s) translated, ${errors.length} failed. ` + + `Publishing what succeeded.`, + ); + return; + } process.exit(1); } } diff --git a/scripts/translate-docs/translator.ts b/scripts/translate-docs/translator.ts index fc93f4d45..53b270c84 100644 --- a/scripts/translate-docs/translator.ts +++ b/scripts/translate-docs/translator.ts @@ -44,6 +44,12 @@ const MAX_ATTEMPTS = ? parsedMaxAttempts : 3; +// How far past the source an output may run before a max_tokens stop reads as a +// repetition loop rather than a large page. Translations of the verbose targets +// (hi, vi, ja) sit near 2x the source's token estimate; 6x is far outside that +// band and was ~16x in the observed failure. +const RUNAWAY_RATIO = Number.parseInt(process.env.TRANSLATE_RUNAWAY_RATIO ?? "", 10) || 6; + function getClient(): Anthropic { if (!client) { // Default 5 retries (up from SDK default of 2) so transient @@ -151,10 +157,32 @@ export async function translateContent( // consolidate publish step. If a page ever legitimately needs more than // MAX_TOKENS, raise TRANSLATE_MAX_TOKENS or split the source. if (response.stop_reason === "max_tokens") { - throw new Error( + // TWO different failures wear this stop_reason, and only one of them is + // "the page is too big". A 16 KB source whose Vietnamese translation + // emitted 64 000 output tokens — observed on the box, 2026-08-18, + // reference/cloud-cli.mdx [vi] — is a DEGENERATE SAMPLE: the model fell + // into a repetition loop. Resampling fixes that; splitting the source does + // not, and treating it as a size problem sent one page's hiccup on to fail + // the whole nightly run. + // + // The tell is the ratio. Source bytes / 4 is a rough token count, and a + // faithful translation lands within a small multiple of it — even for the + // verbose target languages. Far beyond that is the model talking to + // itself, so the error is marked RETRYABLE and translateValidated spends + // an attempt on it. A source that genuinely approaches the ceiling keeps + // the old fail-loud behaviour, because retrying it would burn the budget + // three times to reach the same place. + const sourceTokensApprox = Math.max(1, Math.ceil(content.length / 4)); + const runaway = response.usage.output_tokens > sourceTokensApprox * RUNAWAY_RATIO; + const err = new Error( `translation truncated at max_tokens=${MAX_TOKENS} ` + - `(output ${response.usage.output_tokens} tokens) — source too large to translate in one request`, + `(output ${response.usage.output_tokens} tokens, source ~${sourceTokensApprox}) — ` + + (runaway + ? "output dwarfs the source, which is a runaway sample rather than an oversized page" + : "source too large to translate in one request"), ); + (err as Error & { retryable?: boolean }).retryable = runaway; + throw err; } const translated = @@ -211,15 +239,30 @@ export async function translateValidated(opts: { ? { attempt, maxAttempts: MAX_ATTEMPTS, error: lastError } : undefined; - // No try/catch: a transport/auth/max_tokens throw is not a validity - // failure — let it propagate so it is never silently retried as one. - const result = await translateContent( - opts.source, - opts.lang, - opts.langName, - opts.model, - feedback, - ); + // Transport/auth throws still propagate untouched — they are not validity + // failures and must never be silently retried as one. The ONE exception is + // a truncation flagged `retryable` (a runaway sample, see translateContent): + // that is a bad draw, and a bad draw is exactly what this loop is for. + let result: Awaited>; + try { + result = await translateContent( + opts.source, + opts.lang, + opts.langName, + opts.model, + feedback, + ); + } catch (e) { + const err = e as Error & { retryable?: boolean }; + if (!err.retryable || attempt === MAX_ATTEMPTS) throw err; + lastError = + "The previous attempt ran past the output limit by repeating itself. " + + "Translate the document once, completely, and stop."; + console.warn( + ` ${opts.label} -> attempt ${attempt}/${MAX_ATTEMPTS} overran the output limit; retrying`, + ); + continue; + } inputTokens += result.inputTokens; outputTokens += result.outputTokens; From dd68aee0c8ebf244b013d99fb368f52a084b97ca Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Wed, 19 Aug 2026 13:04:38 +0530 Subject: [PATCH 2/5] Localize a nav group that has no pages, instead of crashing on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildLanguageNav rebuilt each group as {group, pages} and mapped over pages unconditionally. The docs rebuild added {group, expanded, openapi} — a group whose content is an OpenAPI spec — so --update-nav threw TypeError AFTER 784 pages had been translated, losing the whole run for the second night running. Groups are spread now, so expanded/icon/openapi survive rather than being dropped from every localized nav; a pages-less group passes through untouched; and nested groups inside pages recurse instead of being prefixed as paths. --- CHANGELOG.md | 2 + .../translate-docs/mintlify-nav.test.ts | 46 ++++++++++++++++++- scripts/translate-docs/mintlify-nav.ts | 38 +++++++++++++-- 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f09aa7e5d..a271b30b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ ### Fixes +- Stop the localized nav crashing on a group that has no pages, and stop it dropping the properties it does have. `buildLanguageNav` rebuilt every group as `{group, pages}` and called `group.pages.map(...)` unconditionally. The docs rebuild added `{group, expanded, openapi}` — a group whose content is an OpenAPI spec and has no pages at all — so `--update-nav` died with `TypeError: undefined is not an object`, **after 784 pages had already been translated**, taking the whole nightly run with it for the second night running. Groups are now rebuilt by spreading the English group, so `expanded`, `icon` and `openapi` survive instead of being silently discarded from every non-English nav; a group with no pages is carried through untouched (the spec is not translated, and dropping it would remove the API reference from thirteen languages); and `pages` entries that are themselves nested groups recurse rather than being prefixed as if they were paths. `pages` is optional on the type now, which is what it always was in the data. (#PR) + - Stop one bad page discarding a whole night's translation, and resample the draw that caused it. On 2026-08-18 the nightly job translated 782 pages, hit ONE failure, exited 1, and pushed nothing — 2.7M tokens in the bin over a single page. Two things were wrong. **`--allow-partial`** (used by the box job, off everywhere else) publishes what succeeded and carries the failures into the PR body, the Slack note and the run stamp, because the real hazard of a partial publish is a PR that looks complete. And the failure itself was misdiagnosed: `reference/cloud-cli.mdx [vi]` is a 16 KB source that emitted 64 000 output tokens, which is a repetition loop, not a page too large to translate. `stop_reason: "max_tokens"` was treated as a size problem no resample could fix, so it propagated uncaught and consumed no attempt. It is now split by the one signal that separates the two — output against the source's token estimate — and a runaway is retried through the existing validity loop with feedback telling the model to translate once and stop, while a source that genuinely approaches the ceiling keeps failing loudly rather than burning three attempts to arrive at the same place. (#PR) - Let the box pick the translation model per tier, and stop a re-install double-scheduling the box. `getModelForTier` now reads `TRANSLATE_MODEL_TIER1` / `TRANSLATE_MODEL_TIER23`, so the seven languages most readers actually arrive in can keep a strong model while the long tail runs on something cheap — the CLI's `--model` flag flattens every tier to one model, which is the opposite of what the tier split exists for. Any id the gateway serves over the Anthropic `/v1/messages` shape works, since that is the API the translator speaks (verified: `deepseek-v4-pro` and `deepseek-v4-flash` both answer there). Separately, `install.sh` now strips the pre-marker cron form as well as its own marker: a box set up before the marker existed carries a long-form inline `docker run … -e CANARY_JOB=` line, and matching only the marker left it in place — six entries, every job scheduled twice, one on the old image and one on the new. The per-job flock keeps that from doing damage and turns it into something worse to diagnose: which image runs becomes a coin toss. Found on the real box, whose crontab is exactly that shape. (#705) diff --git a/__tests__/scripts/translate-docs/mintlify-nav.test.ts b/__tests__/scripts/translate-docs/mintlify-nav.test.ts index 1f8550706..b1e4ff155 100644 --- a/__tests__/scripts/translate-docs/mintlify-nav.test.ts +++ b/__tests__/scripts/translate-docs/mintlify-nav.test.ts @@ -244,15 +244,57 @@ describe("localizeProductsNavigation", () => { "es", "ja", ]); - expect(languages[1].tabs[0].groups[0].pages).toEqual([ + expect(languages[1].tabs[0].groups[0].pages!).toEqual([ "es/agenteye/overview", ]); }); + it("carries a group that has no pages, instead of crashing on it", () => { + // The docs rebuild introduced `{group, expanded, openapi}` — a group whose + // content is an OpenAPI spec, with no `pages` at all. buildLanguageNav did + // `group.pages.map(...)` unconditionally and took the whole nightly + // translation down with a TypeError, AFTER 784 pages had been translated. + const tabs = [ + { + tab: "Integrations and reference", + groups: [ + { group: "Guides", pages: ["intro"] }, + { group: "HTTP API", expanded: false, openapi: "reference/openapi.json" }, + ], + }, + ]; + const zh = buildLanguageNav(tabs as never, "zh"); + const groups = zh.tabs[0].groups; + expect(groups[0].pages![0]).toBe("zh/intro"); + // Passed through untouched — the spec is not translated, and dropping the + // group would remove the API reference from every non-English nav. + expect(groups[1].openapi).toBe("reference/openapi.json"); + expect(groups[1].pages).toBeUndefined(); + }); + + it("preserves group properties the old builder silently dropped", () => { + // It rebuilt each group as {group, pages}, so `expanded`, `icon` and + // anything else vanished from every localized nav. + const tabs = [{ tab: "Docs", groups: [{ group: "G", expanded: true, icon: "book", pages: ["a"] }] }]; + const de = buildLanguageNav(tabs as never, "de"); + expect(de.tabs[0].groups[0].expanded).toBe(true); + expect(de.tabs[0].groups[0].icon).toBe("book"); + }); + + it("recurses into nested groups rather than prefixing them as paths", () => { + const tabs = [ + { tab: "Docs", groups: [{ group: "Outer", pages: ["top", { group: "Inner", pages: ["deep"] }] }] }, + ]; + const ja = buildLanguageNav(tabs as never, "ja"); + const outer = ja.tabs[0].groups[0]; + expect(outer.pages![0]).toBe("ja/top"); + expect((outer.pages![1] as { pages?: string[] }).pages![0]).toBe("ja/deep"); + }); + it("uses Mintlify's canonical Portuguese locale with existing paths", () => { const portuguese = buildLanguageNav(sampleEnglishTabs, "pt-br"); expect(portuguese.language).toBe("pt-BR"); - expect(portuguese.tabs[0].groups[0].pages[0]).toBe("pt-br/introduction"); + expect(portuguese.tabs[0].groups[0].pages![0]).toBe("pt-br/introduction"); }); }); diff --git a/scripts/translate-docs/mintlify-nav.ts b/scripts/translate-docs/mintlify-nav.ts index 24b6f5f32..1182c747a 100644 --- a/scripts/translate-docs/mintlify-nav.ts +++ b/scripts/translate-docs/mintlify-nav.ts @@ -8,12 +8,21 @@ const DOCS_JSON_PATH = join(__dirname, "..", "..", "docs", "docs.json"); interface NavGroup { group: string; - pages: string[]; + // OPTIONAL, and both of these are why: a Mintlify group may carry `pages`, or + // nested `groups`, or neither — the docs rebuild introduced a group whose + // content is an `openapi` spec and has no pages at all. `pages` entries are + // likewise strings OR nested groups. Typing these as required is what let + // `group.pages.map` crash the nightly run. + pages?: (string | NavGroup)[]; + groups?: NavGroup[]; + // Everything else (expanded, icon, openapi, …) rides along untouched. + [key: string]: unknown; } interface NavTab { tab: string; groups: NavGroup[]; + [key: string]: unknown; } interface LanguageNav { @@ -97,12 +106,31 @@ export function buildLanguageNav( Examples: t.examples, }; + // Rebuilt by SPREADING the English group, not by picking two fields off it. + // The old form silently dropped every other key — `expanded`, `icon`, + // `openapi` — so a localized nav quietly lost them, and it crashed outright + // on the first group that had no `pages` at all. + const localizeGroup = (group: NavGroup): NavGroup => { + const out: NavGroup = { ...group, group: groupNameMap[group.group] || group.group }; + if (Array.isArray(group.pages)) { + // A page entry is a path to prefix, or a nested group to recurse into. + out.pages = group.pages.map((page) => + typeof page === "string" ? `${lang}/${page}` : localizeGroup(page), + ); + } + if (Array.isArray(group.groups)) { + out.groups = group.groups.map(localizeGroup); + } + // No pages and no groups (an `openapi` group): carried through as-is. The + // spec is not translated, and dropping the group would remove the API + // reference from every non-English nav. + return out; + }; + const tabs: NavTab[] = englishTabs.map((tab) => ({ + ...tab, tab: tabNameMap[tab.tab] || tab.tab, - groups: tab.groups.map((group) => ({ - group: groupNameMap[group.group] || group.group, - pages: group.pages.map((page) => `${lang}/${page}`), - })), + groups: (tab.groups ?? []).map(localizeGroup), })); return { From 684958609d8016385b5ff82075faf6b5e3740248 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Wed, 19 Aug 2026 13:47:26 +0530 Subject: [PATCH 3/5] docs: fill in the PR number on this branch's changelog entries --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a271b30b9..6d4a9fea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,9 @@ ### Fixes -- Stop the localized nav crashing on a group that has no pages, and stop it dropping the properties it does have. `buildLanguageNav` rebuilt every group as `{group, pages}` and called `group.pages.map(...)` unconditionally. The docs rebuild added `{group, expanded, openapi}` — a group whose content is an OpenAPI spec and has no pages at all — so `--update-nav` died with `TypeError: undefined is not an object`, **after 784 pages had already been translated**, taking the whole nightly run with it for the second night running. Groups are now rebuilt by spreading the English group, so `expanded`, `icon` and `openapi` survive instead of being silently discarded from every non-English nav; a group with no pages is carried through untouched (the spec is not translated, and dropping it would remove the API reference from thirteen languages); and `pages` entries that are themselves nested groups recurse rather than being prefixed as if they were paths. `pages` is optional on the type now, which is what it always was in the data. (#PR) +- Stop the localized nav crashing on a group that has no pages, and stop it dropping the properties it does have. `buildLanguageNav` rebuilt every group as `{group, pages}` and called `group.pages.map(...)` unconditionally. The docs rebuild added `{group, expanded, openapi}` — a group whose content is an OpenAPI spec and has no pages at all — so `--update-nav` died with `TypeError: undefined is not an object`, **after 784 pages had already been translated**, taking the whole nightly run with it for the second night running. Groups are now rebuilt by spreading the English group, so `expanded`, `icon` and `openapi` survive instead of being silently discarded from every non-English nav; a group with no pages is carried through untouched (the spec is not translated, and dropping it would remove the API reference from thirteen languages); and `pages` entries that are themselves nested groups recurse rather than being prefixed as if they were paths. `pages` is optional on the type now, which is what it always was in the data. (#725) -- Stop one bad page discarding a whole night's translation, and resample the draw that caused it. On 2026-08-18 the nightly job translated 782 pages, hit ONE failure, exited 1, and pushed nothing — 2.7M tokens in the bin over a single page. Two things were wrong. **`--allow-partial`** (used by the box job, off everywhere else) publishes what succeeded and carries the failures into the PR body, the Slack note and the run stamp, because the real hazard of a partial publish is a PR that looks complete. And the failure itself was misdiagnosed: `reference/cloud-cli.mdx [vi]` is a 16 KB source that emitted 64 000 output tokens, which is a repetition loop, not a page too large to translate. `stop_reason: "max_tokens"` was treated as a size problem no resample could fix, so it propagated uncaught and consumed no attempt. It is now split by the one signal that separates the two — output against the source's token estimate — and a runaway is retried through the existing validity loop with feedback telling the model to translate once and stop, while a source that genuinely approaches the ceiling keeps failing loudly rather than burning three attempts to arrive at the same place. (#PR) +- Stop one bad page discarding a whole night's translation, and resample the draw that caused it. On 2026-08-18 the nightly job translated 782 pages, hit ONE failure, exited 1, and pushed nothing — 2.7M tokens in the bin over a single page. Two things were wrong. **`--allow-partial`** (used by the box job, off everywhere else) publishes what succeeded and carries the failures into the PR body, the Slack note and the run stamp, because the real hazard of a partial publish is a PR that looks complete. And the failure itself was misdiagnosed: `reference/cloud-cli.mdx [vi]` is a 16 KB source that emitted 64 000 output tokens, which is a repetition loop, not a page too large to translate. `stop_reason: "max_tokens"` was treated as a size problem no resample could fix, so it propagated uncaught and consumed no attempt. It is now split by the one signal that separates the two — output against the source's token estimate — and a runaway is retried through the existing validity loop with feedback telling the model to translate once and stop, while a source that genuinely approaches the ceiling keeps failing loudly rather than burning three attempts to arrive at the same place. (#725) - Let the box pick the translation model per tier, and stop a re-install double-scheduling the box. `getModelForTier` now reads `TRANSLATE_MODEL_TIER1` / `TRANSLATE_MODEL_TIER23`, so the seven languages most readers actually arrive in can keep a strong model while the long tail runs on something cheap — the CLI's `--model` flag flattens every tier to one model, which is the opposite of what the tier split exists for. Any id the gateway serves over the Anthropic `/v1/messages` shape works, since that is the API the translator speaks (verified: `deepseek-v4-pro` and `deepseek-v4-flash` both answer there). Separately, `install.sh` now strips the pre-marker cron form as well as its own marker: a box set up before the marker existed carries a long-form inline `docker run … -e CANARY_JOB=` line, and matching only the marker left it in place — six entries, every job scheduled twice, one on the old image and one on the new. The per-job flock keeps that from doing damage and turns it into something worse to diagnose: which image runs becomes a coin toss. Found on the real box, whose crontab is exactly that shape. (#705) From 1879607e3a6b92cb2c0d39247ef94101ceb9fc1b Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Wed, 19 Aug 2026 17:23:03 +0530 Subject: [PATCH 4/5] Address review: validate the ratio env var, and cover the untested branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRANSLATE_RUNAWAY_RATIO used `parseInt(...) || 6`, which accepts a NEGATIVE — and a negative ratio makes `output > source * ratio` true for every response, so a genuinely oversized page would be misread as a runaway and burn all three attempts arriving where it started. Validated as a positive integer, matching MAX_TOKENS and MAX_ATTEMPTS in the same file. localizeGroup recurses through both `pages` and `groups`, and only the `pages` branch was covered: deleting the `groups` branch outright left all 20 nav tests green. Both new tests were verified to fail against the unfixed code. --- CHANGELOG.md | 13 +++++--- .../translate-docs/mintlify-nav.test.ts | 22 +++++++++++++ .../scripts/translate-docs/translator.test.ts | 31 +++++++++++++++++++ scripts/translate-docs/translator.ts | 14 ++++++++- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84a56b0c3..9c74c313a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.2-beta.0 — 2026-08-19 + +### Fixes + +- Stop the localized nav crashing on a group that has no pages, and stop it dropping the properties it does have. `buildLanguageNav` rebuilt every group as `{group, pages}` and called `group.pages.map(...)` unconditionally. The docs rebuild added `{group, expanded, openapi}` — a group whose content is an OpenAPI spec and has no pages at all — so `--update-nav` died with `TypeError: undefined is not an object`, **after 784 pages had already been translated**, taking the whole nightly run with it for the second night running. Groups are now rebuilt by spreading the English group, so `expanded`, `icon` and `openapi` survive instead of being silently discarded from every non-English nav; a group with no pages is carried through untouched (the spec is not translated, and dropping it would remove the API reference from thirteen languages); and `pages` entries that are themselves nested groups recurse rather than being prefixed as if they were paths. `pages` is optional on the type now, which is what it always was in the data. (#725) + +- Stop one bad page discarding a whole night's translation, and resample the draw that caused it. On 2026-08-18 the nightly job translated 782 pages, hit ONE failure, exited 1, and pushed nothing — 2.7M tokens in the bin over a single page. Two things were wrong. **`--allow-partial`** (used by the box job, off everywhere else) publishes what succeeded and carries the failures into the PR body, the Slack note and the run stamp, because the real hazard of a partial publish is a PR that looks complete. And the failure itself was misdiagnosed: `reference/cloud-cli.mdx [vi]` is a 16 KB source that emitted 64 000 output tokens, which is a repetition loop, not a page too large to translate. `stop_reason: "max_tokens"` was treated as a size problem no resample could fix, so it propagated uncaught and consumed no attempt. It is now split by the one signal that separates the two — output against the source's token estimate — and a runaway is retried through the existing validity loop with feedback telling the model to translate once and stop, while a source that genuinely approaches the ceiling keeps failing loudly rather than burning three attempts to arrive at the same place. (#725) + ## 1.0.1-beta.2 — 2026-08-17 ### Features @@ -14,9 +22,6 @@ ### Fixes -- Stop the localized nav crashing on a group that has no pages, and stop it dropping the properties it does have. `buildLanguageNav` rebuilt every group as `{group, pages}` and called `group.pages.map(...)` unconditionally. The docs rebuild added `{group, expanded, openapi}` — a group whose content is an OpenAPI spec and has no pages at all — so `--update-nav` died with `TypeError: undefined is not an object`, **after 784 pages had already been translated**, taking the whole nightly run with it for the second night running. Groups are now rebuilt by spreading the English group, so `expanded`, `icon` and `openapi` survive instead of being silently discarded from every non-English nav; a group with no pages is carried through untouched (the spec is not translated, and dropping it would remove the API reference from thirteen languages); and `pages` entries that are themselves nested groups recurse rather than being prefixed as if they were paths. `pages` is optional on the type now, which is what it always was in the data. (#725) - -- Stop one bad page discarding a whole night's translation, and resample the draw that caused it. On 2026-08-18 the nightly job translated 782 pages, hit ONE failure, exited 1, and pushed nothing — 2.7M tokens in the bin over a single page. Two things were wrong. **`--allow-partial`** (used by the box job, off everywhere else) publishes what succeeded and carries the failures into the PR body, the Slack note and the run stamp, because the real hazard of a partial publish is a PR that looks complete. And the failure itself was misdiagnosed: `reference/cloud-cli.mdx [vi]` is a 16 KB source that emitted 64 000 output tokens, which is a repetition loop, not a page too large to translate. `stop_reason: "max_tokens"` was treated as a size problem no resample could fix, so it propagated uncaught and consumed no attempt. It is now split by the one signal that separates the two — output against the source's token estimate — and a runaway is retried through the existing validity loop with feedback telling the model to translate once and stop, while a source that genuinely approaches the ceiling keeps failing loudly rather than burning three attempts to arrive at the same place. (#725) - **Retry `bun run build` on the release path, which is the half that never had a net.** `bun --bun next build` is a demonstrated flake, and on 2026-08-19 it proved it in the worst place: publishing v1.0.1, bun 1.3.14 took a SIGSEGV during the TypeScript phase — `oh no: Bun has crashed. This indicates a bug in Bun, not your code` — exited 132, and took `release-assets`, `publish`, `verify-install` and `announce` down with it, all skipped. Nothing about the code being released was wrong; the identical command had passed on the identical commit in `ci.yml` minutes earlier, which is the definition of a retryable failure. `ci.yml`'s `build` job has wrapped this in three attempts since it was written, and publish.yml's two `bun run build` steps — `cli-tarball`'s and `publish`'s — had none, so the path where a spurious failure costs the most was the one without protection. The second one matters more than the first: by the time `publish` builds, the release assets are already attached, so a crash there leaves a GitHub Release advertising daemon binaries whose npm package never shipped. Both are retried now, and `release-pipeline.test.ts` asserts it rather than trusting anyone to remember — a bare `run: bun run build` anywhere in publish.yml fails the suite. (#728) - **Stop CI paying for work it throws away, and stop a stalled apt mirror holding a release for six hours.** Four costs, found by measuring a green run rather than a red one. **`bun install` was running a full Next.js production build**: `package.json`'s `prepare` is `bun run build`, which bun fires as an install lifecycle hook, so six of `ci.yml`'s eight jobs spent ~28s (14s compile + 13s TypeScript) building an application they never read — and the `build` job did it twice, since its own Build step then re-ran the same thing warm in 7s. `rust-quality` has passed `--ignore-scripts` since it landed and installs in **one second**, which is the control that proves the rest; `translate-docs.yml` already guards the same way with a comment naming this exact hazard. Every install now does. **The cargo cache cost more to move than the work it replaced**: one `cargo-Linux-*` entry had reached **5,727 MB** — 57% of the repo's entire 10 GiB quota in a single key, which is the LRU-eviction pressure the previous fix here was about and did not remove — and restoring it took **127 seconds** against the 74s `cargo test` it existed to avoid. The cause is `path: target` taken literally: it archives every intermediate the workspace ever produced, including this workspace's own crates, which recompile in seconds and are the artifacts most likely to be stale. `Swatinem/rust-cache` caches the dependency artifacts and prunes the rest. **`rust-quality` ran in full on every pull request**, including the many that touch no Rust; its `Detect crates` gate was written for a stage-1 empty workspace and, with all three crates present, had been answering `true` unconditionally for months. It now also diffs the merge commit against its first parent, so it still reports a status — no `needs:` edge, nothing serialised behind it — while finishing in seconds on a TypeScript-only branch. `docs` gained the same gate, and its `mintlify` install is pinned to `4.2.680` to match `translate-docs.yml`, where floating meant an upstream release could redden a branch that changed nothing. **And 190 of 208 unit test files were building a jsdom they never touched**: the config set `environment: "jsdom"` globally for the sake of 16 React files and two more that already opt in per-file, and the `test` matrix runs the suite three times, so it was paid three times per run. Split into `node`/`dom` projects on the file extension, jsdom construction drops from **40.96s to 13.67s** measured locally, and a new `.test.tsx` still gets a DOM without anyone remembering to ask. The release hang is the same story in one step: **nothing in this repo had a job timeout except `integration-suite.yml`**, so when the linux-x64 daemon leg hit a stalled Azure mirror on 2026-08-19 it sat in `apt-get update` through three runner re-dispatches with v1.0.1 blocked behind it, while the arm64 leg ran the identical step in seconds. Every job across five workflows now declares one; the apt step is retried and given real acquire timeouts (its defaults are long enough to be no timeout at all against a stall) and loses its `-qq`, which was suppressing the one thing worth having in the log — which mirror stalled. `build-daemon.yml` also gains a concurrency group, scoped to `pull_request` so the `workflow_call` legs that *are* a release's binaries are never cancelled. `musl-tools` itself stays: `-p failproofaid` reaches `rusqlite` with `bundled` and `ring` through rustls, so the `cc` crate needs `musl-gcc` on both musl legs. **The bound is `sudo timeout`, not `nick-fields/retry`**, and that distinction is the whole fix rather than a style choice — the retry action was tried first and CI rejected it: it bounds a step by killing the process tree **as the runner user**, and apt runs as root, so the four-minute timeout fired exactly as designed and the action then died with `kill EPERM` instead of retrying, converting a recoverable stall into a failed leg. `timeout` inside the `sudo` makes the killer root too. The same run also showed the stall is real and not a one-off: every `azure.archive.ubuntu.com` line came back `Ign`, apt fell back to `archive.ubuntu.com`, fetched the InRelease files and then sat for three and a half minutes emitting nothing — so the step now tries `apt-get install` **before** `apt-get update` at all, since the refresh is the part that stalls and the runner image's package lists usually make it unnecessary. Dropping the `prepare` hook does cost the `test` job one thing it was silently getting: the custom-policy loader tests resolve `import ... from 'failproofai'` through `findDistIndex()` and need a real `dist/index.js`, so the job now builds that one bundle explicitly — three milliseconds against the ~28s it replaces. Worth recording how that surfaced, because the check that should have caught it did not: running the **whole suite** with `dist/` moved aside passes, since an earlier test writes the file before the loader tests read it, and only running them alone fails. Test-order luck read as a clean bill of health. None of these regressions turns CI red on its own either, which is why `release-pipeline.test.ts` now asserts all four — the timeouts, the `--ignore-scripts`, the absence of a bare `target` cache path, and an apt step that is bounded by a killer running as the same user apt does. Net: **~4.0 min wall and ~15.7 runner-minutes per pull request down to ~1.7 min and ~9**, with ~5 GiB of cache quota returned. (#726) @@ -486,7 +491,6 @@ never "blocked". ## 1.0.0-beta.8 — 2026-08-06 - ### Features - Add `failproofai uninstall` — the sanctioned way off a machine. npm runs no uninstall script, so `npm rm -g failproofai` deletes the package and leaves behind everything durable it installed: hook entries in up to twelve agent CLIs' settings files and a root-owned systemd unit. Those leftovers are not inert — the hook entries invoke `npx -y failproofai`, which re-downloads the package, so a "removed" failproofai keeps running on every tool call; and on a `daemonConfigured` machine the surviving unit points at a worker script npm just deleted, which under fail-closed semantics denies EVERY tool call with nothing on screen naming the cause. The command clears `daemonConfigured` **first**, before hooks and before the service, so a partial uninstall can only ever fail open — the intuitive order leaves a window where the flag demands a daemon that is already gone, and that window is a total agent lockout. `--purge` also deletes `~/.failproofai`; `--dry-run` shows the plan; `--yes` skips the prompt, which is required rather than assumed when there is no TTY. Incomplete cleanup exits non-zero and prints the exact `sudo` commands to finish, and `--purge` suppresses the command's own telemetry — resolving an instance id lazily WRITES `state/telemetry-id`, which re-created the whole directory seconds after deleting it and left a just-wiped machine holding a brand-new tracking identifier. (#694) @@ -505,7 +509,6 @@ never "blocked". - Say which fault the probe actually hit. `DaemonFailure` reports `unreachable` for BOTH a refused connection and a request that was accepted and never answered, so setup told people their worker would not start when nothing was listening at all — sending them to inspect a healthy process. `probeDaemon` now distinguishes "never accepted a connection" from "accepted, but could not answer a hook", and the wizard prints the matching remedy. (#694) - ### Chores - Add `scripts/repro-npm-install.sh`, which reproduces a real user install in the shape that actually breaks: `npm i -g` into a ROOT-owned prefix, then the CLI run by an unprivileged user, with real systemd in the container. A single-user laptop cannot exercise that split — its npm prefix is owned by the person running the hooks — which is how the root-owned policy-shim fail-open shipped. It also guards two traps found while writing it: cgroup v2 needs `--cgroupns=host` plus tmpfs mounts or the container exits 255 with an empty `docker logs`, and `npm pack --ignore-scripts` skips the `prepare` rebuild, so the script asserts the version inside the tarball rather than the one in `package.json`. diff --git a/__tests__/scripts/translate-docs/mintlify-nav.test.ts b/__tests__/scripts/translate-docs/mintlify-nav.test.ts index b1e4ff155..223251f6e 100644 --- a/__tests__/scripts/translate-docs/mintlify-nav.test.ts +++ b/__tests__/scripts/translate-docs/mintlify-nav.test.ts @@ -291,6 +291,28 @@ describe("localizeProductsNavigation", () => { expect((outer.pages![1] as { pages?: string[] }).pages![0]).toBe("ja/deep"); }); + it("recurses through a group's `groups`, not only through its `pages`", () => { + // A group may nest via `groups` as well as inside `pages`, and those are two + // separate branches in localizeGroup. Deleting the `groups` branch entirely + // left the whole suite green, so this covers it independently. + const tabs = [ + { + tab: "Docs", + groups: [ + { + group: "Outer", + pages: ["top"], + groups: [{ group: "Nested", pages: ["deep"] }], + }, + ], + }, + ]; + const ko = buildLanguageNav(tabs as never, "ko"); + const outer = ko.tabs[0].groups[0]; + expect(outer.pages![0]).toBe("ko/top"); + expect(outer.groups![0].pages![0]).toBe("ko/deep"); + }); + it("uses Mintlify's canonical Portuguese locale with existing paths", () => { const portuguese = buildLanguageNav(sampleEnglishTabs, "pt-br"); diff --git a/__tests__/scripts/translate-docs/translator.test.ts b/__tests__/scripts/translate-docs/translator.test.ts index d3aa752a5..c96584d60 100644 --- a/__tests__/scripts/translate-docs/translator.test.ts +++ b/__tests__/scripts/translate-docs/translator.test.ts @@ -44,6 +44,37 @@ describe("translateContent", () => { streamMock.mockReset(); }); + it("ignores a non-positive TRANSLATE_RUNAWAY_RATIO", async () => { + // `parseInt(...) || 6` accepted a negative, and a negative ratio makes + // `output > source * ratio` true for EVERY response — so a genuinely + // oversized page would be misread as a runaway and burn all three attempts + // arriving exactly where it started. Re-imported under the hostile value: + // the guard must fall back to the default and still call this NOT retryable. + const prev = process.env.TRANSLATE_RUNAWAY_RATIO; + process.env.TRANSLATE_RUNAWAY_RATIO = "-1"; + vi.resetModules(); + try { + const fresh = await import("@/scripts/translate-docs/translator"); + mockFinalMessage({ + stop_reason: "max_tokens", + content: [{ type: "text", text: "big…" }], + usage: { input_tokens: 60000, output_tokens: 64000 }, + }); + let err!: Error & { retryable?: boolean }; + try { + await fresh.translateContent("y".repeat(200000), "vi", "Vietnamese"); + } catch (e) { + err = e as Error & { retryable?: boolean }; + } + expect(err.retryable).toBe(false); + expect(err.message).toMatch(/source too large/); + } finally { + if (prev === undefined) delete process.env.TRANSLATE_RUNAWAY_RATIO; + else process.env.TRANSLATE_RUNAWAY_RATIO = prev; + vi.resetModules(); + } + }); + it("marks a truncation whose output dwarfs the source as retryable", async () => { // reference/cloud-cli.mdx [vi], on the box, 2026-08-18: a 16 KB source // emitted 64 000 output tokens. That is a repetition loop, not a page too diff --git a/scripts/translate-docs/translator.ts b/scripts/translate-docs/translator.ts index 53b270c84..5c8166e17 100644 --- a/scripts/translate-docs/translator.ts +++ b/scripts/translate-docs/translator.ts @@ -48,7 +48,19 @@ const MAX_ATTEMPTS = // repetition loop rather than a large page. Translations of the verbose targets // (hi, vi, ja) sit near 2x the source's token estimate; 6x is far outside that // band and was ~16x in the observed failure. -const RUNAWAY_RATIO = Number.parseInt(process.env.TRANSLATE_RUNAWAY_RATIO ?? "", 10) || 6; +// Validated the same way MAX_TOKENS and MAX_ATTEMPTS above are, and for a +// sharper reason: `parseInt(...) || 6` accepts a NEGATIVE, and a negative ratio +// makes `output > source * ratio` true for every response — so a genuinely +// oversized page would be classified as a runaway and burn all three attempts +// to arrive exactly where it started. +const parsedRunawayRatio = Number.parseInt( + process.env.TRANSLATE_RUNAWAY_RATIO ?? "", + 10, +); +const RUNAWAY_RATIO = + Number.isInteger(parsedRunawayRatio) && parsedRunawayRatio > 0 + ? parsedRunawayRatio + : 6; function getClient(): Anthropic { if (!client) { From e07f98d868249d2ac0aa86aece1c64c23600bb59 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Wed, 19 Aug 2026 18:04:44 +0530 Subject: [PATCH 5/5] Keep the nav to pages that exist, so a partial run can actually publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --allow-partial published what succeeded, then --update-nav regenerated the nav from the English tree and emitted an entry for the failed page in the language that failed it. mintlify validate rejected the missing file and the job died before its push, discarding the 784 pages that had translated — the exact loss --allow-partial exists to prevent. Nav generation now omits a localized page whose file is absent, prunes a group left with no pages and a tab left with no groups, and keeps an openapi group that never had pages. The existence check is injected, so the pure transform stays testable and both docs.json writers get the real one. This closes the hazard from every direction it can arrive: a failed page, a pruned page, or a translation that only exists on an unmerged branch. --- CHANGELOG.md | 2 + .../translate-docs/mintlify-nav.test.ts | 51 +++++++++++++ scripts/translate-docs/mintlify-nav.ts | 72 +++++++++++++++---- 3 files changed, 111 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c74c313a..90c0ea9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixes +- Stop the localized navigation referencing pages that were never translated, which is what still discarded a partial run. `--allow-partial` published what succeeded — and then `--update-nav` regenerated the nav from the ENGLISH tree, emitting an entry for the failed page in the language that failed it, so `mintlify validate` rejected the missing file and the job died before its push anyway. The 784 pages that HAD translated went with it, which is precisely the loss `--allow-partial` exists to prevent. Nav generation now omits any localized page whose file is not on disk, prunes a group left with no pages and a tab left with no groups, and keeps an `openapi` group that never had pages to begin with. The check is injected rather than hardcoded, so the pure transform stays testable and the two paths that actually write `docs.json` get the real one. This also closes the same hazard from every other direction it can arrive from — a pruned page, or a translation that only exists on an unmerged branch — because the nav is now derived from what is present rather than from what English says should be. (#725) + - Stop the localized nav crashing on a group that has no pages, and stop it dropping the properties it does have. `buildLanguageNav` rebuilt every group as `{group, pages}` and called `group.pages.map(...)` unconditionally. The docs rebuild added `{group, expanded, openapi}` — a group whose content is an OpenAPI spec and has no pages at all — so `--update-nav` died with `TypeError: undefined is not an object`, **after 784 pages had already been translated**, taking the whole nightly run with it for the second night running. Groups are now rebuilt by spreading the English group, so `expanded`, `icon` and `openapi` survive instead of being silently discarded from every non-English nav; a group with no pages is carried through untouched (the spec is not translated, and dropping it would remove the API reference from thirteen languages); and `pages` entries that are themselves nested groups recurse rather than being prefixed as if they were paths. `pages` is optional on the type now, which is what it always was in the data. (#725) - Stop one bad page discarding a whole night's translation, and resample the draw that caused it. On 2026-08-18 the nightly job translated 782 pages, hit ONE failure, exited 1, and pushed nothing — 2.7M tokens in the bin over a single page. Two things were wrong. **`--allow-partial`** (used by the box job, off everywhere else) publishes what succeeded and carries the failures into the PR body, the Slack note and the run stamp, because the real hazard of a partial publish is a PR that looks complete. And the failure itself was misdiagnosed: `reference/cloud-cli.mdx [vi]` is a 16 KB source that emitted 64 000 output tokens, which is a repetition loop, not a page too large to translate. `stop_reason: "max_tokens"` was treated as a size problem no resample could fix, so it propagated uncaught and consumed no attempt. It is now split by the one signal that separates the two — output against the source's token estimate — and a runaway is retried through the existing validity loop with feedback telling the model to translate once and stop, while a source that genuinely approaches the ceiling keeps failing loudly rather than burning three attempts to arrive at the same place. (#725) diff --git a/__tests__/scripts/translate-docs/mintlify-nav.test.ts b/__tests__/scripts/translate-docs/mintlify-nav.test.ts index 223251f6e..e366b29f8 100644 --- a/__tests__/scripts/translate-docs/mintlify-nav.test.ts +++ b/__tests__/scripts/translate-docs/mintlify-nav.test.ts @@ -232,6 +232,7 @@ describe("localizeProductsNavigation", () => { }, ], ["es", "ja"], + () => true, ); expect(product.tabs).toBeUndefined(); @@ -313,6 +314,56 @@ describe("localizeProductsNavigation", () => { expect(outer.groups![0].pages![0]).toBe("ko/deep"); }); + it("omits a page whose localized file is missing, and keeps the rest", () => { + // THE PARTIAL-RUN CASE. One page fails to translate for one language; the + // English tree still lists it, so the nav used to emit `vi/reference/cloud-cli` + // regardless, `mintlify validate` rejected the missing file, and the job died + // before its push — discarding 784 pages that HAD translated. The entry is + // dropped instead: the page simply does not exist in that language yet. + const tabs = [ + { + tab: "Docs", + groups: [{ group: "Reference", pages: ["index", "reference/cloud-cli"] }], + }, + ]; + const missing = "vi/reference/cloud-cli.mdx"; + const vi = buildLanguageNav(tabs as never, "vi", (rel) => rel !== missing); + + expect(vi.tabs[0].groups[0].pages).toEqual(["vi/index"]); + // and the language that DID translate it keeps it + const zh = buildLanguageNav(tabs as never, "zh", () => true); + expect(zh.tabs[0].groups[0].pages).toEqual(["zh/index", "zh/reference/cloud-cli"]); + }); + + it("drops a group left with no pages, and a tab left with no groups", () => { + // Filtering can empty a group, and an empty group is its own validation + // error — so the pruning has to go all the way up. + const tabs = [ + { tab: "Solo", groups: [{ group: "Only", pages: ["gone"] }] }, + { tab: "Mixed", groups: [{ group: "Kept", pages: ["here"] }, { group: "Empty", pages: ["gone2"] }] }, + ]; + const nav = buildLanguageNav(tabs as never, "ja", (rel) => !rel.includes("gone")); + + expect(nav.tabs.map((t) => t.tab)).toEqual(["Mixed"]); + expect(nav.tabs[0].groups.map((g) => g.group)).toEqual(["Kept"]); + }); + + it("keeps an openapi group even though it has no pages to check", () => { + // hasContent must not confuse "emptied by filtering" with "never had pages". + const tabs = [ + { + tab: "Reference", + groups: [ + { group: "HTTP API", expanded: false, openapi: "reference/openapi.json" }, + { group: "Guides", pages: ["gone"] }, + ], + }, + ]; + const de = buildLanguageNav(tabs as never, "de", () => false); + expect(de.tabs[0].groups.map((g) => g.group)).toEqual(["HTTP API"]); + expect(de.tabs[0].groups[0].openapi).toBe("reference/openapi.json"); + }); + it("uses Mintlify's canonical Portuguese locale with existing paths", () => { const portuguese = buildLanguageNav(sampleEnglishTabs, "pt-br"); diff --git a/scripts/translate-docs/mintlify-nav.ts b/scripts/translate-docs/mintlify-nav.ts index 1182c747a..4e7b058bb 100644 --- a/scripts/translate-docs/mintlify-nav.ts +++ b/scripts/translate-docs/mintlify-nav.ts @@ -1,10 +1,11 @@ -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { getLanguageByCode, NAV_TRANSLATIONS } from "./config"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const DOCS_JSON_PATH = join(__dirname, "..", "..", "docs", "docs.json"); +const DOCS_DIR = join(__dirname, "..", "..", "docs"); +const DOCS_JSON_PATH = join(DOCS_DIR, "docs.json"); interface NavGroup { group: string; @@ -85,9 +86,33 @@ export function getNavigationPageReferences( * Build a navigation entry for a specific language by transforming the * English navigation structure. */ +/** + * Does the localized file for a nav entry exist on disk? + * + * Defaults to "yes" so the pure transform stays testable with fixtures, and the + * REAL check is injected by updateDocsJson / localizeProductsNavigation, which + * are the two paths that write docs.json. + */ +export type PageExists = (relativePath: string) => boolean; + +const fileOnDisk: PageExists = (rel) => existsSync(join(DOCS_DIR, rel)); + +/** + * Keep a group only if it still carries something to render. Dropping every + * missing page can empty a group, and an empty group is its own validation + * error — but a group whose content is an `openapi` spec has no pages by + * design and must survive. + */ +function hasContent(group: NavGroup): boolean { + if (Array.isArray(group.pages) && group.pages.length > 0) return true; + if (Array.isArray(group.groups) && group.groups.length > 0) return true; + return !Array.isArray(group.pages) && !Array.isArray(group.groups); +} + export function buildLanguageNav( englishTabs: NavTab[], lang: string, + exists: PageExists = () => true, ): LanguageNav { const t = NAV_TRANSLATIONS[lang]; if (!t) throw new Error(`No nav translations for language: ${lang}`); @@ -114,12 +139,25 @@ export function buildLanguageNav( const out: NavGroup = { ...group, group: groupNameMap[group.group] || group.group }; if (Array.isArray(group.pages)) { // A page entry is a path to prefix, or a nested group to recurse into. - out.pages = group.pages.map((page) => - typeof page === "string" ? `${lang}/${page}` : localizeGroup(page), - ); + // + // A path is DROPPED when its localized file is not on disk. The English + // tree is the source of the nav, so without this every English page gets + // an entry in all fourteen languages whether or not it was translated — + // and `mintlify validate` rejects the run. That took down a nightly job + // that had already translated 784 pages: one page failed, the nav still + // referenced it in that language, and every successful page was discarded + // with it. Omitting the entry is the honest state: the page does not + // exist in that language yet. + out.pages = group.pages + .map((page) => + typeof page === "string" ? `${lang}/${page}` : localizeGroup(page), + ) + .filter((page) => + typeof page === "string" ? exists(`${page}.mdx`) : hasContent(page), + ); } if (Array.isArray(group.groups)) { - out.groups = group.groups.map(localizeGroup); + out.groups = group.groups.map(localizeGroup).filter(hasContent); } // No pages and no groups (an `openapi` group): carried through as-is. The // spec is not translated, and dropping the group would remove the API @@ -127,11 +165,13 @@ export function buildLanguageNav( return out; }; - const tabs: NavTab[] = englishTabs.map((tab) => ({ - ...tab, - tab: tabNameMap[tab.tab] || tab.tab, - groups: (tab.groups ?? []).map(localizeGroup), - })); + const tabs: NavTab[] = englishTabs + .map((tab) => ({ + ...tab, + tab: tabNameMap[tab.tab] || tab.tab, + groups: (tab.groups ?? []).map(localizeGroup).filter(hasContent), + })) + .filter((tab) => tab.groups.length > 0); return { language: getLanguageByCode(lang)?.mintlifyCode ?? lang, @@ -153,10 +193,11 @@ export function readDocsConfig(): Record { export function generateLanguagesArray( englishTabs: NavTab[], langCodes: string[], + exists: PageExists = () => true, ): LanguageNav[] { // English first (default) const english: LanguageNav = { language: "en", tabs: englishTabs }; - const others = langCodes.map((code) => buildLanguageNav(englishTabs, code)); + const others = langCodes.map((code) => buildLanguageNav(englishTabs, code, exists)); return [english, ...others]; } @@ -164,6 +205,9 @@ export function generateLanguagesArray( export function localizeProductsNavigation( products: unknown[], langCodes: string[], + // Defaults to the real disk check, like updateDocsJson — injectable so the + // pure transform can be exercised against fixtures that are not on disk. + exists: PageExists = fileOnDisk, ): Record[] { return products.map((value) => { const product = value as Record; @@ -177,7 +221,7 @@ export function localizeProductsNavigation( const { tabs: _tabs, ...rest } = product; return { ...rest, - languages: generateLanguagesArray(englishTabs, langCodes), + languages: generateLanguagesArray(englishTabs, langCodes, exists), }; }); } @@ -205,7 +249,7 @@ export function updateDocsJson(langCodes: string[]): void { } const newNav: Record = { - languages: generateLanguagesArray(englishTabs, langCodes), + languages: generateLanguagesArray(englishTabs, langCodes, fileOnDisk), }; if (nav.global) { newNav.global = nav.global;