diff --git a/.changeset/fix-genui-api-extractor-dist-race.md b/.changeset/fix-genui-api-extractor-dist-race.md new file mode 100644 index 0000000000..92a5362ede --- /dev/null +++ b/.changeset/fix-genui-api-extractor-dist-race.md @@ -0,0 +1,5 @@ +--- + +--- + +Fix a flaky CI failure in genui API extraction where `@lynx-js/genui-cli`'s `tsc` (and `@lynx-js/genui#api-extractor`) could fail with `TS2307` / `TS7016` ("Cannot find module `@lynx-js/genui-a2ui-prompt`"). `run-api-extractor.mjs` rebuilt each package in-script (`pnpm run build`), rewriting its `dist/` while turbo-scheduled consumer builds read the same `dist/`, so `tsc` could observe `index.js` without its freshly-cleaned `index.d.ts`. The script no longer builds — turbo's task graph builds each package, and the api-extractor task now depends on the package build for the rust-free genui packages — and the file lock that only existed to serialize those in-script builds is removed. diff --git a/packages/genui/a2ui-catalog-extractor/turbo.json b/packages/genui/a2ui-catalog-extractor/turbo.json index 294effadb6..0e27bb3695 100644 --- a/packages/genui/a2ui-catalog-extractor/turbo.json +++ b/packages/genui/a2ui-catalog-extractor/turbo.json @@ -33,6 +33,13 @@ "outputs": [ "dist/**" ] + }, + "api-extractor": { + "dependsOn": [ + "//#build", + "build" + ], + "cache": false } } } diff --git a/packages/genui/a2ui-prompt/package.json b/packages/genui/a2ui-prompt/package.json index c98ad3045d..3b9b1c7690 100644 --- a/packages/genui/a2ui-prompt/package.json +++ b/packages/genui/a2ui-prompt/package.json @@ -23,8 +23,7 @@ ], "scripts": { "api-extractor": "node ../scripts/run-api-extractor.mjs", - "build": "rslib build", - "build:api": "rslib build" + "build": "rslib build" }, "devDependencies": { "@microsoft/api-extractor": "catalog:", diff --git a/packages/genui/a2ui-prompt/turbo.json b/packages/genui/a2ui-prompt/turbo.json index c56f63f014..6adbe71600 100644 --- a/packages/genui/a2ui-prompt/turbo.json +++ b/packages/genui/a2ui-prompt/turbo.json @@ -23,24 +23,12 @@ "dist/**" ] }, - "build:api": { + "api-extractor": { "dependsOn": [ + "//#build", "build" ], - "inputs": [ - "src/**", - "../server/agent/a2ui-catalog.ts", - "../server/agent/a2ui-examples.ts", - "../server/agent/a2ui-prompt.ts", - "../server/agent/catalog/**/*.json", - "package.json", - "rslib.config.ts", - "tsconfig.build.json", - "tsconfig.json" - ], - "outputs": [ - "dist/**" - ] + "cache": false } } } diff --git a/packages/genui/scripts/run-api-extractor.mjs b/packages/genui/scripts/run-api-extractor.mjs index 0c9649e786..65c2a29455 100644 --- a/packages/genui/scripts/run-api-extractor.mjs +++ b/packages/genui/scripts/run-api-extractor.mjs @@ -3,63 +3,14 @@ // LICENSE file in the root directory of this source tree. import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { open, readFile, rm } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; -const genuiRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const lockPath = join(genuiRoot, '.api-extractor.lock'); -const lockTimeoutMs = 10 * 60 * 1000; const entryPointTimeoutMs = 5 * 1000; const retryDelayMs = 500; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const isProcessAlive = (pid) => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -}; - -const acquireLock = async () => { - const start = Date.now(); - - while (Date.now() - start < lockTimeoutMs) { - try { - const file = await open(lockPath, 'wx'); - await file.writeFile(JSON.stringify({ - cwd: process.cwd(), - pid: process.pid, - startedAt: new Date().toISOString(), - })); - await file.close(); - return; - } catch (error) { - if (error?.code !== 'EEXIST') { - throw error; - } - - try { - const current = JSON.parse(await readFile(lockPath, 'utf8')); - if (typeof current.pid === 'number' && !isProcessAlive(current.pid)) { - await rm(lockPath, { force: true }); - continue; - } - } catch { - await rm(lockPath, { force: true }); - continue; - } - - await sleep(retryDelayMs); - } - } - - throw new Error(`Timed out waiting for ${lockPath}`); -}; - const run = (command, args) => { const result = spawnSync(command, args, { shell: process.platform === 'win32', @@ -127,14 +78,14 @@ const ensureMainEntryPoint = async () => { ); }; -await acquireLock(); - -try { - run('pnpm', ['run', 'build']); - await ensureMainEntryPoint(); - run('api-extractor', ['run', '--verbose']); -} finally { - if (existsSync(lockPath)) { - await rm(lockPath, { force: true }); - } -} +// No lock is needed: turbo's task graph builds each package before its +// `api-extractor` task (which depends on `build`) and before every consumer +// build, so api-extractor only ever reads a finished `dist/`. Do NOT build +// here — a rebuild would re-clean and rewrite `dist/` while turbo-scheduled +// consumer builds (e.g. `genui-cli#build`) read the same `dist/`, transiently +// removing the `.d.ts` and breaking their `tsc` (TS2307/TS7016). +// `ensureMainEntryPoint` stays only as a last-resort build if the entry point +// is somehow missing. Concurrent api-extractor runs across packages touch only +// their own per-package outputs, so they need no mutual exclusion. +await ensureMainEntryPoint(); +run('api-extractor', ['run', '--verbose']); diff --git a/packages/genui/turbo.json b/packages/genui/turbo.json index f25fb8c151..5d6532af58 100644 --- a/packages/genui/turbo.json +++ b/packages/genui/turbo.json @@ -41,7 +41,7 @@ "//#build", "@lynx-js/genui-a2ui#build:api", "@lynx-js/genui-a2ui-catalog-extractor#build:api", - "@lynx-js/genui-a2ui-prompt#build:api", + "@lynx-js/genui-a2ui-prompt#build", "@lynx-js/genui-openui#build:api" ], "cache": false