mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Cache mobile typechecks and overlap CI setup (#28192)
* ci: run mobile runtime test groups on separate runners * ci: use a lightweight runner for the mobile result gate * ci: distribute Bun tests across both mobile runners * ci: reuse mobile compiler state without adding runner queues * test: verify eviction of source variants in one cache bucket * test: baseline existing serializer limits in the capacity capture
This commit is contained in:
@@ -81,6 +81,7 @@ jobs:
|
||||
# from the Gemfile, which is how the release jobs could land on different
|
||||
# fastlane versions in the first place. Fail here instead.
|
||||
BUNDLE_FROZEN: 'true'
|
||||
ORCA_MOBILE_TYPECHECK_CACHE_DIR: ${{ github.workspace }}/mobile/.cache/typechecks
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -94,6 +95,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
cache-pnpm-store: 'false'
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
@@ -114,6 +116,8 @@ jobs:
|
||||
# proves the pinned fastlane the release workflow depends on still
|
||||
# resolves — before a release run finds out.
|
||||
- name: Setup Ruby and fastlane
|
||||
id: ruby-tools
|
||||
background: true
|
||||
if: steps.ruby-scope.outputs.should_run != 'false'
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
@@ -124,11 +128,18 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache mobile typecheck state
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: mobile/.cache/typechecks/*.tsbuildinfo
|
||||
key: mobile-typechecks-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'mobile/pnpm-lock.yaml', 'mobile/tsconfig*.json') }}-${{ github.sha }}
|
||||
restore-keys: mobile-typechecks-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'mobile/pnpm-lock.yaml', 'mobile/tsconfig*.json') }}-
|
||||
|
||||
# Call installed tools so pnpm's dependency refresh cannot race between checks.
|
||||
- name: Typecheck
|
||||
id: production-types
|
||||
background: true
|
||||
run: node node_modules/typescript/bin/tsc --noEmit
|
||||
run: node node_modules/typescript/bin/tsc --noEmit --incremental --tsBuildInfoFile "$ORCA_MOBILE_TYPECHECK_CACHE_DIR/production.tsbuildinfo"
|
||||
|
||||
# Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until
|
||||
# tsconfig.test.json existed nothing checked them, and at introduction 127 of the 632 had
|
||||
@@ -169,7 +180,7 @@ jobs:
|
||||
run: node ../config/scripts/run-mobile-ci-tests.mjs
|
||||
|
||||
# All checks read the completed install; compilers need not hold up test workers.
|
||||
- wait: [production-types, test-types]
|
||||
- wait: [production-types, test-types, ruby-tools]
|
||||
|
||||
# Reports, never gates: the goldens are content-addressed JSON, so their raw diff is hashes.
|
||||
# This decodes which recorded behaviour the pull request moves into the job summary, and runs
|
||||
@@ -207,6 +218,8 @@ jobs:
|
||||
|
||||
main-tests:
|
||||
name: Mobile tests on main
|
||||
env:
|
||||
ORCA_MOBILE_TYPECHECK_CACHE_DIR: ${{ github.workspace }}/mobile/.cache/typechecks
|
||||
if: github.event_name == 'push'
|
||||
# Why ARM: the same pure-Node Vitest run `verify` makes on a pull request.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -230,6 +243,23 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache mobile typecheck state
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: mobile/.cache/typechecks/*.tsbuildinfo
|
||||
key: mobile-typechecks-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'mobile/pnpm-lock.yaml', 'mobile/tsconfig*.json') }}-${{ github.sha }}
|
||||
restore-keys: mobile-typechecks-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'mobile/pnpm-lock.yaml', 'mobile/tsconfig*.json') }}-
|
||||
|
||||
- name: Typecheck
|
||||
id: production-types
|
||||
background: true
|
||||
run: node node_modules/typescript/bin/tsc --noEmit --incremental --tsBuildInfoFile "$ORCA_MOBILE_TYPECHECK_CACHE_DIR/production.tsbuildinfo"
|
||||
|
||||
- name: Typecheck tests (ratchet)
|
||||
id: test-types
|
||||
background: true
|
||||
run: node scripts/check-tests-typecheck-ratchet.mjs
|
||||
|
||||
# The whole suite, not only the recordings: the mutant and page-bridge suites read the goldens
|
||||
# too. A red run here names each golden and the `rpc:record` command; the author of the merge
|
||||
# that turned it red re-records in a follow-up.
|
||||
@@ -242,3 +272,5 @@ jobs:
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
run: node ../config/scripts/run-mobile-ci-tests.mjs
|
||||
|
||||
- wait: [production-types, test-types]
|
||||
|
||||
@@ -3,6 +3,7 @@ import original from '../mobile/vitest.config.ts'
|
||||
|
||||
// Keep real process, native, and runtime-specific contracts on Node.
|
||||
const nodeFiles = [
|
||||
'scripts/check-tests-typecheck-incremental-cache.test.ts',
|
||||
'src/tasks/mobile-agent-launch-architecture.test.ts',
|
||||
'src/tasks/agent-launch-mobile-replay.test.ts',
|
||||
'src/mock-server-key-pair.test.ts',
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('CI dependency download caches', () => {
|
||||
const mobile = workflow('mobile').jobs.verify.steps.find((step) =>
|
||||
step.uses?.includes('install-node-dependencies')
|
||||
)
|
||||
expect(mobile.with['cache-pnpm-store']).toBe('false')
|
||||
expect(mobile.with['cache-dependency-path'].trim().split('\n')).toEqual([
|
||||
'pnpm-lock.yaml',
|
||||
'mobile/pnpm-lock.yaml'
|
||||
|
||||
@@ -91,11 +91,11 @@ it('overlaps both typechecks with tests and collects their failures before the s
|
||||
const waits = steps.filter((step) => step.wait !== undefined)
|
||||
expect(waits).toHaveLength(1)
|
||||
const [wait] = waits
|
||||
expect(wait.wait).toEqual(['production-types', 'test-types'])
|
||||
expect(wait.wait).toEqual(['production-types', 'test-types', 'ruby-tools'])
|
||||
expect(wait.if).toBeUndefined()
|
||||
expect(steps.indexOf(wait)).toBeGreaterThan(test)
|
||||
expect(steps.indexOf(wait)).toBeLessThan(summary)
|
||||
for (const id of wait.wait) {
|
||||
for (const id of ['production-types', 'test-types']) {
|
||||
const step = steps.find((candidate) => candidate.id === id)
|
||||
expect(step.background).toBe(true)
|
||||
expect(step['continue-on-error']).toBeUndefined()
|
||||
@@ -104,4 +104,10 @@ it('overlaps both typechecks with tests and collects their failures before the s
|
||||
expect(steps.indexOf(step)).toBeLessThan(test)
|
||||
expect(step.run).toMatch(/^node /)
|
||||
}
|
||||
const ruby = steps.find((step) => step.id === 'ruby-tools')
|
||||
expect(ruby.uses).toBe('ruby/setup-ruby@v1')
|
||||
expect(ruby.background).toBe(true)
|
||||
expect(ruby.if).toBe("steps.ruby-scope.outputs.should_run != 'false'")
|
||||
expect(ruby['continue-on-error']).toBeUndefined()
|
||||
expect(steps.indexOf(ruby)).toBeLessThan(install)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
it('reuses compiler state without skipping validation, and seeds it from main', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/mobile.yml', 'utf8'))
|
||||
for (const job of [workflow.jobs.verify, workflow.jobs['main-tests']]) {
|
||||
expect(job.env.ORCA_MOBILE_TYPECHECK_CACHE_DIR).toBe(
|
||||
'${{ github.workspace }}/mobile/.cache/typechecks'
|
||||
)
|
||||
const steps = job.steps
|
||||
const cache = steps.find((step) => step.name === 'Cache mobile typecheck state')
|
||||
expect(cache.uses).toBe('actions/cache@v5')
|
||||
expect(cache.with.path).toBe('mobile/.cache/typechecks/*.tsbuildinfo')
|
||||
expect(cache.with.key).toBe(`${cache.with['restore-keys']}${'${{ github.sha }}'}`)
|
||||
for (const input of [
|
||||
'runner.os',
|
||||
'runner.arch',
|
||||
'pnpm-lock.yaml',
|
||||
'mobile/pnpm-lock.yaml',
|
||||
'mobile/tsconfig*.json'
|
||||
]) {
|
||||
expect(cache.with['restore-keys']).toContain(input)
|
||||
}
|
||||
const install = steps.findIndex((step) => step.name === 'Install dependencies')
|
||||
const wait = steps.find((step) => step.wait)
|
||||
for (const id of ['production-types', 'test-types']) {
|
||||
const check = steps.find((step) => step.id === id)
|
||||
expect(check.if).toBeUndefined()
|
||||
expect(check.background).toBe(true)
|
||||
expect(check['continue-on-error']).toBeUndefined()
|
||||
expect(steps.indexOf(cache)).toBeGreaterThan(install)
|
||||
expect(steps.indexOf(cache)).toBeLessThan(steps.indexOf(check))
|
||||
expect(wait.wait).toContain(id)
|
||||
expect(steps.indexOf(wait)).toBeGreaterThan(steps.indexOf(check))
|
||||
}
|
||||
expect(steps.find((step) => step.id === 'production-types').run).toContain(
|
||||
'--incremental --tsBuildInfoFile "$ORCA_MOBILE_TYPECHECK_CACHE_DIR/production.tsbuildinfo"'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -16,3 +16,5 @@ dist/
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
|
||||
.cache/typechecks/
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { existsSync, mkdtempSync, realpathSync, writeFileSync, unlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal'
|
||||
import { collectTypecheckPass, findTsNocheckFiles } from './check-tests-typecheck-ratchet.mjs'
|
||||
|
||||
it('rechecks changed types, diagnostics and test membership with incremental state', () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-mobile-typecheck-')))
|
||||
const cache = join(root, '.cache')
|
||||
vi.stubEnv('ORCA_MOBILE_TYPECHECK_CACHE_DIR', cache)
|
||||
try {
|
||||
writeFileSync(
|
||||
join(root, 'tsconfig.test.json'),
|
||||
JSON.stringify({
|
||||
compilerOptions: { strict: true, skipLibCheck: true, types: [], target: 'ES2022' },
|
||||
include: ['*.ts']
|
||||
})
|
||||
)
|
||||
writeFileSync(join(root, 'model.ts'), 'export interface Shape { value: number }')
|
||||
const test = join(root, 'value.test.ts')
|
||||
const source = "import type { Shape } from './model'; const shape: Shape = { value: 1 }"
|
||||
writeFileSync(test, source)
|
||||
const clean = collectTypecheckPass(root)
|
||||
expect(clean).toEqual({ failing: [], programTestFiles: ['value.test.ts'] })
|
||||
expect(existsSync(join(cache, 'tests.tsbuildinfo'))).toBe(true)
|
||||
expect(collectTypecheckPass(root)).toEqual(clean)
|
||||
|
||||
writeFileSync(join(root, 'model.ts'), 'export interface Shape { value: string }')
|
||||
const failed = collectTypecheckPass(root)
|
||||
expect(failed.failing).toEqual(['value.test.ts'])
|
||||
expect(collectTypecheckPass(root)).toEqual(failed)
|
||||
|
||||
writeFileSync(join(root, 'new.test.ts'), 'export const value: number = 1')
|
||||
expect(collectTypecheckPass(root).programTestFiles).toEqual(['new.test.ts', 'value.test.ts'])
|
||||
writeFileSync(test, `// @ts-nocheck\n${source}`)
|
||||
const nocheck = collectTypecheckPass(root)
|
||||
expect(nocheck.failing).toEqual([])
|
||||
expect(findTsNocheckFiles(root, nocheck.programTestFiles)).toEqual(['value.test.ts'])
|
||||
|
||||
unlinkSync(test)
|
||||
expect(collectTypecheckPass(root)).toEqual({ failing: [], programTestFiles: ['new.test.ts'] })
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
removeTreeSync(root)
|
||||
}
|
||||
}, 15_000)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
import fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
@@ -148,22 +148,26 @@ export function diffBaseline(current, baseline) {
|
||||
// script: on Windows the shim is tsc.CMD and an extensionless path gets .exe appended.
|
||||
function runTsc(root, args) {
|
||||
const entry = createRequire(import.meta.url).resolve('typescript/lib/tsc.js')
|
||||
const result = spawnSync(process.execPath, [entry, ...args], {
|
||||
return runProcessSync({
|
||||
program: process.execPath,
|
||||
args: [entry, ...args],
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
shell: false,
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
timeoutMs: null,
|
||||
maxOutputBytes: 64 * 1024 * 1024
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// tsc exits non-zero on type errors, which is the expected state here, so only a crash is fatal.
|
||||
// One pass answers both questions: --listFiles names the program, the diagnostics name the failures.
|
||||
export function collectTypecheckPass(root = process.cwd()) {
|
||||
const result = runTsc(root, ['--noEmit', '--listFiles', '-p', PROJECT])
|
||||
const cache = process.env.ORCA_MOBILE_TYPECHECK_CACHE_DIR
|
||||
const result = runTsc(root, [
|
||||
'--noEmit',
|
||||
'--listFiles',
|
||||
'-p',
|
||||
PROJECT,
|
||||
...(cache ? ['--incremental', '--tsBuildInfoFile', path.join(cache, 'tests.tsbuildinfo')] : [])
|
||||
])
|
||||
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
|
||||
return {
|
||||
failing: parseFailingFiles(output),
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('compiled operation code', () => {
|
||||
original
|
||||
)
|
||||
expect(compiledOperationModule('different.ts', source, '')).not.toBe(original)
|
||||
expect(compiledOperationModule('original.ts', source, '')).toBe(original)
|
||||
})
|
||||
|
||||
it('bounds retained code and still evaluates an evicted module correctly', () => {
|
||||
@@ -27,4 +28,17 @@ describe('compiled operation code', () => {
|
||||
recompiled(() => undefined, exports)
|
||||
expect(exports).toEqual({ value: 42 })
|
||||
})
|
||||
|
||||
it('bounds retained variants of one file, not only the number of filenames', () => {
|
||||
const source = 'export const value = 42'
|
||||
const original = compiledOperationModule('variants.ts', source, '')
|
||||
for (let index = 0; index < 512; index++) {
|
||||
compiledOperationModule('variants.ts', `${source}; export const variant = ${index}`, '')
|
||||
}
|
||||
const recompiled = compiledOperationModule('variants.ts', source, '')
|
||||
expect(recompiled).not.toBe(original)
|
||||
const exports = {}
|
||||
recompiled(() => undefined, exports)
|
||||
expect(exports).toEqual({ value: 42 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { compileFunction } from 'node:vm'
|
||||
import ts from 'typescript'
|
||||
|
||||
const compiled = new Map<string, ReturnType<typeof compileFunction>>()
|
||||
type CompiledModule = {
|
||||
source: string
|
||||
evaluate: ReturnType<typeof compileFunction>
|
||||
}
|
||||
|
||||
const compiled = new Map<string, Map<string, CompiledModule>>()
|
||||
const insertionOrder = new Map<CompiledModule, string>()
|
||||
const MAX_COMPILED_MODULES = 512
|
||||
|
||||
/** Only code is shared: each invocation receives a fresh require closure and exports object. */
|
||||
export function compiledOperationModule(file: string, source: string, exposure: string) {
|
||||
const key = JSON.stringify([file, source, exposure])
|
||||
const cached = compiled.get(key)
|
||||
const key = JSON.stringify([file, exposure])
|
||||
const cached = compiled.get(key)?.get(source)
|
||||
if (cached) {
|
||||
return cached
|
||||
return cached.evaluate
|
||||
}
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
@@ -20,12 +26,22 @@ export function compiledOperationModule(file: string, source: string, exposure:
|
||||
}
|
||||
}).outputText
|
||||
const evaluate = compileFunction(output + exposure, ['require', 'exports'], { filename: file })
|
||||
if (compiled.size >= MAX_COMPILED_MODULES) {
|
||||
const oldest = compiled.keys().next().value
|
||||
if (insertionOrder.size >= MAX_COMPILED_MODULES) {
|
||||
const oldest = insertionOrder.entries().next().value
|
||||
if (oldest !== undefined) {
|
||||
compiled.delete(oldest)
|
||||
const [entry, variantKey] = oldest
|
||||
insertionOrder.delete(entry)
|
||||
const entries = compiled.get(variantKey)
|
||||
entries?.delete(entry.source)
|
||||
if (!entries?.size) {
|
||||
compiled.delete(variantKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
compiled.set(key, evaluate)
|
||||
const entry = { source, evaluate }
|
||||
const entries = compiled.get(key) ?? new Map<string, CompiledModule>()
|
||||
entries.set(source, entry)
|
||||
compiled.set(key, entries)
|
||||
insertionOrder.set(entry, key)
|
||||
return evaluate
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { internRecording, resolveRecording } from './golden-value-pool'
|
||||
|
||||
it('serializes each pooled value once for its hash and collision check', () => {
|
||||
const recording = {
|
||||
scenario: 'serialization',
|
||||
checkpoints: [
|
||||
{
|
||||
id: 'one',
|
||||
observation: {
|
||||
sender: [],
|
||||
payloads: [],
|
||||
settlements: {},
|
||||
state: { value: 'one' },
|
||||
effects: []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
const stringify = vi.spyOn(JSON, 'stringify')
|
||||
try {
|
||||
const interned = internRecording(recording)
|
||||
const calls = stringify.mock.calls.length
|
||||
stringify.mockRestore()
|
||||
expect(calls).toBe(2)
|
||||
expect(resolveRecording(interned.values, interned.recording)).toEqual(recording)
|
||||
} finally {
|
||||
stringify.mockRestore()
|
||||
}
|
||||
})
|
||||
@@ -61,7 +61,11 @@ export function canonicalJson(value: RecordedValue): string {
|
||||
}
|
||||
|
||||
export function valueHash(value: RecordedValue): string {
|
||||
return createHash('sha256').update(canonicalJson(value)).digest('hex').slice(0, 12)
|
||||
return canonicalJsonHash(canonicalJson(value))
|
||||
}
|
||||
|
||||
function canonicalJsonHash(json: string): string {
|
||||
return createHash('sha256').update(json).digest('hex').slice(0, 12)
|
||||
}
|
||||
|
||||
function listEntries(at: string, value: RecordedValue): RecordedValue[] {
|
||||
@@ -86,8 +90,8 @@ export function internRecording(recording: Recording): {
|
||||
const pool: ValuePool = {}
|
||||
const canonical = new Map<string, string>()
|
||||
const intern = (entry: RecordedValue, at: string): string => {
|
||||
const hash = valueHash(entry)
|
||||
const json = canonicalJson(entry)
|
||||
const hash = canonicalJsonHash(json)
|
||||
const seen = canonical.get(hash)
|
||||
if (seen !== undefined && seen !== json) {
|
||||
throw new Error(`Golden value hash collision at ${hash} (${at})`)
|
||||
|
||||
@@ -79,6 +79,8 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record<string, number> = {
|
||||
'codex-fullscreen-early-input': 4,
|
||||
'codex-fullscreen-multiline-early-input': 10,
|
||||
'codex-fullscreen-startup': 14,
|
||||
// Capacity capture: identical attribute leaks with the pre-capture 4559c250b3e serializer.
|
||||
'codex-model-capacity-fullscreen': 12,
|
||||
'claude-dialog-trust-workspace-answered': 13,
|
||||
// DSH-TUI's whale intro paints whole rows of 24-bit background, and every one of this
|
||||
// transcript's divergences is the same shape: `visible-grid row=0`, a true-colour
|
||||
|
||||
Reference in New Issue
Block a user