diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 3cf43712a1a..71f6971d9ba 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -4,6 +4,8 @@ import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { isWorkspacePackage, parseWorkspaceEntries } from '../build-standalone-public-packages.mjs'; import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from '../grok-public-install-sandbox.mjs'; const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 }; @@ -139,11 +141,58 @@ test('the bundled-dependency installs run together while the other packages are assert.ok(source.includes("destination: join(root, `pack-${index}`)"), 'each pack writes into its own directory'); // The timing line reports the split staging phase, each install, and the // pack step with what the packs would have cost one after another. - for (const key of ['stage_sources_ms', 'stage_bundled_ms', 'stage_pack_ms', 'stage_pack_check_ms']) assert.ok(source.includes(`timing.${key} =`)); + for (const key of ['stage_ui_dist_ms', 'stage_sources_ms', 'stage_bundled_ms', 'stage_pack_ms', 'stage_pack_check_ms']) assert.ok(source.includes(`timing.${key} =`)); assert.ok(source.includes('...bundledInstallTimings(installs)')); assert.ok(source.includes('Object.assign(timing, packTimings(packs, concurrency))')); }); +test('the verifier stages only workspace packages, so it skips the standalone build release.sh needs', () => { + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + const position = fragment => { const index = source.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; }; + // The standalone public packages are the ones outside the pnpm workspace + // (the sandbox-provider plugins). release.sh builds them because it publishes + // them; the verifier packs only the closure of the server and the CLI under + // `workspace:` specs, and a `workspace:` spec can only name a workspace + // package, so no tarball here ever contains or depends on a standalone build. + // The one reader of that build is qualification-package retention, which + // packs the Daytona plugin from its `dist`, so the build runs exactly when + // retention is requested: the single reference sits inside that gate, and + // the same flag is what later decides whether retention runs at all. + const standaloneBuild = "run(process.execPath, [join(repo, 'scripts/build-standalone-public-packages.mjs')], repo)"; + assert.equal(source.split('build-standalone-public-packages').length - 1, 1, 'the standalone build is referenced once, inside the retention gate'); + const gate = position("const retainQualificationPackages = Boolean(process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR);\n if (retainQualificationPackages) {\n const standaloneStarted = performance.now();\n " + standaloneBuild); + assert.ok(source.includes('timing.stage_standalone_build_ms ='), 'the gated build is timed under its own key'); + assert.ok(source.includes('if (retainQualificationPackages) {\n const retained = retainRunnerQualificationPackages({'), 'retention is gated by the same flag as the build'); + assert.doesNotMatch(source, /if \(process\.env\.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR\)/, 'no second reading of the retention flag can diverge from the build gate'); + const retention = readFileSync(new URL('../retain-runner-qualification-packages.mjs', import.meta.url), 'utf8'); + assert.ok(retention.includes("const pluginRelative = 'packages/plugins/sandbox-providers/daytona';") && retention.includes("cpSync(join(repo, pluginRelative, 'dist'), join(plugin, 'dist'), { recursive: true })"), 'retention still packs the Daytona plugin from its dist, which is why the gated build exists'); + const repo = new URL('../../', import.meta.url); + const listing = spawnSync(process.execPath, [fileURLToPath(new URL('scripts/release-package-map.mjs', repo)), 'list'], { cwd: fileURLToPath(repo), encoding: 'utf8' }); + assert.equal(listing.status, 0, listing.stderr); + const packages = new Map(listing.stdout.trim().split('\n').map(line => line.split('\t')).map(([dir, name]) => [name, { dir, manifest: JSON.parse(readFileSync(new URL(`${dir}/package.json`, repo), 'utf8')) }])); + const needed = new Set(); + const visit = name => { + if (needed.has(name)) return; + const entry = packages.get(name); assert.ok(entry, `missing public workspace dependency ${name}`); needed.add(name); + for (const [dep, spec] of Object.entries({ ...entry.manifest.dependencies, ...entry.manifest.optionalDependencies })) if (spec.startsWith('workspace:')) visit(dep); + }; + assert.ok(source.includes("visit('@paperclipai/server');\n visit('paperclipai');")); + visit('@paperclipai/server'); visit('paperclipai'); + const workspace = parseWorkspaceEntries(readFileSync(new URL('pnpm-workspace.yaml', repo), 'utf8')); + const standalone = [...packages.values()].filter(({ dir }) => !isWorkspacePackage(dir, workspace)).map(({ dir }) => dir); + assert.ok(standalone.length > 0, 'the public package map must still carry standalone packages for this check to mean anything'); + assert.ok(standalone.includes('packages/plugins/sandbox-providers/daytona'), 'the Daytona plugin retention packs is a standalone package, so pnpm build alone cannot produce its dist'); + for (const name of needed) assert.ok(isWorkspacePackage(packages.get(name).dir, workspace), `${name} is staged by the verifier but is not a workspace package`); + // The server publishes `ui-dist`, so that copy stays, before any package is + // staged, reusing the UI that `pnpm build` produced, and it is timed. The + // gated standalone build keeps release.sh's order: after the closure is + // known and before the UI copy and any staged source. + assert.deepEqual(JSON.parse(readFileSync(new URL('server/package.json', repo), 'utf8')).files, ['dist', 'ui-dist', 'skills']); + assert.ok(source.includes("PAPERCLIP_RELEASE_REUSE_UI_DIST: '1'")); + const uiDist = position("run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo)"); + assert.ok(position("visit('paperclipai')") < gate && gate < uiDist && uiDist < position('timing.stage_ui_dist_ms =') && uiDist < position('const stageSource = ')); +}); + test('the installed Codex probe exercises the public export and rejects incomplete or mismatched packages', () => { const root = realpathSync(mkdtempSync(join(tmpdir(), 'installed-codex-probe-'))); try { diff --git a/scripts/build-standalone-public-packages.mjs b/scripts/build-standalone-public-packages.mjs index 7b5e6b4902b..db26ccf7564 100644 --- a/scripts/build-standalone-public-packages.mjs +++ b/scripts/build-standalone-public-packages.mjs @@ -17,7 +17,7 @@ const repoRoot = path.resolve(scriptDir, ".."); const workspacePath = path.join(repoRoot, "pnpm-workspace.yaml"); const releasePackageMapPath = path.join(repoRoot, "scripts", "release-package-map.mjs"); -function parseWorkspaceEntries(workspaceText) { +export function parseWorkspaceEntries(workspaceText) { // Keep this aligned with the repo's block-sequence `packages:` format in // pnpm-workspace.yaml. If that file moves to a more complex YAML shape, // switch this parser to a real YAML parser instead of line matching. @@ -61,7 +61,7 @@ function globToRegExp(pattern) { return new RegExp(`^${regex}$`); } -function isWorkspacePackage(pkgDir, workspaceEntries) { +export function isWorkspacePackage(pkgDir, workspaceEntries) { let included = false; for (const entry of workspaceEntries) { diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index c7276a60b5c..524b76b86d5 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -64,10 +64,30 @@ try { } visit('@paperclipai/server'); visit('paperclipai'); + // release.sh also builds the standalone public packages (the sandbox-provider + // plugins outside the pnpm workspace) before it stages anything, because it + // publishes them. Nothing staged here reaches them: `needed` is closed under + // `workspace:` specs, so every package staged below is a workspace package + // that `pnpm build` already built, and that build would only cost one + // network install and compile per plugin whose output no tarball reads. + // Canary Dry Run still runs it through release.sh. The one path that does + // read a standalone build is qualification-package retention at the end: + // it packs the Daytona plugin from its `dist`, which only this build + // produces, so the build runs when retention is requested and nowhere else. + const retainQualificationPackages = Boolean(process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR); + if (retainQualificationPackages) { + const standaloneStarted = performance.now(); + run(process.execPath, [join(repo, 'scripts/build-standalone-public-packages.mjs')], repo); + timing.stage_standalone_build_ms = Math.round(performance.now() - standaloneStarted); + } + // The server's `ui-dist` payload is prepared here; with the reuse flag set + // above, that copies the `ui/dist` that `pnpm build` produced instead of + // building the UI again. + const uiDistStarted = performance.now(); + run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo); + timing.stage_ui_dist_ms = Math.round(performance.now() - uiDistStarted); // Match release.sh's unified versioning in temporary staging directories. // Source manifests remain untouched, including independently versioned SDKs. - run(process.execPath, [join(repo, 'scripts/build-standalone-public-packages.mjs')], repo); - run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo); // Each package's published files are staged into `source-` and its // pack input into `package-`, with in dependency order so the // tarball list keeps matching `needed`. The packages that bundle dependencies @@ -221,11 +241,13 @@ try { // without re-running it. `*_prefetch_ms` runs from a download's start to its // last output, and `*_wait_ms` is how long the main flow waited for a // prefetch that had not finished yet; zero means the overlap hid it entirely. - // `stage_sources_ms`, `stage_bundled_ms` and `stage_pack_ms` split the - // staging phase, and `stage_bundled__ms` is each concurrent install. + // `stage_ui_dist_ms`, `stage_sources_ms`, `stage_bundled_ms` and + // `stage_pack_ms` split the staging phase, and `stage_bundled__ms` + // is each concurrent install. `stage_standalone_build_ms` appears only when + // qualification-package retention made the standalone build necessary. const downloaded = Object.fromEntries(downloads.map(({ name, durationMs }) => [`${name}_prefetch_ms`, durationMs])); console.log(JSON.stringify({ schema: 'paperclip.public-npm-install.timing.v1', ...timing, ...downloaded, ...bundledInstallTimings(installs) })); - if (process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR) { + if (retainQualificationPackages) { const retained = retainRunnerQualificationPackages({ repo, output: process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR, sourceRevision, releaseVersion, env, publicArchives: [...needed].map((name, index) => ({ name, file: tarballs[index] })), });