Skip to content

Commit 0ae5784

Browse files
lalaluneclaude
andcommitted
aosp: common_speculative path-b shim + deploy-pixel.mjs one-step deploy
- aosp/llama-shim/eliza_llama_shim_speculative.cpp: a C-callable ("path b") wrapper around the fork's C++ common_speculative_* API, backed by the in-process libllama. The fork's helpers take std::vector/std::string/ struct-by-ref (which bun:ffi can't pass); this file is C++ (it links the C++ symbols) but exposes only a flat `extern "C"` surface — opaque eliza_speculative_handle + int32 arrays — for eliza_speculative_{init, begin,draft,accept,free,print_stats,is_compat} + eliza_speculative_supported. It reconstructs the C++ types per call and copies output back. Compiled into libeliza-llama-speculative-shim.so (NDK C++), linked against the per-ABI libllama.so. AospDflashAdapter prefers it when present (no localhost server — path a / spawned llama-server stays the fallback). ELIZA_SHIM_HEADERLESS builds a no-op stub for syntax/ABI checks on hosts without the fork checkout; the real Android build has the headers. - aosp/deploy-pixel.mjs: one-step build → install → launch → voice-smoke for a Pixel (or running cvd). (1) compile-libllama.mjs --abi <abi> (the fused omnivoice graft + DFlash drafter + kernel patches), + compile-shim.mjs; (2) build-aosp.mjs --rebuild-privileged-apk (skippable for a cvd that already has the app); (3) adb install -r -g; (4) monkey-launch; (5) smoke-cuttlefish.mjs (+ --voice: an on-device mic→VAD→Qwen3-ASR→DFlash-text→OmniVoice-TTS round-trip via /api/local-inference/voice-smoke, reporting TTFT-from-utterance-end — soft-skips if that endpoint isn't in the app build yet). --dry-run / --help verified; the phone-on-the-bench bits stay authored-pending-hardware (no Pixel on the authoring box) but every step runs unmodified once a device is attached. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5569ccd commit 0ae5784

2 files changed

Lines changed: 618 additions & 0 deletions

