From 48fcd064c20f0ca0f4a9c7260bc79944fe9776bd Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 2 Nov 2026 15:19:55 +0800 Subject: [PATCH 1/5] fix(agentic): rebuild dev clients when native fingerprints change --- scripts/agentic/preflight.sh | 58 ++++++++++++- scripts/agentic/preflight.test.mjs | 132 +++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 scripts/agentic/preflight.test.mjs diff --git a/scripts/agentic/preflight.sh b/scripts/agentic/preflight.sh index ee3017982..53979abc7 100755 --- a/scripts/agentic/preflight.sh +++ b/scripts/agentic/preflight.sh @@ -73,6 +73,23 @@ load_env_defaults ".env.${APP_VARIANT_EFFECTIVE}" load_env_defaults ".env.${APP_VARIANT_EFFECTIVE}.local" IOS_DEVELOPMENT_TEAM="${IOS_DEVELOPMENT_TEAM:-${APPLE_TEAM_ID:-}}" +native_fingerprint() { + APP_VARIANT="$APP_VARIANT_EFFECTIVE" node - "$APP_ROOT" "$PLATFORM" <<'FINGERPRINT' +// Hash workspace native sources too, even when their package version is unchanged. +Promise.resolve().then(() => { + const [appRoot, platform] = process.argv.slice(2); + const expo = require.resolve('expo', { paths: [appRoot] }); + const { createProjectHashAsync } = require(require.resolve('@expo/fingerprint', { paths: [expo] })); + return createProjectHashAsync(appRoot, { platforms: [platform], preset: 'strict', concurrentIoLimit: 4, silent: true }); +}) + .then(hash => { + if (!/^[a-f0-9]{40}$/.test(hash)) throw new Error('Invalid native fingerprint'); + process.stdout.write(hash); + }) + .catch(() => { console.error('[preflight] FAIL Could not compute native fingerprint'); process.exitCode = 1; }); +FINGERPRINT +} + run_simctl_timeout() { python3 - "$@" <<'PY' import subprocess @@ -347,6 +364,37 @@ elif [[ "${SKIP_BUILD:-0}" != "1" ]]; then fi fi +# Stamps survive checkout resets and belong to one variant, platform and device. +if [ "$PLATFORM" = android ]; then + FINGERPRINT_BUNDLE="$BUNDLE_ID_ANDROID" + FINGERPRINT_DEVICE="$SERIAL" +else + FINGERPRINT_BUNDLE="$BUNDLE_ID_IOS" + if [ "${IOS_DEVICE_MODE:-simulator}" = physical ]; then + FINGERPRINT_DEVICE="$IOS_DEVICE_UDID" + else + FINGERPRINT_DEVICE="$SIM_UUID" + fi +fi +FINGERPRINT_STAMP="${XDG_CACHE_HOME:-${HOME}/.cache}/audiolab-dev-client/${FINGERPRINT_BUNDLE}/${PLATFORM}-${FINGERPRINT_DEVICE}.fingerprint" +if ! CURRENT_NATIVE_FINGERPRINT=$(native_fingerprint); then + if [ "${SKIP_BUILD:-0}" != 1 ]; then exit 1; fi + info "WARNING: Could not check dev client native fingerprint; keeping SKIP_BUILD=1 behavior" +elif [ "$APP_INSTALLED" = true ]; then + INSTALLED_NATIVE_FINGERPRINT=$(cat "$FINGERPRINT_STAMP" 2>/dev/null || true) + if [[ ! "$INSTALLED_NATIVE_FINGERPRINT" =~ ^[a-f0-9]{40}$ ]]; then INSTALLED_NATIVE_FINGERPRINT=missing; fi + if [ "$INSTALLED_NATIVE_FINGERPRINT" != "$CURRENT_NATIVE_FINGERPRINT" ]; then + if [ "${SKIP_BUILD:-0}" = 1 ]; then + info "WARNING: dev client native fingerprint changed (${INSTALLED_NATIVE_FINGERPRINT} → ${CURRENT_NATIVE_FINGERPRINT}); SKIP_BUILD=1 keeps the installed client" + else + info "dev client native fingerprint changed (${INSTALLED_NATIVE_FINGERPRINT} → ${CURRENT_NATIVE_FINGERPRINT})" + APP_INSTALLED=false + fi + else + pass "Dev client native fingerprint unchanged (${CURRENT_NATIVE_FINGERPRINT})" + fi +fi + # REBUILD=1 forces reinstall even if app is already installed if [ "${REBUILD:-0}" = "1" ]; then APP_INSTALLED=false; fi @@ -363,8 +411,11 @@ if [ "$APP_INSTALLED" = false ]; then launch_stage native-deps 300 fi + if [ "$PLATFORM" = ios ]; then resolve_ios_workspace_and_scheme "$APP_VARIANT_EFFECTIVE"; fi + # Capture the inputs after dependency hooks/prebuild, before native compilation. + CURRENT_NATIVE_FINGERPRINT=$(native_fingerprint) + if [ "$PLATFORM" = "ios" ] && [ "${IOS_DEVICE_MODE:-simulator}" = "physical" ]; then - resolve_ios_workspace_and_scheme "$APP_VARIANT_EFFECTIVE" info "Building iOS app for physical device ${IOS_DEVICE_UDID} using ${IOS_SCHEME_NAME}..." BUILD_LOG=".agent/ios-device-build-$(date -u +%Y%m%dT%H%M%SZ).log" mkdir -p .agent @@ -385,7 +436,6 @@ if [ "$APP_INSTALLED" = false ]; then xcrun devicectl device install app --device "${IOS_DEVICE_UDID}" "$APP_PATH" pass "iOS app built and installed on physical device" elif [ "$PLATFORM" = "ios" ]; then - resolve_ios_workspace_and_scheme "$APP_VARIANT_EFFECTIVE" info "Building iOS app for simulator ${SIM} (${SIM_UUID}) using ${IOS_SCHEME_NAME}..." BUILD_LOG=".agent/ios-simulator-build-$(date -u +%Y%m%dT%H%M%SZ).log" mkdir -p .agent @@ -415,6 +465,10 @@ if [ "$APP_INSTALLED" = false ]; then adb -s "${SERIAL}" install -r "$APK_PATH" pass "Android app built and installed on ${SERIAL}" fi + mkdir -p "$(dirname "$FINGERPRINT_STAMP")" + printf '%s\n' "$CURRENT_NATIVE_FINGERPRINT" > "${FINGERPRINT_STAMP}.$$" + mv "${FINGERPRINT_STAMP}.$$" "$FINGERPRINT_STAMP" + pass "Recorded dev client native fingerprint (${CURRENT_NATIVE_FINGERPRINT})" fi # ── 4. Launch via Expo dev client deep link ──────────────────── diff --git a/scripts/agentic/preflight.test.mjs b/scripts/agentic/preflight.test.mjs new file mode 100644 index 000000000..5780863f2 --- /dev/null +++ b/scripts/agentic/preflight.test.mjs @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execute = promisify(execFile); +const source = await fs.readFile(new URL('./preflight.sh', import.meta.url), 'utf8'); +const fingerprint = source.slice(source.indexOf('native_fingerprint() {'), source.indexOf('run_simctl_timeout() {')); +const build = source.slice(source.indexOf('info "Step 3:'), source.indexOf('# ── 4. Launch')); +const oldHash = '1'.repeat(40); +const newHash = '2'.repeat(40); + +async function fixture(t, platform = 'android', mode = 'physical') { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'audiolab-preflight-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + for (const folder of ['bin', 'node_modules/expo', 'node_modules/@expo/fingerprint', + 'android/app/build/outputs/apk/debug', 'ios/build/Build/Products/Debug-iphonesimulator/Fixture.app', + 'ios/build-device/Build/Products/Debug-iphoneos/Fixture.app']) { + await fs.mkdir(path.join(root, folder), { recursive: true }); + } + await fs.writeFile(path.join(root, 'android/app/build/outputs/apk/debug/app-debug.apk'), 'apk'); + await fs.writeFile(path.join(root, 'node_modules/expo/index.js'), ''); + await fs.writeFile(path.join(root, 'node_modules/@expo/fingerprint/index.js'), ` +exports.createProjectHashAsync = async (root, options) => { + require('node:assert/strict').deepEqual(options, { + platforms: [process.env.PLATFORM], preset: 'strict', concurrentIoLimit: 4, silent: true, + }); + if (process.env.FAIL_FINGERPRINT === '1') throw new Error('fixture failure'); + return process.env.FIXTURE_HASH; +}; +`); + await fs.writeFile(path.join(root, 'bin/adb'), `#!/bin/bash +case "$*" in + *'pm list packages'*) [ "$INSTALLED" = 1 ] && echo "package:net.siteed.fixture.$APP_VARIANT"; exit 0 ;; + *' install -r '*) echo install >> "$EVENTS"; [ "\${FAIL_INSTALL:-0}" != 1 ] ;; +esac +`, { mode: 0o755 }); + await fs.writeFile(path.join(root, 'bin/xcrun'), '#!/bin/bash\necho install >> "$EVENTS"\n[ "\${FAIL_INSTALL:-0}" != 1 ]\n', { mode: 0o755 }); + const script = `set -euo pipefail +cd "$APP_ROOT" +APP_VARIANT_EFFECTIVE="$APP_VARIANT" +BUNDLE_ID_ANDROID="net.siteed.fixture.$APP_VARIANT" +BUNDLE_ID_IOS="$BUNDLE_ID_ANDROID" +SERIAL="\${FIXTURE_DEVICE:-pixel-fixture}" +SIM_UUID=sim-fixture +SIM=Fixture +IOS_DEVICE_UDID=phone-fixture +AGENTIC_SKIP_BUILD_SUPPORTED=true +AGENTIC_PRE_BUILD_HOOK= +info() { echo "$1"; } +pass() { echo "PASS $1"; } +fail() { echo "FAIL $1"; } +simulator_app_installed() { [ "$INSTALLED" = 1 ]; } +physical_ios_app_installed() { [ "$INSTALLED" = 1 ]; } +resolve_ios_workspace_and_scheme() { IOS_SCHEME_NAME=Fixture; } +launch_stage() { echo build >> "$EVENTS"; [ "\${FAIL_BUILD:-0}" != 1 ]; } +${fingerprint} +${build}`; + const env = { ...process.env, PATH: `${root}/bin:${process.env.PATH}`, APP_ROOT: root, + XDG_CACHE_HOME: path.join(root, 'cache'), PLATFORM: platform, IOS_DEVICE_MODE: mode, + APP_VARIANT: 'development', SKIP_BUILD: '0', REBUILD: '0', INSTALLED: '1', + FIXTURE_HASH: newHash, EVENTS: path.join(root, 'events') }; + const target = platform === 'android' ? 'pixel-fixture' : mode === 'physical' ? 'phone-fixture' : 'sim-fixture'; + const stamp = path.join(env.XDG_CACHE_HOME, `audiolab-dev-client/net.siteed.fixture.development/${platform}-${target}.fingerprint`); + return { + root, env, stamp, + run: overrides => execute('bash', ['-c', script], { env: { ...env, ...overrides }, timeout: 10000 }), + seed: async hash => { await fs.mkdir(path.dirname(stamp), { recursive: true }); await fs.writeFile(stamp, `${hash}\n`); }, + events: async () => fs.readFile(env.EVENTS, 'utf8').catch(() => ''), + }; +} + +for (const [platform, mode] of [['android', 'physical'], ['ios', 'physical'], ['ios', 'simulator']]) { + test(`${platform} ${mode}: missing stamp rebuilds, then matching stamp reuses, changed stamp rebuilds`, async t => { + const f = await fixture(t, platform, mode); + assert.match((await f.run()).stdout, /fingerprint changed \(missing →/); + assert.equal(await fs.readFile(f.stamp, 'utf8'), `${newHash}\n`); + assert.equal(await f.events(), 'build\ninstall\n'); + assert.match((await f.run()).stdout, /fingerprint unchanged/); + assert.equal(await f.events(), 'build\ninstall\n'); + await f.seed(oldHash); + assert.match((await f.run()).stdout, new RegExp(`fingerprint changed \\(${oldHash} → ${newHash}\\)`)); + assert.equal(await f.events(), 'build\ninstall\nbuild\ninstall\n'); + }); +} + +test('SKIP_BUILD warns without rebuilding or stamping an installed stale client', async t => { + const f = await fixture(t); + await f.seed(oldHash); + assert.match((await f.run({ SKIP_BUILD: '1' })).stdout, /WARNING: dev client native fingerprint changed/); + assert.equal(await f.events(), ''); + assert.equal(await fs.readFile(f.stamp, 'utf8'), `${oldHash}\n`); +}); + +test('failed build or install retains the previous fingerprint', async t => { + for (const failure of ['FAIL_BUILD', 'FAIL_INSTALL']) { + const f = await fixture(t); + await f.seed(oldHash); + await assert.rejects(f.run({ [failure]: '1' })); + assert.equal(await fs.readFile(f.stamp, 'utf8'), `${oldHash}\n`); + } +}); + +test('fingerprint failure fails safely, while SKIP_BUILD keeps its installed-client behavior', async t => { + const f = await fixture(t); + await assert.rejects(f.run({ FAIL_FINGERPRINT: '1' })); + assert.equal(await f.events(), ''); + assert.match((await f.run({ FAIL_FINGERPRINT: '1', SKIP_BUILD: '1' })).stdout, /WARNING: Could not check/); + assert.equal(await f.events(), ''); +}); + +test('REBUILD forces reinstall with a matching fingerprint and missing app always builds', async t => { + const f = await fixture(t); + await f.seed(newHash); + await f.run({ REBUILD: '1' }); + await f.run({ INSTALLED: '0' }); + assert.equal(await f.events(), 'build\ninstall\nbuild\ninstall\n'); +}); + +test('variants and devices do not share stamps', async t => { + const f = await fixture(t); + await f.seed(newHash); + assert.match((await f.run({ APP_VARIANT: 'preview' })).stdout, /fingerprint changed \(missing →/); + assert.equal(await f.events(), 'build\ninstall\n'); + assert.equal(await fs.readFile(f.stamp, 'utf8'), `${newHash}\n`); + assert.match((await f.run({ FIXTURE_DEVICE: 'second-pixel' })).stdout, /fingerprint changed \(missing →/); + assert.equal(await f.events(), 'build\ninstall\nbuild\ninstall\n'); + assert.equal(await fs.readFile(f.stamp, 'utf8'), `${newHash}\n`); +}); From a5420429967d89f884bd3dc580a2ccab7951abf7 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 2 Nov 2026 16:32:38 +0800 Subject: [PATCH 2/5] fix: address self-review feedback (MANUAL-000172) --- scripts/agentic/preflight.sh | 57 ++++++++++++------- .../agentic/{ => recipe}/preflight.test.mjs | 24 +++++++- 2 files changed, 58 insertions(+), 23 deletions(-) rename scripts/agentic/{ => recipe}/preflight.test.mjs (81%) diff --git a/scripts/agentic/preflight.sh b/scripts/agentic/preflight.sh index 53979abc7..6c836d196 100755 --- a/scripts/agentic/preflight.sh +++ b/scripts/agentic/preflight.sh @@ -75,12 +75,18 @@ IOS_DEVELOPMENT_TEAM="${IOS_DEVELOPMENT_TEAM:-${APPLE_TEAM_ID:-}}" native_fingerprint() { APP_VARIANT="$APP_VARIANT_EFFECTIVE" node - "$APP_ROOT" "$PLATFORM" <<'FINGERPRINT' -// Hash workspace native sources too, even when their package version is unchanged. +// Include shared audio-studio C++ and native codegen specs; exclude other workspace JS and documentation. Promise.resolve().then(() => { const [appRoot, platform] = process.argv.slice(2); const expo = require.resolve('expo', { paths: [appRoot] }); const { createProjectHashAsync } = require(require.resolve('@expo/fingerprint', { paths: [expo] })); - return createProjectHashAsync(appRoot, { platforms: [platform], preset: 'strict', concurrentIoLimit: 4, silent: true }); + return createProjectHashAsync(appRoot, { + platforms: [platform], preset: 'strict', concurrentIoLimit: 4, silent: true, + extraSources: [{ type: 'dir', filePath: '../../packages/audio-studio/cpp', reasons: ['audioStudioSharedCpp'] }], + ignorePaths: ['docs/**/*', 'src/**/*', 'lib/**/*', '*.md', 'wasm/**/*', 'wasm-src/**/*'] + .map(pattern => `../../packages/*/${pattern}`) + .concat('!../../packages/*/src/**/Native*.ts'), + }); }) .then(hash => { if (!/^[a-f0-9]{40}$/.test(hash)) throw new Error('Invalid native fingerprint'); @@ -377,21 +383,24 @@ else fi fi FINGERPRINT_STAMP="${XDG_CACHE_HOME:-${HOME}/.cache}/audiolab-dev-client/${FINGERPRINT_BUNDLE}/${PLATFORM}-${FINGERPRINT_DEVICE}.fingerprint" -if ! CURRENT_NATIVE_FINGERPRINT=$(native_fingerprint); then - if [ "${SKIP_BUILD:-0}" != 1 ]; then exit 1; fi - info "WARNING: Could not check dev client native fingerprint; keeping SKIP_BUILD=1 behavior" -elif [ "$APP_INSTALLED" = true ]; then - INSTALLED_NATIVE_FINGERPRINT=$(cat "$FINGERPRINT_STAMP" 2>/dev/null || true) - if [[ ! "$INSTALLED_NATIVE_FINGERPRINT" =~ ^[a-f0-9]{40}$ ]]; then INSTALLED_NATIVE_FINGERPRINT=missing; fi - if [ "$INSTALLED_NATIVE_FINGERPRINT" != "$CURRENT_NATIVE_FINGERPRINT" ]; then - if [ "${SKIP_BUILD:-0}" = 1 ]; then - info "WARNING: dev client native fingerprint changed (${INSTALLED_NATIVE_FINGERPRINT} → ${CURRENT_NATIVE_FINGERPRINT}); SKIP_BUILD=1 keeps the installed client" +CURRENT_NATIVE_FINGERPRINT="" +if [ "$APP_INSTALLED" = true ] && [ "${REBUILD:-0}" != 1 ]; then + if ! CURRENT_NATIVE_FINGERPRINT=$(native_fingerprint); then + if [ "${SKIP_BUILD:-0}" != 1 ]; then exit 1; fi + info "WARNING: Could not check dev client native fingerprint; keeping SKIP_BUILD=1 behavior" + else + INSTALLED_NATIVE_FINGERPRINT=$(cat "$FINGERPRINT_STAMP" 2>/dev/null || true) + if [[ ! "$INSTALLED_NATIVE_FINGERPRINT" =~ ^[a-f0-9]{40}$ ]]; then INSTALLED_NATIVE_FINGERPRINT=missing; fi + if [ "$INSTALLED_NATIVE_FINGERPRINT" != "$CURRENT_NATIVE_FINGERPRINT" ]; then + if [ "${SKIP_BUILD:-0}" = 1 ]; then + info "WARNING: dev client native fingerprint changed (${INSTALLED_NATIVE_FINGERPRINT} → ${CURRENT_NATIVE_FINGERPRINT}); SKIP_BUILD=1 keeps the installed client" + else + info "dev client native fingerprint changed (${INSTALLED_NATIVE_FINGERPRINT} → ${CURRENT_NATIVE_FINGERPRINT})" + APP_INSTALLED=false + fi else - info "dev client native fingerprint changed (${INSTALLED_NATIVE_FINGERPRINT} → ${CURRENT_NATIVE_FINGERPRINT})" - APP_INSTALLED=false + pass "Dev client native fingerprint unchanged (${CURRENT_NATIVE_FINGERPRINT})" fi - else - pass "Dev client native fingerprint unchanged (${CURRENT_NATIVE_FINGERPRINT})" fi fi @@ -399,7 +408,7 @@ fi if [ "${REBUILD:-0}" = "1" ]; then APP_INSTALLED=false; fi if [ "$APP_INSTALLED" = false ]; then - info "App not installed — building..." + info "Development client needs a build..." # Run pre-build hook if defined if [ -n "${AGENTIC_PRE_BUILD_HOOK:-}" ]; then @@ -413,7 +422,11 @@ if [ "$APP_INSTALLED" = false ]; then if [ "$PLATFORM" = ios ]; then resolve_ios_workspace_and_scheme "$APP_VARIANT_EFFECTIVE"; fi # Capture the inputs after dependency hooks/prebuild, before native compilation. - CURRENT_NATIVE_FINGERPRINT=$(native_fingerprint) + if ! CURRENT_NATIVE_FINGERPRINT=$(native_fingerprint); then + if [ "${SKIP_BUILD:-0}" != 1 ]; then exit 1; fi + CURRENT_NATIVE_FINGERPRINT="" + info "WARNING: Could not compute dev client native fingerprint; SKIP_BUILD=1 continues the build without recording a stamp" + fi if [ "$PLATFORM" = "ios" ] && [ "${IOS_DEVICE_MODE:-simulator}" = "physical" ]; then info "Building iOS app for physical device ${IOS_DEVICE_UDID} using ${IOS_SCHEME_NAME}..." @@ -465,10 +478,12 @@ if [ "$APP_INSTALLED" = false ]; then adb -s "${SERIAL}" install -r "$APK_PATH" pass "Android app built and installed on ${SERIAL}" fi - mkdir -p "$(dirname "$FINGERPRINT_STAMP")" - printf '%s\n' "$CURRENT_NATIVE_FINGERPRINT" > "${FINGERPRINT_STAMP}.$$" - mv "${FINGERPRINT_STAMP}.$$" "$FINGERPRINT_STAMP" - pass "Recorded dev client native fingerprint (${CURRENT_NATIVE_FINGERPRINT})" + if [ -n "$CURRENT_NATIVE_FINGERPRINT" ]; then + mkdir -p "$(dirname "$FINGERPRINT_STAMP")" + printf '%s\n' "$CURRENT_NATIVE_FINGERPRINT" > "${FINGERPRINT_STAMP}.$$" + mv "${FINGERPRINT_STAMP}.$$" "$FINGERPRINT_STAMP" + pass "Recorded dev client native fingerprint (${CURRENT_NATIVE_FINGERPRINT})" + fi fi # ── 4. Launch via Expo dev client deep link ──────────────────── diff --git a/scripts/agentic/preflight.test.mjs b/scripts/agentic/recipe/preflight.test.mjs similarity index 81% rename from scripts/agentic/preflight.test.mjs rename to scripts/agentic/recipe/preflight.test.mjs index 5780863f2..8a0f1d474 100644 --- a/scripts/agentic/preflight.test.mjs +++ b/scripts/agentic/recipe/preflight.test.mjs @@ -7,7 +7,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const execute = promisify(execFile); -const source = await fs.readFile(new URL('./preflight.sh', import.meta.url), 'utf8'); +const source = await fs.readFile(new URL('../preflight.sh', import.meta.url), 'utf8'); const fingerprint = source.slice(source.indexOf('native_fingerprint() {'), source.indexOf('run_simctl_timeout() {')); const build = source.slice(source.indexOf('info "Step 3:'), source.indexOf('# ── 4. Launch')); const oldHash = '1'.repeat(40); @@ -25,8 +25,13 @@ async function fixture(t, platform = 'android', mode = 'physical') { await fs.writeFile(path.join(root, 'node_modules/expo/index.js'), ''); await fs.writeFile(path.join(root, 'node_modules/@expo/fingerprint/index.js'), ` exports.createProjectHashAsync = async (root, options) => { + require('node:fs').appendFileSync(process.env.FINGERPRINT_EVENTS, 'fingerprint\\n'); require('node:assert/strict').deepEqual(options, { platforms: [process.env.PLATFORM], preset: 'strict', concurrentIoLimit: 4, silent: true, + extraSources: [{ type: 'dir', filePath: '../../packages/audio-studio/cpp', reasons: ['audioStudioSharedCpp'] }], + ignorePaths: ['docs/**/*', 'src/**/*', 'lib/**/*', '*.md', 'wasm/**/*', 'wasm-src/**/*'] + .map(pattern => '../../packages/*/' + pattern) + .concat('!../../packages/*/src/**/Native*.ts'), }); if (process.env.FAIL_FINGERPRINT === '1') throw new Error('fixture failure'); return process.env.FIXTURE_HASH; @@ -62,7 +67,7 @@ ${build}`; const env = { ...process.env, PATH: `${root}/bin:${process.env.PATH}`, APP_ROOT: root, XDG_CACHE_HOME: path.join(root, 'cache'), PLATFORM: platform, IOS_DEVICE_MODE: mode, APP_VARIANT: 'development', SKIP_BUILD: '0', REBUILD: '0', INSTALLED: '1', - FIXTURE_HASH: newHash, EVENTS: path.join(root, 'events') }; + FIXTURE_HASH: newHash, EVENTS: path.join(root, 'events'), FINGERPRINT_EVENTS: path.join(root, 'fingerprint-events') }; const target = platform === 'android' ? 'pixel-fixture' : mode === 'physical' ? 'phone-fixture' : 'sim-fixture'; const stamp = path.join(env.XDG_CACHE_HOME, `audiolab-dev-client/net.siteed.fixture.development/${platform}-${target}.fingerprint`); return { @@ -70,6 +75,7 @@ ${build}`; run: overrides => execute('bash', ['-c', script], { env: { ...env, ...overrides }, timeout: 10000 }), seed: async hash => { await fs.mkdir(path.dirname(stamp), { recursive: true }); await fs.writeFile(stamp, `${hash}\n`); }, events: async () => fs.readFile(env.EVENTS, 'utf8').catch(() => ''), + fingerprintCalls: async () => (await fs.readFile(env.FINGERPRINT_EVENTS, 'utf8').catch(() => '')).split('\n').filter(Boolean).length, }; } @@ -116,10 +122,24 @@ test('REBUILD forces reinstall with a matching fingerprint and missing app alway const f = await fixture(t); await f.seed(newHash); await f.run({ REBUILD: '1' }); + assert.equal(await f.fingerprintCalls(), 1); await f.run({ INSTALLED: '0' }); + assert.equal(await f.fingerprintCalls(), 2); assert.equal(await f.events(), 'build\ninstall\nbuild\ninstall\n'); }); +test('SKIP_BUILD installs a missing Android client despite fingerprint failure without stamping', async t => { + const f = await fixture(t); + const result = await f.run({ SKIP_BUILD: '1', INSTALLED: '0', FAIL_FINGERPRINT: '1' }); + assert.match(result.stdout, /continues the build without recording a stamp/); + assert.equal(await f.fingerprintCalls(), 1); + assert.equal(await f.events(), 'build\ninstall\n'); + await assert.rejects(fs.access(f.stamp)); + await f.seed(oldHash); + await f.run({ SKIP_BUILD: '1', INSTALLED: '0', FAIL_FINGERPRINT: '1' }); + assert.equal(await fs.readFile(f.stamp, 'utf8'), `${oldHash}\n`); +}); + test('variants and devices do not share stamps', async t => { const f = await fixture(t); await f.seed(newHash); From 8bd7b9009477cabe29326addcdfa29a353f9da48 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 3 Nov 2026 08:54:53 +0800 Subject: [PATCH 3/5] fix(agentic): ignore generated Moonshine iOS slices in native fingerprints --- scripts/agentic/preflight.sh | 2 +- scripts/agentic/recipe/preflight.test.mjs | 48 ++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/scripts/agentic/preflight.sh b/scripts/agentic/preflight.sh index 6c836d196..24865a14d 100755 --- a/scripts/agentic/preflight.sh +++ b/scripts/agentic/preflight.sh @@ -85,7 +85,7 @@ Promise.resolve().then(() => { extraSources: [{ type: 'dir', filePath: '../../packages/audio-studio/cpp', reasons: ['audioStudioSharedCpp'] }], ignorePaths: ['docs/**/*', 'src/**/*', 'lib/**/*', '*.md', 'wasm/**/*', 'wasm-src/**/*'] .map(pattern => `../../packages/*/${pattern}`) - .concat('!../../packages/*/src/**/Native*.ts'), + .concat('!../../packages/*/src/**/Native*.ts', '../../packages/moonshine.rn/prebuilt/ios/current/**/*'), }); }) .then(hash => { diff --git a/scripts/agentic/recipe/preflight.test.mjs b/scripts/agentic/recipe/preflight.test.mjs index 8a0f1d474..8ed6a9c5a 100644 --- a/scripts/agentic/recipe/preflight.test.mjs +++ b/scripts/agentic/recipe/preflight.test.mjs @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { createRequire } from 'node:module'; const execute = promisify(execFile); const source = await fs.readFile(new URL('../preflight.sh', import.meta.url), 'utf8'); @@ -31,7 +32,7 @@ exports.createProjectHashAsync = async (root, options) => { extraSources: [{ type: 'dir', filePath: '../../packages/audio-studio/cpp', reasons: ['audioStudioSharedCpp'] }], ignorePaths: ['docs/**/*', 'src/**/*', 'lib/**/*', '*.md', 'wasm/**/*', 'wasm-src/**/*'] .map(pattern => '../../packages/*/' + pattern) - .concat('!../../packages/*/src/**/Native*.ts'), + .concat('!../../packages/*/src/**/Native*.ts', '../../packages/moonshine.rn/prebuilt/ios/current/**/*'), }); if (process.env.FAIL_FINGERPRINT === '1') throw new Error('fixture failure'); return process.env.FIXTURE_HASH; @@ -150,3 +151,48 @@ test('variants and devices do not share stamps', async t => { assert.equal(await f.events(), 'build\ninstall\nbuild\ninstall\n'); assert.equal(await fs.readFile(f.stamp, 'utf8'), `${newHash}\n`); }); + +test('Moonshine device/simulator slice swaps preserve both platform fingerprints while native edits invalidate them', async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'audiolab-fingerprint-probe-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const appRoot = path.join(root, 'apps/playground'); + const moonshine = path.join(root, 'packages/moonshine.rn'); + for (const folder of ['node_modules/expo', 'node_modules/@expo/fingerprint']) { + await fs.mkdir(path.join(appRoot, folder), { recursive: true }); + } + await fs.mkdir(path.join(moonshine, 'prebuilt/ios/current'), { recursive: true }); + await fs.mkdir(path.join(moonshine, 'prebuilt/ios/Moonshine.xcframework/ios-arm64'), { recursive: true }); + await fs.mkdir(path.join(root, 'packages/audio-studio/cpp'), { recursive: true }); + await fs.writeFile(path.join(appRoot, 'package.json'), JSON.stringify({ name: 'fingerprint-probe', version: '1.0.0' })); + await fs.writeFile(path.join(appRoot, 'app.json'), JSON.stringify({ expo: { name: 'Probe', slug: 'probe' } })); + await fs.writeFile(path.join(appRoot, 'node_modules/expo/index.js'), ''); + const require = createRequire(new URL('../../../apps/playground/package.json', import.meta.url)); + const expo = require.resolve('expo'); + const realFingerprint = require.resolve('@expo/fingerprint', { paths: [expo] }); + // Supply the workspace package root as autolinking would, while retaining the preflight options. + await fs.writeFile(path.join(appRoot, 'node_modules/@expo/fingerprint/index.js'), ` +exports.createProjectHashAsync = (root, options) => require(${JSON.stringify(realFingerprint)}).createProjectHashAsync(root, { + ...options, + extraSources: [...options.extraSources, { type: 'dir', filePath: '../../packages/moonshine.rn', reasons: ['fixtureNativeModule'] }], +}).catch(error => { console.error(error); throw error; }); +`); + const slice = path.join(moonshine, 'prebuilt/ios/current/libmoonshine_core.a'); + const native = path.join(moonshine, 'prebuilt/ios/Moonshine.xcframework/ios-arm64/libmoonshine.a'); + await fs.writeFile(native, 'native library\n'); + await fs.writeFile(slice, 'device slice\n'); + const hash = async platform => (await execute('bash', ['-c', `${fingerprint}\nnative_fingerprint`], { + env: { ...process.env, APP_ROOT: appRoot, APP_VARIANT_EFFECTIVE: 'development', PLATFORM: platform }, + cwd: appRoot, + timeout: 30000, + })).stdout; + const baseline = {}; + for (const platform of ['android', 'ios']) baseline[platform] = await hash(platform); + await fs.writeFile(slice, 'simulator slice\n'); + for (const platform of ['android', 'ios']) { + assert.equal(await hash(platform), baseline[platform], `${platform}: generated slice changed fingerprint`); + } + await fs.writeFile(native, 'changed native library\n'); + for (const platform of ['android', 'ios']) { + assert.notEqual(await hash(platform), baseline[platform], `${platform}: native change was ignored`); + } +}); From 4a361ae745a1c52863a896c71a1af6699ff8e2de Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 3 Nov 2026 09:59:16 +0800 Subject: [PATCH 4/5] fix(agentic): stabilize generated native inputs and preserve fingerprint errors --- scripts/agentic/preflight.sh | 12 ++++-- scripts/agentic/recipe/preflight.test.mjs | 48 ++++++++++++++++++++--- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/scripts/agentic/preflight.sh b/scripts/agentic/preflight.sh index 24865a14d..004d8e758 100755 --- a/scripts/agentic/preflight.sh +++ b/scripts/agentic/preflight.sh @@ -75,24 +75,28 @@ IOS_DEVELOPMENT_TEAM="${IOS_DEVELOPMENT_TEAM:-${APPLE_TEAM_ID:-}}" native_fingerprint() { APP_VARIANT="$APP_VARIANT_EFFECTIVE" node - "$APP_ROOT" "$PLATFORM" <<'FINGERPRINT' -// Include shared audio-studio C++ and native codegen specs; exclude other workspace JS and documentation. +// Include shared audio-studio C++, config plugin sources and native codegen specs; exclude other workspace JS and documentation. Promise.resolve().then(() => { const [appRoot, platform] = process.argv.slice(2); const expo = require.resolve('expo', { paths: [appRoot] }); const { createProjectHashAsync } = require(require.resolve('@expo/fingerprint', { paths: [expo] })); return createProjectHashAsync(appRoot, { platforms: [platform], preset: 'strict', concurrentIoLimit: 4, silent: true, - extraSources: [{ type: 'dir', filePath: '../../packages/audio-studio/cpp', reasons: ['audioStudioSharedCpp'] }], + extraSources: [ + { type: 'dir', filePath: '../../packages/audio-studio/cpp', reasons: ['audioStudioSharedCpp'] }, + { type: 'dir', filePath: '../../packages/audio-studio/plugin/src', reasons: ['audioStudioConfigPlugin'] }, + ], ignorePaths: ['docs/**/*', 'src/**/*', 'lib/**/*', '*.md', 'wasm/**/*', 'wasm-src/**/*'] .map(pattern => `../../packages/*/${pattern}`) - .concat('!../../packages/*/src/**/Native*.ts', '../../packages/moonshine.rn/prebuilt/ios/current/**/*'), + .concat('!../../packages/*/src/**/Native*.ts', '../../packages/moonshine.rn/prebuilt/ios/current/**/*', + '../../packages/moonshine.rn/prebuilt/android/moonshine-voice-isolated.aar'), }); }) .then(hash => { if (!/^[a-f0-9]{40}$/.test(hash)) throw new Error('Invalid native fingerprint'); process.stdout.write(hash); }) - .catch(() => { console.error('[preflight] FAIL Could not compute native fingerprint'); process.exitCode = 1; }); + .catch(error => { console.error('[preflight] FAIL Could not compute native fingerprint:', error.message); process.exitCode = 1; }); FINGERPRINT } diff --git a/scripts/agentic/recipe/preflight.test.mjs b/scripts/agentic/recipe/preflight.test.mjs index 8ed6a9c5a..a4b508d31 100644 --- a/scripts/agentic/recipe/preflight.test.mjs +++ b/scripts/agentic/recipe/preflight.test.mjs @@ -29,10 +29,14 @@ exports.createProjectHashAsync = async (root, options) => { require('node:fs').appendFileSync(process.env.FINGERPRINT_EVENTS, 'fingerprint\\n'); require('node:assert/strict').deepEqual(options, { platforms: [process.env.PLATFORM], preset: 'strict', concurrentIoLimit: 4, silent: true, - extraSources: [{ type: 'dir', filePath: '../../packages/audio-studio/cpp', reasons: ['audioStudioSharedCpp'] }], + extraSources: [ + { type: 'dir', filePath: '../../packages/audio-studio/cpp', reasons: ['audioStudioSharedCpp'] }, + { type: 'dir', filePath: '../../packages/audio-studio/plugin/src', reasons: ['audioStudioConfigPlugin'] }, + ], ignorePaths: ['docs/**/*', 'src/**/*', 'lib/**/*', '*.md', 'wasm/**/*', 'wasm-src/**/*'] .map(pattern => '../../packages/*/' + pattern) - .concat('!../../packages/*/src/**/Native*.ts', '../../packages/moonshine.rn/prebuilt/ios/current/**/*'), + .concat('!../../packages/*/src/**/Native*.ts', '../../packages/moonshine.rn/prebuilt/ios/current/**/*', + '../../packages/moonshine.rn/prebuilt/android/moonshine-voice-isolated.aar'), }); if (process.env.FAIL_FINGERPRINT === '1') throw new Error('fixture failure'); return process.env.FIXTURE_HASH; @@ -113,9 +117,14 @@ test('failed build or install retains the previous fingerprint', async t => { test('fingerprint failure fails safely, while SKIP_BUILD keeps its installed-client behavior', async t => { const f = await fixture(t); - await assert.rejects(f.run({ FAIL_FINGERPRINT: '1' })); + await assert.rejects(f.run({ FAIL_FINGERPRINT: '1' }), error => { + assert.match(error.stderr, /Could not compute native fingerprint: fixture failure/); + return true; + }); assert.equal(await f.events(), ''); - assert.match((await f.run({ FAIL_FINGERPRINT: '1', SKIP_BUILD: '1' })).stdout, /WARNING: Could not check/); + const skipped = await f.run({ FAIL_FINGERPRINT: '1', SKIP_BUILD: '1' }); + assert.match(skipped.stdout, /WARNING: Could not check/); + assert.match(skipped.stderr, /Could not compute native fingerprint: fixture failure/); assert.equal(await f.events(), ''); }); @@ -152,7 +161,7 @@ test('variants and devices do not share stamps', async t => { assert.equal(await fs.readFile(f.stamp, 'utf8'), `${newHash}\n`); }); -test('Moonshine device/simulator slice swaps preserve both platform fingerprints while native edits invalidate them', async t => { +test('generated Moonshine artifacts preserve both platform fingerprints while native inputs invalidate them', async t => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'audiolab-fingerprint-probe-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const appRoot = path.join(root, 'apps/playground'); @@ -162,7 +171,9 @@ test('Moonshine device/simulator slice swaps preserve both platform fingerprints } await fs.mkdir(path.join(moonshine, 'prebuilt/ios/current'), { recursive: true }); await fs.mkdir(path.join(moonshine, 'prebuilt/ios/Moonshine.xcframework/ios-arm64'), { recursive: true }); + await fs.mkdir(path.join(moonshine, 'prebuilt/android'), { recursive: true }); await fs.mkdir(path.join(root, 'packages/audio-studio/cpp'), { recursive: true }); + await fs.mkdir(path.join(root, 'packages/audio-studio/plugin/src'), { recursive: true }); await fs.writeFile(path.join(appRoot, 'package.json'), JSON.stringify({ name: 'fingerprint-probe', version: '1.0.0' })); await fs.writeFile(path.join(appRoot, 'app.json'), JSON.stringify({ expo: { name: 'Probe', slug: 'probe' } })); await fs.writeFile(path.join(appRoot, 'node_modules/expo/index.js'), ''); @@ -178,6 +189,13 @@ exports.createProjectHashAsync = (root, options) => require(${JSON.stringify(rea `); const slice = path.join(moonshine, 'prebuilt/ios/current/libmoonshine_core.a'); const native = path.join(moonshine, 'prebuilt/ios/Moonshine.xcframework/ios-arm64/libmoonshine.a'); + const aar = path.join(moonshine, 'prebuilt/android/moonshine-voice-isolated.aar'); + const manifest = path.join(moonshine, 'package.json'); + const packageJson = { name: '@siteed/moonshine.rn', version: '1.0.0', + moonshineArtifacts: { android: { isolatedAarSha256: '1'.repeat(64) } } }; + const plugin = path.join(root, 'packages/audio-studio/plugin/src/index.ts'); + await fs.writeFile(manifest, JSON.stringify(packageJson)); + await fs.writeFile(plugin, '// native config plugin\n'); await fs.writeFile(native, 'native library\n'); await fs.writeFile(slice, 'device slice\n'); const hash = async platform => (await execute('bash', ['-c', `${fingerprint}\nnative_fingerprint`], { @@ -191,8 +209,28 @@ exports.createProjectHashAsync = (root, options) => require(${JSON.stringify(rea for (const platform of ['android', 'ios']) { assert.equal(await hash(platform), baseline[platform], `${platform}: generated slice changed fingerprint`); } + await fs.writeFile(aar, 'generated isolated AAR\n'); + for (const platform of ['android', 'ios']) { + assert.equal(await hash(platform), baseline[platform], `${platform}: generated AAR changed fingerprint`); + } + await fs.unlink(aar); + for (const platform of ['android', 'ios']) { + assert.equal(await hash(platform), baseline[platform], `${platform}: removed AAR changed fingerprint`); + } + packageJson.moonshineArtifacts.android.isolatedAarSha256 = '2'.repeat(64); + await fs.writeFile(manifest, JSON.stringify(packageJson)); + for (const platform of ['android', 'ios']) { + assert.notEqual(await hash(platform), baseline[platform], `${platform}: AAR checksum change was ignored`); + } + packageJson.moonshineArtifacts.android.isolatedAarSha256 = '1'.repeat(64); + await fs.writeFile(manifest, JSON.stringify(packageJson)); await fs.writeFile(native, 'changed native library\n'); for (const platform of ['android', 'ios']) { assert.notEqual(await hash(platform), baseline[platform], `${platform}: native change was ignored`); } + await fs.writeFile(native, 'native library\n'); + await fs.writeFile(plugin, '// changed native config plugin\n'); + for (const platform of ['android', 'ios']) { + assert.notEqual(await hash(platform), baseline[platform], `${platform}: config plugin source change was ignored`); + } }); From 1da16ae86fed794ebb6f9672771f0a3c38bddf6c Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 3 Nov 2026 10:49:16 +0800 Subject: [PATCH 5/5] fix(agentic): exclude plugin tests from native fingerprints --- scripts/agentic/preflight.sh | 3 ++- scripts/agentic/recipe/preflight.test.mjs | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/agentic/preflight.sh b/scripts/agentic/preflight.sh index 004d8e758..780221229 100755 --- a/scripts/agentic/preflight.sh +++ b/scripts/agentic/preflight.sh @@ -89,7 +89,8 @@ Promise.resolve().then(() => { ignorePaths: ['docs/**/*', 'src/**/*', 'lib/**/*', '*.md', 'wasm/**/*', 'wasm-src/**/*'] .map(pattern => `../../packages/*/${pattern}`) .concat('!../../packages/*/src/**/Native*.ts', '../../packages/moonshine.rn/prebuilt/ios/current/**/*', - '../../packages/moonshine.rn/prebuilt/android/moonshine-voice-isolated.aar'), + '../../packages/moonshine.rn/prebuilt/android/moonshine-voice-isolated.aar', + '../../packages/audio-studio/plugin/src/**/*.test.ts'), }); }) .then(hash => { diff --git a/scripts/agentic/recipe/preflight.test.mjs b/scripts/agentic/recipe/preflight.test.mjs index a4b508d31..f52e76e17 100644 --- a/scripts/agentic/recipe/preflight.test.mjs +++ b/scripts/agentic/recipe/preflight.test.mjs @@ -36,7 +36,8 @@ exports.createProjectHashAsync = async (root, options) => { ignorePaths: ['docs/**/*', 'src/**/*', 'lib/**/*', '*.md', 'wasm/**/*', 'wasm-src/**/*'] .map(pattern => '../../packages/*/' + pattern) .concat('!../../packages/*/src/**/Native*.ts', '../../packages/moonshine.rn/prebuilt/ios/current/**/*', - '../../packages/moonshine.rn/prebuilt/android/moonshine-voice-isolated.aar'), + '../../packages/moonshine.rn/prebuilt/android/moonshine-voice-isolated.aar', + '../../packages/audio-studio/plugin/src/**/*.test.ts'), }); if (process.env.FAIL_FINGERPRINT === '1') throw new Error('fixture failure'); return process.env.FIXTURE_HASH; @@ -194,6 +195,11 @@ exports.createProjectHashAsync = (root, options) => require(${JSON.stringify(rea const packageJson = { name: '@siteed/moonshine.rn', version: '1.0.0', moonshineArtifacts: { android: { isolatedAarSha256: '1'.repeat(64) } } }; const plugin = path.join(root, 'packages/audio-studio/plugin/src/index.ts'); + const pluginTests = ['index.test.ts', 'nested/index.test.ts'].map(file => path.join(path.dirname(plugin), file)); + for (const file of pluginTests) { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, '// plugin test\n'); + } await fs.writeFile(manifest, JSON.stringify(packageJson)); await fs.writeFile(plugin, '// native config plugin\n'); await fs.writeFile(native, 'native library\n'); @@ -205,6 +211,10 @@ exports.createProjectHashAsync = (root, options) => require(${JSON.stringify(rea })).stdout; const baseline = {}; for (const platform of ['android', 'ios']) baseline[platform] = await hash(platform); + for (const file of pluginTests) await fs.writeFile(file, '// changed plugin test\n'); + for (const platform of ['android', 'ios']) { + assert.equal(await hash(platform), baseline[platform], `${platform}: config plugin test changed fingerprint`); + } await fs.writeFile(slice, 'simulator slice\n'); for (const platform of ['android', 'ios']) { assert.equal(await hash(platform), baseline[platform], `${platform}: generated slice changed fingerprint`);