Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 1.0.2-beta.0 — 2026-08-19

### 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)
Comment thread
chhhee10 marked this conversation as resolved.

- 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
Expand Down Expand Up @@ -483,7 +493,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)

Expand All @@ -502,7 +511,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`.

Expand Down
20 changes: 20 additions & 0 deletions __tests__/integration-suite/local-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
119 changes: 117 additions & 2 deletions __tests__/scripts/translate-docs/mintlify-nav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ describe("localizeProductsNavigation", () => {
},
],
["es", "ja"],
() => true,
);

expect(product.tabs).toBeUndefined();
Expand All @@ -244,15 +245,129 @@ 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");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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("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");

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");
});
});
107 changes: 102 additions & 5 deletions __tests__/scripts/translate-docs/translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,76 @@ 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
// 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`.
Expand Down Expand Up @@ -210,26 +280,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 () => {
Expand Down
Loading
Loading