From 63cf354129c339ebb66a825ef6017830c2c02b13 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 8 Aug 2026 09:41:46 -0300 Subject: [PATCH] fix(docker): complete partially traced packages in standalone co-location (#9615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(docker): complete partially traced packages in standalone co-location Publish-to-Docker-Hub has failed on every release/v3.8.50 push since #9151 enabled publishing from active release branches: the post-build guard dies with "Cannot find module .../@atjsh/llmlingua-2/dist/index.js" while the co-location step right above it reports 100 packages copied. Root cause: Next's file tracing materializes @atjsh/llmlingua-2 PARTIALLY in the standalone (package.json lands, the dist/ payload its main points at does not). colocateOptionals' no-clobber checked existsSync on the package DIRECTORY, so the partial shell counted as present and the one package that mattered was skipped forever (#9185 added the closure walk but kept the directory-level check). Fix: presence is now judged by entrypoint integrity — the package resolves from inside the target tree (same contract as the Dockerfile guard). Partial directories are completed with a file-level no-clobber merge (cpSync force:false), so files the trace did materialize are never overwritten and pinned instances (dist transformers 3.5.2) keep their protection. Validation (TDD): 2 new tests in docker-llmlingua-optionals-9166.test.ts reproduce the CI failure (partial package skipped; closure-wide early-exit firing while a member is partial) — red on the old code, 5/5 green after. * fix: update colocate test mock packages to match isPackageIntact entrypoint resolution The PR's isPackageIntact check uses require.resolve to validate that co-located packages have a usable entrypoint inside the target tree. The pre-existing test's mock packages lacked main fields and index files, so require.resolve failed and the idempotency assertion broke. Update buildRoot() to give every closure package a resolvable entry (main + index.js), mirroring what real npm packages ship. Refs #9615 * docs(changelog): fragment for #9615 * fix(yuanbao-web): accept content field in SSE text events (upstream format change) (#8739) Closes #8739 * fix(errorClassifier): classify ChatGPT Web SENTINEL_BLOCKED 403 as FORBIDDEN, enabling combo fallback (#8813) Closes #8813 * fix(vertex): route Claude models to native rawPredict and respect targetFormat overrides (#8994) Closes #8994 * fix(cursor): preserve tool context across multi-turn conversations when client lacks conversation_id (#9029) Closes #9029 * fix(sse): move Antigravity client system content to first user message to avoid upstream 429 (#9030) Closes #9030 * fix(combo): distinguish pre-dispatch skips from genuine failures to prevent false 503 ALL_ACCOUNTS_INACTIVE (#9630) Closes #9630 * fix: repair stray brace in combo.ts and fix no-explicit-any types in repro-9630 test --------- Co-authored-by: diegosouzapw --- .../9615-docker-colocate-partial-trace.md | 1 + scripts/build/colocateOptionals.mjs | 48 +++++++-- tests/unit/colocate-optionals.test.ts | 33 +++++-- .../docker-llmlingua-optionals-9166.test.ts | 98 +++++++++++++++++++ 4 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 changelog.d/fixes/9615-docker-colocate-partial-trace.md diff --git a/changelog.d/fixes/9615-docker-colocate-partial-trace.md b/changelog.d/fixes/9615-docker-colocate-partial-trace.md new file mode 100644 index 0000000000..700a0e023e --- /dev/null +++ b/changelog.d/fixes/9615-docker-colocate-partial-trace.md @@ -0,0 +1 @@ +- **fix(docker):** standalone co-location now completes packages Next's file tracing materialized partially (package.json without its `main` payload) — unblocks the Docker Hub publish that failed on every v3.8.50 push with `Cannot find module '@atjsh/llmlingua-2/dist/index.js'` ([#9615](https://github.com/diegosouzapw/OmniRoute/pull/9615)) diff --git a/scripts/build/colocateOptionals.mjs b/scripts/build/colocateOptionals.mjs index 91548954b0..367a95c112 100644 --- a/scripts/build/colocateOptionals.mjs +++ b/scripts/build/colocateOptionals.mjs @@ -47,7 +47,8 @@ */ import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; +import { dirname, join, sep } from "node:path"; /** * Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is @@ -96,6 +97,33 @@ export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES) return closure; } +/** + * A package in the target tree counts as PRESENT only when its entrypoint + * resolves from inside that tree — the same contract the Dockerfile's + * post-build guard enforces. Next's file tracing can materialize a package + * PARTIALLY (the package.json lands, the files its `main` points at do not), + * and a directory-level `existsSync` check then skips the package forever + * while the runtime dies with "Cannot find module /dist/index.js". + * + * @param {string} targetNodeModulesDir + * @param {string} name + * @returns {boolean} + */ +function isPackageIntact(targetNodeModulesDir, name) { + if (!existsSync(join(targetNodeModulesDir, name))) return false; + try { + const probe = createRequire( + join(targetNodeModulesDir, "__colocate_probe__.js") + ); + const resolved = probe.resolve(name); + // A resolution that walked past the target into an ancestor tree does not + // prove the target copy is usable. + return resolved.startsWith(targetNodeModulesDir + sep); + } catch { + return false; + } +} + /** * Co-locate the SLM optional dependency closure from `/node_modules` * into a standalone bundle's `node_modules`. @@ -142,11 +170,12 @@ export function colocateLlmlinguaOptionals({ const closure = computeDependencyClosure(rootNm, seeds); - // Check the complete closure rather than only the entry package. A partially - // populated bundle must still receive any missing transitive dependencies. + // Check the complete closure rather than only the entry package, and judge + // presence by entrypoint integrity — a partially traced directory (see + // isPackageIntact) must still receive its missing files. if ( closure.length > 0 && - closure.every((name) => existsSync(join(targetNm, name))) + closure.every((name) => isPackageIntact(targetNm, name)) ) { return { skipped: true, reason: "already co-located" }; } @@ -155,11 +184,18 @@ export function colocateLlmlinguaOptionals({ for (const name of closure) { const dest = join(targetNm, name); - if (existsSync(dest)) continue; + if (isPackageIntact(targetNm, name)) continue; try { mkdirSync(dirname(dest), { recursive: true }); - cpSync(join(rootNm, name), dest, { recursive: true }); + // force:false merges into a partially traced directory: files the trace + // already materialized are kept, missing ones (the package payload) are + // filled in from the root tree. + cpSync(join(rootNm, name), dest, { + recursive: true, + force: false, + errorOnExist: false, + }); copied++; } catch (err) { log( diff --git a/tests/unit/colocate-optionals.test.ts b/tests/unit/colocate-optionals.test.ts index b9832c580b..2b0f864b6f 100644 --- a/tests/unit/colocate-optionals.test.ts +++ b/tests/unit/colocate-optionals.test.ts @@ -33,6 +33,11 @@ function mkPkg( * @tensorflow/tfjs → dep @tensorflow/tfjs-core → dep long * js-tiktoken → dep base64-js * @huggingface/transformers present at root as a (stale) 4.2.0 + * + * Each mock package gets a resolvable entrypoint so that isPackageIntact (which + * checks entrypoint integrity via require.resolve) can validate the co-located + * copy. The `main` field and corresponding index.js mirror what real npm + * packages ship. */ function buildRoot(rootDir: string): void { const rootNm = join(rootDir, "node_modules"); @@ -40,6 +45,7 @@ function buildRoot(rootDir: string): void { rootNm, "@atjsh/llmlingua-2", { + main: "dist/index.js", dependencies: { "es-toolkit": "^1.38.0" }, peerDependencies: { "@huggingface/transformers": "*", @@ -49,12 +55,27 @@ function buildRoot(rootDir: string): void { }, { "dist/index.js": "export const llmlingua = true;\n" } ); - mkPkg(rootNm, "es-toolkit", {}); - mkPkg(rootNm, "@tensorflow/tfjs", { dependencies: { "@tensorflow/tfjs-core": "4.22.0" } }); - mkPkg(rootNm, "@tensorflow/tfjs-core", { dependencies: { long: "^5.0.0" } }); - mkPkg(rootNm, "long", {}); - mkPkg(rootNm, "js-tiktoken", { dependencies: { "base64-js": "^1.5.1" } }); - mkPkg(rootNm, "base64-js", {}); + mkPkg(rootNm, "es-toolkit", { main: "index.js" }, { "index.js": "export const esToolkit = true;\n" }); + mkPkg( + rootNm, + "@tensorflow/tfjs", + { main: "index.js", dependencies: { "@tensorflow/tfjs-core": "4.22.0" } }, + { "index.js": "export const tfjs = true;\n" } + ); + mkPkg( + rootNm, + "@tensorflow/tfjs-core", + { main: "index.js", dependencies: { long: "^5.0.0" } }, + { "index.js": "export const tfjsCore = true;\n" } + ); + mkPkg(rootNm, "long", { main: "index.js" }, { "index.js": "export const long = true;\n" }); + mkPkg( + rootNm, + "js-tiktoken", + { main: "index.js", dependencies: { "base64-js": "^1.5.1" } }, + { "index.js": "export const tiktoken = true;\n" } + ); + mkPkg(rootNm, "base64-js", { main: "index.js" }, { "index.js": "export const base64 = true;\n" }); // Root transformers is the STALE 4.x line — the bug we must not propagate into dist. mkPkg(rootNm, "@huggingface/transformers", { version: "4.2.0" }); } diff --git a/tests/unit/docker-llmlingua-optionals-9166.test.ts b/tests/unit/docker-llmlingua-optionals-9166.test.ts index ba1f245743..da994ed5c4 100644 --- a/tests/unit/docker-llmlingua-optionals-9166.test.ts +++ b/tests/unit/docker-llmlingua-optionals-9166.test.ts @@ -55,6 +55,7 @@ function buildLlmlinguaRoot( rootNm, "@atjsh/llmlingua-2", { + main: "dist/index.js", dependencies: { "es-toolkit": "^1.38.0", }, @@ -227,6 +228,103 @@ test("#9166 standalone assembly never overwrites an already pinned transformers } }); +test("#9166 co-location completes a partially traced package (package.json without its main)", () => { + const root = mkdtempSync( + join(tmpdir(), "omniroute-docker-llmlingua-partial-9166-") + ); + + try { + buildLlmlinguaRoot(root); + const { distDir, standaloneDir } = createStandalone(root); + + // Next's file tracing materializes @atjsh/llmlingua-2 PARTIALLY in the + // standalone: the package.json lands (its "main" points at dist/index.js) + // but the dist/ payload does not — the exact state the Docker guard hits + // ("Cannot find module .../dist/index.js"). A directory-level no-clobber + // sees the dir and skips the package forever. + mkPkg(join(standaloneDir, "node_modules"), "@atjsh/llmlingua-2", { + main: "dist/index.js", + }); + + assembleStandalone({ + distDir, + outDir: standaloneDir, + projectRoot: root, + copyNatives: true, + }); + + assert.ok( + existsSync( + join( + standaloneDir, + "node_modules", + "@atjsh", + "llmlingua-2", + "dist", + "index.js" + ) + ), + "a partially traced package must be completed, not skipped as already present" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("#9166 co-location is not skipped when every closure dir exists but one is partial", () => { + const root = mkdtempSync( + join(tmpdir(), "omniroute-docker-llmlingua-partial-all-9166-") + ); + + try { + buildLlmlinguaRoot(root); + const { distDir, standaloneDir } = createStandalone(root); + const standaloneNm = join(standaloneDir, "node_modules"); + + // Every closure package already has a directory in the standalone (so a + // directory-level "already co-located" early-exit would fire), but the + // llmlingua-2 one is the partial NFT-trace shell without its main. + for (const packageName of [ + "es-toolkit", + "@tensorflow/tfjs", + "@tensorflow/tfjs-core", + "long", + "js-tiktoken", + "base64-js", + "@huggingface/transformers", + "onnxruntime-node", + ]) { + mkPkg(standaloneNm, packageName, { main: "index.js" }, { + "index.js": "export {};\n", + }); + } + mkPkg(standaloneNm, "@atjsh/llmlingua-2", { main: "dist/index.js" }); + + assembleStandalone({ + distDir, + outDir: standaloneDir, + projectRoot: root, + copyNatives: true, + }); + + assert.ok( + existsSync( + join( + standaloneDir, + "node_modules", + "@atjsh", + "llmlingua-2", + "dist", + "index.js" + ) + ), + "the closure-wide early-exit must not fire while any member is partial" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("#9166 Docker explicitly installs and validates LLMLingua optionals", () => { const dockerfile = readFileSync( new URL("../../Dockerfile", import.meta.url),