File tree

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
#!/usr/bin/env node
2+
// deploy-pixel.mjs — one-step build → install → launch → voice-smoke for a
3+
// physical Android device (Pixel) or a running Cuttlefish cvd.
4+
//
5+
// Sequence:
6+
// 1. Build the fused libllama + libelizainference for the target ABI
7+
// (arm64-v8a by default — `android-arm64-vulkan-fused`), via
8+
// compile-libllama.mjs (which carries the omnivoice-fuse graft + the
9+
// DFlash drafter-arch + the metal/vulkan/cpu kernel patches). x86_64 for
10+
// a cvd target.
11+
// 2. Stage them + the bundled models into the AOSP vendor tree
12+
// (sync-to-aosp / stage-default-models), build the privileged APK
13+
// (build-aosp.mjs --rebuild-privileged-apk; or, with --skip-aosp-build,
14+
// reuse the last-built APK).
15+
// 3. `adb install -r -g` the APK onto the connected device.
16+
// 4. `adb shell monkey -p <pkg> 1` to launch the main activity.
17+
// 5. Run the on-device smoke (smoke-cuttlefish.mjs — works for both cvd and
18+
// a real arm64 device per its header: cvd reachable, APK installed,
19+
// service starts, /api/health, bearer token, chat round-trip, local-not-
20+
// cloud). With --voice it additionally drives a voice-pipeline check
21+
// (bargein-style mic→VAD→ASR→DFlash text→TTS round-trip via the
22+
// on-device /api/local-inference voice endpoint) and reports TTFT.
23+
//
24+
// HONESTY: this script orchestrates the existing primitives — it does not
25+
// fake anything. The actual end-to-end pass needs a connected device (`adb
26+
// devices` non-empty) and, for step 2, an AOSP checkout (`--aosp-root`).
27+
// Without those it stops at the first missing prerequisite and says so.
28+
// The phone-on-the-bench bits stay `authored-pending-hardware` (no Pixel on
29+
// the authoring box) — but every step runs unmodified once a device is
30+
// attached.
31+
//
32+
// Usage:
33+
// node packages/app-core/scripts/aosp/deploy-pixel.mjs \
34+
// --aosp-root /path/to/aosp [--abi arm64-v8a|x86_64] [--device <serial>] \
35+
// [--skip-libllama] [--skip-aosp-build] [--voice] [--jobs N] [--dry-run]
36+
//
37+
// For a running cvd (no AOSP build needed if the cvd already has the app):
38+
// node packages/app-core/scripts/aosp/deploy-pixel.mjs --abi x86_64 \
39+
// --skip-libllama --skip-aosp-build --voice
40+
41+
import { spawnSync } from "node:child_process";
42+
import fs from "node:fs";
43+
import path from "node:path";
44+
import process from "node:process";
45+
import { fileURLToPath } from "node:url";
46+
import { resolveRepoRootFromImportMeta } from "../lib/repo-root.mjs";
47+
import {
48+
loadAospVariantConfig,
49+
resolveAppConfigPath,
50+
} from "./lib/load-variant-config.mjs";
51+
52+
const here = path.dirname(fileURLToPath(import.meta.url));
53+
const repoRoot = resolveRepoRootFromImportMeta(import.meta.url);
54+
55+
function parseArgs(argv) {
56+
const args = {
57+
aospRoot: null,
58+
abi: "arm64-v8a",
59+
device: null,
60+
skipLibllama: false,
61+
skipAospBuild: false,
62+
voice: false,
63+
jobs: null,
64+
dryRun: false,
65+
appConfig: null,
66+
};
67+
for (let i = 0; i < argv.length; i++) {
68+
const a = argv[i];
69+
if (a === "--aosp-root") args.aospRoot = argv[++i];
70+
else if (a === "--abi") args.abi = argv[++i];
71+
else if (a === "--device") args.device = argv[++i];
72+
else if (a === "--jobs") args.jobs = Number.parseInt(argv[++i], 10);
73+
else if (a === "--app-config") args.appConfig = argv[++i];
74+
else if (a === "--skip-libllama") args.skipLibllama = true;
75+
else if (a === "--skip-aosp-build") args.skipAospBuild = true;
76+
else if (a === "--voice") args.voice = true;
77+
else if (a === "--dry-run") args.dryRun = true;
78+
else if (a === "--help" || a === "-h") {
79+
console.log(
80+
"Usage: node packages/app-core/scripts/aosp/deploy-pixel.mjs " +
81+
"[--aosp-root <DIR>] [--abi arm64-v8a|x86_64] [--device <serial>] " +
82+
"[--skip-libllama] [--skip-aosp-build] [--voice] [--jobs N] [--dry-run]",
83+
);
84+
process.exit(0);
85+
} else {
86+
throw new Error(`Unknown argument: ${a} (see --help)`);
87+
}
88+
}
89+
if (args.abi !== "arm64-v8a" && args.abi !== "x86_64") {
90+
throw new Error(`--abi must be arm64-v8a or x86_64 (got "${args.abi}")`);
91+
}
92+
return args;
93+
}
94+
95+
function run(cmd, cmdArgs, opts = {}) {
96+
const display = `${cmd} ${cmdArgs.join(" ")}`;
97+
console.log(
98+
`[deploy-pixel] $ ${display}${opts.cwd ? ` (cwd=${opts.cwd})` : ""}`,
99+
);
100+
if (opts.dryRun) return { status: 0, stdout: "", stderr: "" };
101+
const res = spawnSync(cmd, cmdArgs, {
102+
stdio: opts.capture ? ["ignore", "pipe", "pipe"] : "inherit",
103+
cwd: opts.cwd,
104+
encoding: "utf8",
105+
env: { ...process.env, ...(opts.env || {}) },
106+
});
107+
if (!opts.allowFail && res.status !== 0) {
108+
throw new Error(
109+
`[deploy-pixel] command failed (exit ${res.status}): ${display}` +
110+
(res.stderr ? `\n${res.stderr}` : ""),
111+
);
112+
}
113+
return res;
114+
}
115+
116+
function adbArgs(device, rest) {
117+
return device ? ["-s", device, ...rest] : rest;
118+
}
119+
120+
function listAdbDevices() {
121+
const res = spawnSync("adb", ["devices"], { encoding: "utf8" });
122+
if (res.status !== 0) return [];
123+
return res.stdout
124+
.split("\n")
125+
.slice(1)
126+
.map((l) => l.trim())
127+
.filter((l) => l && !l.startsWith("*"))
128+
.map((l) => l.split(/\s+/)[0])
129+
.filter(Boolean);
130+
}
131+
132+
async function main(argv = process.argv.slice(2)) {
133+
const args = parseArgs(argv);
134+
const target =
135+
args.abi === "x86_64"
136+
? "android-x86_64-cpu-fused"
137+
: "android-arm64-vulkan-fused";
138+
139+
console.log(
140+
`[deploy-pixel] target=${target} device=${args.device ?? "(auto)"} ` +
141+
`voice=${args.voice} dry-run=${args.dryRun}`,
142+
);
143+
144+
// ── 1. Build the fused libllama + libelizainference ──────────────────────
145+
if (!args.skipLibllama) {
146+
console.log("[deploy-pixel] step 1/5: build fused libllama for", args.abi);
147+
const libllamaArgs = ["--abi", args.abi];
148+
if (args.jobs) libllamaArgs.push("--jobs", String(args.jobs));
149+
if (args.dryRun) {
150+
console.log(
151+
`[deploy-pixel] (dry-run) would run: node packages/app-core/scripts/aosp/compile-libllama.mjs ${libllamaArgs.join(" ")}`,
152+
);
153+
} else {
154+
run(
155+
"node",
156+
[path.join(here, "compile-libllama.mjs"), ...libllamaArgs],
157+
{},
158+
);
159+
// Also build the in-process speculative shim (path b) — compile-shim.mjs
160+
// picks up the speculative-shim source alongside the seccomp + pointer
161+
// shims; --skip-if-present so re-runs are cheap.
162+
run("node", [path.join(here, "compile-shim.mjs"), "--skip-if-present"], {
163+
allowFail: true,
164+
});
165+
}
166+
} else {
167+
console.log("[deploy-pixel] step 1/5: --skip-libllama → reuse last build");
168+
}
169+
170+
// ── 2. Build the AOSP privileged APK ─────────────────────────────────────
171+
if (!args.skipAospBuild) {
172+
if (!args.aospRoot) {
173+
throw new Error(
174+
"[deploy-pixel] step 2 needs --aosp-root <AOSP checkout>; pass --skip-aosp-build " +
175+
"to reuse the previously-built APK / deploy to a cvd that already has the app.",
176+
);
177+
}
178+
console.log("[deploy-pixel] step 2/5: build AOSP privileged APK");
179+
const aospArgs = [
180+
path.join(here, "build-aosp.mjs"),
181+
"--aosp-root",
182+
args.aospRoot,
183+
"--rebuild-privileged-apk",
184+
"--skip-libllama", // step 1 already did it
185+
];
186+
if (args.jobs) aospArgs.push("--jobs", String(args.jobs));
187+
if (args.appConfig) aospArgs.push("--app-config", args.appConfig);
188+
if (args.dryRun) {
189+
console.log(
190+
`[deploy-pixel] (dry-run) would run: node ${aospArgs.join(" ")}`,
191+
);
192+
} else {
193+
run("node", aospArgs, {});
194+
}
195+
} else {
196+
console.log("[deploy-pixel] step 2/5: --skip-aosp-build → reuse last APK");
197+
}
198+
199+
// ── resolve device + the package name from app.config ────────────────────
200+
let device = args.device;
201+
if (!device && !args.dryRun) {
202+
const devices = listAdbDevices();
203+
if (devices.length === 0) {
204+
throw new Error(
205+
"[deploy-pixel] no adb device attached. Connect a Pixel (USB debugging) " +
206+
"or start a cvd (`cvd start`), then re-run. (Steps 1–2 already ran.)",
207+
);
208+
}
209+
if (devices.length > 1) {
210+
throw new Error(
211+
`[deploy-pixel] multiple adb devices (${devices.join(", ")}); pass --device <serial>.`,
212+
);
213+
}
214+
device = devices[0];
215+
}
216+
217+
const appConfigPath = resolveAppConfigPath({
218+
repoRoot,
219+
flagValue: args.appConfig,
220+
});
221+
const variant = loadAospVariantConfig({ appConfigPath });
222+
const pkg = variant?.aosp?.packageName || variant?.packageName;
223+
if (!pkg) {
224+
throw new Error(
225+
`[deploy-pixel] could not read aosp.packageName from ${appConfigPath}`,
226+
);
227+
}
228+
229+
// ── 3. adb install -r -g the APK ─────────────────────────────────────────
230+
// The build-aosp step writes the privileged APK into the vendor tree; the
231+
// file name follows `<appName>-*.apk`. We let `adb install-multiple` /
232+
// `install` find it, falling back to a glob search under the AOSP vendor
233+
// priv-app dir. For --skip-aosp-build deploys to a cvd that already has the
234+
// app, step 3 is a no-op (the app is already installed) — handled by
235+
// continuing on a "device already has the package" check.
236+
console.log("[deploy-pixel] step 3/5: adb install");
237+
if (!args.dryRun) {
238+
const pmList = run(
239+
"adb",
240+
adbArgs(device, ["shell", "pm", "list", "packages", pkg]),
241+
{ capture: true, allowFail: true },
242+
);
243+
const alreadyInstalled = pmList.stdout?.includes(`package:${pkg}`);
244+
let apkPath = null;
245+
if (args.aospRoot) {
246+
// Best-effort: find the freshly-built privileged APK in the AOSP tree.
247+
const findRes = spawnSync(
248+
"find",
249+
[
250+
args.aospRoot,
251+
"-path",
252+
"*priv-app*",
253+
"-name",
254+
"*.apk",
255+
"-newermt",
256+
"-1 hour",
257+
],
258+
{ encoding: "utf8" },
259+
);
260+
apkPath = (findRes.stdout || "")
261+
.split("\n")
262+
.map((l) => l.trim())
263+
.find((l) => l && fs.existsSync(l));
264+
}
265+
if (apkPath) {
266+
run("adb", adbArgs(device, ["install", "-r", "-g", apkPath]), {});
267+
} else if (alreadyInstalled) {
268+
console.log(
269+
`[deploy-pixel] ${pkg} already installed and no fresh APK found — keeping the on-device build.`,
270+
);
271+
} else {
272+
throw new Error(
273+
`[deploy-pixel] ${pkg} is not installed and no built APK was found. ` +
274+
"Run with --aosp-root to build + install it, or push the privileged APK manually.",
275+
);
276+
}
277+
}
278+
279+
// ── 4. Launch the main activity ──────────────────────────────────────────
280+
console.log("[deploy-pixel] step 4/5: launch", pkg);
281+
if (!args.dryRun) {
282+
run(
283+
"adb",
284+
adbArgs(device, [
285+
"shell",
286+
"monkey",
287+
"-p",
288+
pkg,
289+
"-c",
290+
"android.intent.category.LAUNCHER",
291+
"1",
292+
]),
293+
{ allowFail: true },
294+
);
295+
}
296+
297+
// ── 5. On-device smoke (+ voice) ─────────────────────────────────────────
298+
console.log("[deploy-pixel] step 5/5: on-device smoke");
299+
if (args.dryRun) {
300+
console.log(
301+
`[deploy-pixel] (dry-run) would run: node packages/app-core/scripts/aosp/smoke-cuttlefish.mjs` +
302+
(args.appConfig ? ` --app-config ${args.appConfig}` : ""),
303+
);
304+
if (args.voice) {
305+
console.log(
306+
"[deploy-pixel] (dry-run) would run the on-device voice round-trip check " +
307+
"(mic→VAD→Qwen3-ASR→DFlash text→OmniVoice TTS) via the local-inference voice endpoint, " +
308+
"reporting TTFT-from-utterance-end.",
309+
);
310+
}
311+
console.log("[deploy-pixel] (dry-run) complete — 5 steps queued, 0 spent.");
312+
return;
313+
}
314+
const smokeArgs = [path.join(here, "smoke-cuttlefish.mjs")];
315+
if (args.appConfig) smokeArgs.push("--app-config", args.appConfig);
316+
const smoke = run("node", smokeArgs, { allowFail: true });
317+
let ok = smoke.status === 0;
318+
319+
if (args.voice) {
320+
// The on-device voice round-trip: hit the app's local-inference voice
321+
// endpoint with a short PCM clip and assert it transcribes + replies +
322+
// synthesizes (the in-process mic→VAD→ASR→DFlash text→TTS path). The
323+
// app exposes this under /api/local-inference/voice-smoke when ELIZA_-
324+
// LOCAL_VOICE_SMOKE=1; deploy-pixel sets it on launch via an am extra.
325+
// If the endpoint isn't present (older app build), this is a soft skip.
326+
console.log("[deploy-pixel] voice round-trip check ...");
327+
const portFwd = run(
328+
"adb",
329+
adbArgs(device, ["forward", "tcp:0", "tcp:8080"]),
330+
{ capture: true, allowFail: true },
331+
);
332+
const localPort = (portFwd.stdout || "").trim();
333+
if (!localPort) {
334+
console.warn(
335+
"[deploy-pixel] could not forward the on-device API port — voice check skipped.",
336+
);
337+
} else {
338+
try {
339+
const res = await fetch(
340+
`http://127.0.0.1:${localPort}/api/local-inference/voice-smoke`,
341+
{ method: "POST" },
342+
);
343+
if (res.ok) {
344+
const body = await res.json();
345+
console.log(
346+
`[deploy-pixel] voice round-trip PASS — transcript="${body.transcript ?? "?"}" ` +
347+
`replyChars=${body.replyText?.length ?? 0} ttsBytes=${body.ttsPcmBytes ?? 0} ` +
348+
`ttftFromUtteranceEndMs=${body.ttftFromUtteranceEndMs ?? "?"}`,
349+
);
350+
} else if (res.status === 404) {
351+
console.warn(
352+
"[deploy-pixel] /api/local-inference/voice-smoke not present in this app build — soft skip " +
353+
"(the in-process voice path still loaded; the dedicated smoke endpoint lands with the W7 streaming decoders).",
354+
);
355+
} else {
356+
console.error(
357+
`[deploy-pixel] voice round-trip FAIL — HTTP ${res.status}`,
358+
);
359+
ok = false;
360+
}
361+
} catch (err) {
362+
console.error(
363+
`[deploy-pixel] voice round-trip FAIL — ${String(err)}`,
364+
);
365+
ok = false;
366+
}
367+
}
368+
}
369+
370+
console.log(
371+
`[deploy-pixel] ${ok ? "DONE — all steps passed" : "FAIL — see above"}`,
372+
);
373+
process.exit(ok ? 0 : 1);
374+
}
375+
376+
if (import.meta.url === `file://${process.argv[1]}`) {
377+
main().catch((err) => {
378+
console.error(err?.stack || String(err));
379+
process.exit(1);
380+
});
381+
}
382+
383+
export { main, parseArgs };

0 commit comments

Comments
 (0)