mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 08:07:18 +00:00
The mobile web bundle check failed at 124 assets against a ceiling of 123. Main already sat exactly on that ceiling: its sweep table read 69 scripts at 16 routes while the tree builds 73, the whole margin of 4. This branch imports the agent.launch capabilities from their own module, so protocol-version is no longer pulled into the root layout and four other routes. That moves which routes share which modules, and the Qoder capability module, imported by protocol-version and the AI-vault resume path, no longer shares an importer set with anything, so it gets a chunk of its own: 74 scripts. The fence says to re-derive the bound rather than raise it, so the sweep is re-measured on this head (every prefix of the sorted route list). The worst route now adds 10 scripts (session), not 9, which moves the pinned shell crossing from 32 to 30 routes; main re-measured on its own lands on the same crossing.
287 lines
13 KiB
JavaScript
287 lines
13 KiB
JavaScript
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import * as esbuild from 'esbuild'
|
|
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
|
|
import { isDirectInvocation } from './script-entry-detection.mjs'
|
|
import { assertNoCarriageReturnsInSource } from './mobile-web-source-line-endings.mjs'
|
|
import {
|
|
MOBILE_WEB_BUNDLE_DIR as defaultBundleDir,
|
|
assertMobileWebBundleBuilt
|
|
} from './verify-packaged-mobile-web-bundle.cjs'
|
|
|
|
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
|
const manifestContract = join(
|
|
projectDir,
|
|
'src',
|
|
'shared',
|
|
'mobile-web-bundle',
|
|
'manifest-contract.ts'
|
|
)
|
|
|
|
/**
|
|
* The document, the route chunks and the images the route tree imports. Derived rather than
|
|
* pinned, because a flat number stops agreeing with the chunk ceiling as routes are added: at 128
|
|
* and 42 images, 18 routes are already allowed 98 chunks, and 98 + 42 + 1 is 141, so the asset
|
|
* count would have failed first and named the count rather than the split that caused it. Written
|
|
* as chunks + images + the document, a bundle at the chunk ceiling sits exactly at this one, so
|
|
* the chunk ceiling always trips first and the failure says what actually grew.
|
|
*/
|
|
export function mobileWebAppBundleMaxAssets(routeCount, imageCount) {
|
|
return mobileWebAppBundleMaxChunks(routeCount) + imageCount + 1
|
|
}
|
|
|
|
/**
|
|
* Phase C byte budget for the app bundle, not the contract ceiling (10 MiB per asset,
|
|
* MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES). Deliberately below it so growth trips a build rather than a
|
|
* refused asset on a phone. Splitting barely moves it — the same code is emitted in more files —
|
|
* so shrinking this still means cutting code.
|
|
*
|
|
* This head reads 7,686,714 bytes of the 9,437,184 here, 81.5%, leaving 1,750,470. A reading and
|
|
* not a pin: nothing asserts it, because the number moves with every build. It is here so the
|
|
* generation that spends the rest can see it was already this close.
|
|
*/
|
|
export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
|
|
|
|
/**
|
|
* Scripts emitted at every prefix of the sorted route key list, and the route each prefix added.
|
|
*
|
|
* A chunk is emitted per distinct set of importers, not per route, so a route's marginal cost is
|
|
* what it fails to share rather than what it weighs. Re-measured on this head by building
|
|
* `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than
|
|
* fitted to. The spread it shows is 1 to 10: `pr` and `web` add one script each, `session` adds ten.
|
|
* The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the
|
|
* swept tree reads 74 scripts at 16 routes.
|
|
*
|
|
* This table is the fence's only input, so a route added to the tree stales it and the pins beside
|
|
* the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped.
|
|
*/
|
|
export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [
|
|
['./_layout.tsx', 3],
|
|
['./h/[hostId]/[...page].tsx', 7],
|
|
['./h/[hostId]/accounts.tsx', 9],
|
|
['./h/[hostId]/agent-history/[worktreeId].tsx', 14],
|
|
['./h/[hostId]/edit.tsx', 19],
|
|
['./h/[hostId]/files/[worktreeId].tsx', 24],
|
|
['./h/[hostId]/files/preview/[worktreeId].tsx', 32],
|
|
['./h/[hostId]/history/[worktreeId].tsx', 34],
|
|
['./h/[hostId]/index.tsx', 39],
|
|
['./h/[hostId]/pr/[worktreeId].tsx', 40],
|
|
['./h/[hostId]/review/[worktreeId].tsx', 48],
|
|
['./h/[hostId]/session/[worktreeId].tsx', 58],
|
|
['./h/[hostId]/source-control/[worktreeId].tsx', 63],
|
|
['./h/[hostId]/tasks.tsx', 71],
|
|
['./h/[hostId]/web.tsx', 72],
|
|
['./h/_layout.tsx', 74]
|
|
]
|
|
|
|
const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts)
|
|
|
|
/** What each route after the first cost, which is the spread the envelope is an upper bound of. */
|
|
export const MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD = sweptScripts
|
|
.slice(1)
|
|
.map((scripts, index) => scripts - sweptScripts[index])
|
|
|
|
/**
|
|
* How far above the measurement the envelope sits, and the only slack a refactor gets.
|
|
*
|
|
* Measured, not chosen, and per head rather than cumulative: at 8, 10, 12 and 14 routes the head
|
|
* that wrote the old `4r + 16` read 32, 43, 61 and 69, the head that first swept these prefixes
|
|
* read 34, 44, 57 and 65, and this one reads 33, 43, 56 and 64. So one head has moved the count by
|
|
* as much as four at a fixed route count with no route added (61 to 57), and the step that dropped
|
|
* the page's second Zod moved it by one everywhere. Four is that worst step, which is what a shared
|
|
* importer set moving between heads costs. Summing the steps instead would grow this number every
|
|
* head and loosen the fence for free. A refactor inside four keeps building; anything past it
|
|
* re-measures the sweep.
|
|
*/
|
|
export const MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN = 4
|
|
|
|
/**
|
|
* How many scripts the page may be cut into, for a given number of routes.
|
|
*
|
|
* Anchored on the sweep above rather than fitted to the route count, because the count is not a
|
|
* function of the route count alone: `4r + 16` was a guess at break-even and its slack ran from 17
|
|
* at one route to 4 at thirteen, so it was a near-miss exactly where the tree actually sits. This
|
|
* is the measurement plus the margin at the swept tree, growing by the worst route the sweep saw
|
|
* for every route past it — a route cannot breach it without costing more than any route measured.
|
|
*
|
|
* Flat below the swept length, which is the whole sweep's upper bound too: the count rises with the
|
|
* prefix, so one number above its top row is above every row. The fence is only ever asked about
|
|
* the real tree, and routes are only ever added.
|
|
*
|
|
* Two-sided in the test beside it. An envelope more than the margin above the build is a fence
|
|
* nobody re-derived, and it fails there rather than surviving as headroom for a bump.
|
|
*
|
|
* The route count stays the only term. A deferred engine belongs inside one artifact and costs one
|
|
* script: C7.10 item B first reached mermaid with `import('mermaid')`, which emitted 103 more
|
|
* because mermaid lazily imports each of its own diagram types, and a second term admitting those
|
|
* would have raised this fence far enough to admit any split at all. The build test's control is
|
|
* what holds that line.
|
|
*
|
|
* This is the ceiling that catches a split running away; MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES
|
|
* below is the one that catches it collapsing, and it is the real budget of the two.
|
|
*/
|
|
export function mobileWebAppBundleMaxChunks(routeCount) {
|
|
const beyondTheSweep = Math.max(0, routeCount - MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.length)
|
|
return (
|
|
sweptScripts.at(-1) +
|
|
MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN +
|
|
Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD) * beyondTheSweep
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What the browser must parse before the first route can paint: the entry plus every chunk it
|
|
* reaches by static import. This is the budget splitting exists to hold — it was 8.16 MB as one
|
|
* chunk and measures 1,244,312 bytes split on this head, 1.19 of the 3 MiB — so a route
|
|
* re-imported statically, or `splitting` dropped, fails the build here instead of arriving as a
|
|
* slow first open on a phone.
|
|
*
|
|
* It is not a per-route escape hatch. Re-measured here by making one route's manifest entry a
|
|
* static import and reading this same closure back: session alone breaks the bound at 3.32 MiB,
|
|
* and tasks at 2.17, source-control 2.04, review 2.03, index 1.89 and files/preview 1.85 each
|
|
* spend most of a budget that has to cover the entry as well. What keeps the hatch usable at all
|
|
* is that expo-router reads `unstable_settings` off layout nodes only, and the subtree's one
|
|
* layout, `h/_layout.tsx`, measures 1.89 MiB static. Any other route needing a synchronous export
|
|
* needs this number re-measured, not a static import.
|
|
*/
|
|
export const MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES = 3 * 1024 * 1024
|
|
|
|
/** Every tree whose bytes reach the buildId, so a CRLF checkout cannot fork it. */
|
|
export const MOBILE_WEB_APP_SOURCE_DIRS = [
|
|
join(projectDir, 'mobile', 'web-entry'),
|
|
join(projectDir, 'mobile', 'app'),
|
|
join(projectDir, 'mobile', 'src')
|
|
]
|
|
|
|
class VerificationError extends Error {}
|
|
|
|
function fail(message) {
|
|
throw new VerificationError(message)
|
|
}
|
|
|
|
/**
|
|
* How many assets the phone will accept, read from the contract rather than copied: the native
|
|
* shells hold their own 256 and refuse a larger manifest outright. Bundled through esbuild
|
|
* because node cannot resolve that module's extensionless TypeScript imports, so the number is
|
|
* evaluated from the contract and not parsed out of it.
|
|
*/
|
|
export async function readMobileWebBundleMaxAssets() {
|
|
const { outputFiles } = await esbuild.build({
|
|
entryPoints: [manifestContract],
|
|
bundle: true,
|
|
write: false,
|
|
format: 'esm',
|
|
platform: 'node',
|
|
logLevel: 'silent'
|
|
})
|
|
const source = Buffer.from(outputFiles[0].contents).toString('base64')
|
|
const { MOBILE_WEB_BUNDLE_MAX_ASSETS: ceiling } = await import(
|
|
`data:text/javascript;base64,${source}`
|
|
)
|
|
if (typeof ceiling !== 'number') {
|
|
fail(`${manifestContract} exports no MOBILE_WEB_BUNDLE_MAX_ASSETS to bound the build with`)
|
|
}
|
|
return ceiling
|
|
}
|
|
|
|
/**
|
|
* The derived ceiling is only a budget while it stays inside the map the phone can hold: the
|
|
* shells return null for a manifest over MOBILE_WEB_BUNDLE_MAX_ASSETS rather than dropping the
|
|
* extra assets, so a route count that pushes the chunk envelope plus images plus the document past
|
|
* it would pass this build and fail on the device with nothing to read. At today's 42 images that
|
|
* is 29 routes, inside what Phase C adds, which is why this is a build failure and not a comment.
|
|
* The envelope grants the worst swept route to each one past the sweep, so re-measuring a tree
|
|
* whose routes share more moves that crossing out again.
|
|
*/
|
|
export function assertAssetCeilingFitsShell(routeCount, imageCount, shellMaxAssets) {
|
|
const ceiling = mobileWebAppBundleMaxAssets(routeCount, imageCount)
|
|
if (ceiling > shellMaxAssets) {
|
|
fail(
|
|
`the ceiling derived for ${String(routeCount)} route(s) and ${String(imageCount)} image(s) ` +
|
|
`is ${String(ceiling)} assets, over the ${String(shellMaxAssets)} the shell will load`
|
|
)
|
|
}
|
|
return ceiling
|
|
}
|
|
|
|
async function buildIntoScratch() {
|
|
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-verify-'))
|
|
try {
|
|
return await buildMobileWebAppBundle({ outDir: join(scratch, 'mobile-web') })
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
// bundleDir is a seam for the tests, which verify a scratch build; the script always verifies out/.
|
|
export async function verifyMobileWebAppBundle({ bundleDir = defaultBundleDir } = {}) {
|
|
for (const directory of MOBILE_WEB_APP_SOURCE_DIRS) {
|
|
await assertNoCarriageReturnsInSource(directory)
|
|
}
|
|
|
|
const manifest = assertMobileWebBundleBuilt(bundleDir)
|
|
|
|
if (manifest.totalBytes > MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) {
|
|
fail(
|
|
`bundle is ${String(manifest.totalBytes)} bytes, over the Phase C budget of ` +
|
|
`${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)}`
|
|
)
|
|
}
|
|
|
|
const first = await buildIntoScratch()
|
|
const second = await buildIntoScratch()
|
|
if (first.manifest.buildId !== second.manifest.buildId) {
|
|
fail(`buildId is not reproducible: ${first.manifest.buildId} then ${second.manifest.buildId}`)
|
|
}
|
|
if (first.manifest.buildId !== manifest.buildId) {
|
|
fail(
|
|
`${bundleDir} is stale: it carries buildId ${manifest.buildId}, a fresh build produces ${first.manifest.buildId}`
|
|
)
|
|
}
|
|
// Read off the fresh build rather than the manifest: neither bound is a manifest field, and the
|
|
// buildId just proved this build is the one on disk.
|
|
// After the fresh build, which is what knows how many of the assets are images.
|
|
const maxAssets = assertAssetCeilingFitsShell(
|
|
first.routeKeys.length,
|
|
first.imageCount,
|
|
await readMobileWebBundleMaxAssets()
|
|
)
|
|
if (manifest.assets.length > maxAssets) {
|
|
fail(
|
|
`bundle has ${String(manifest.assets.length)} assets, over the Phase C budget of ` +
|
|
`${String(maxAssets)} for ${String(first.routeKeys.length)} route(s) and ` +
|
|
`${String(first.imageCount)} image(s)`
|
|
)
|
|
}
|
|
const maxChunks = mobileWebAppBundleMaxChunks(first.routeKeys.length)
|
|
if (first.chunkCount > maxChunks) {
|
|
fail(
|
|
`bundle is cut into ${String(first.chunkCount)} chunks, over the Phase C budget of ` +
|
|
`${String(maxChunks)} for ${String(first.routeKeys.length)} route(s)`
|
|
)
|
|
}
|
|
if (first.entryStaticBytes > MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES) {
|
|
fail(
|
|
`${String(first.entryStaticBytes)} bytes load before the first route, over the Phase C ` +
|
|
`budget of ${String(MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES)}`
|
|
)
|
|
}
|
|
return manifest
|
|
}
|
|
|
|
if (isDirectInvocation(import.meta.url, process.argv[1])) {
|
|
try {
|
|
const manifest = await verifyMobileWebAppBundle()
|
|
console.log(
|
|
`[verify-mobile-web-app-bundle] OK — ${String(manifest.assets.length)} asset(s), ` +
|
|
`${String(manifest.totalBytes)}/${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)} bytes, ` +
|
|
`reproducible buildId ${manifest.buildId}`
|
|
)
|
|
} catch (error) {
|
|
console.error(`[verify-mobile-web-app-bundle] ${error.message}`)
|
|
process.exit(1)
|
|
}
|
|
}
|