Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
72f8932
fix(litert-lm-server): stream use-after-free; crash on non-UTF-8 errors
ciroprogamer Oct 14, 2026
c3f51e5
fix(litert-lm-server): reject out-of-range .litertlm header offsets
ciroprogamer Oct 14, 2026
242b9ae
fix(android): keep models, caches, logs and the daemon out of backups
ciroprogamer Oct 14, 2026
8568672
fix(android): per-launch app secret so other apps can't use the daemon
ciroprogamer Oct 14, 2026
758b58e
fix(android): count downloads as busy; re-post a swiped notification
ciroprogamer Oct 14, 2026
a99c35f
fix(android): stage the daemon's real version
ciroprogamer Oct 14, 2026
ff6dad1
fix(play-listing): parse listing-copy.md with CRLF line endings
ciroprogamer Oct 14, 2026
ced6bac
fix(android): send the app secret only to a daemon that proves itself
ciroprogamer Oct 15, 2026
3f0ed38
fix(android): load localhost:7000 links from 127.0.0.1 with the cookie
ciroprogamer Oct 15, 2026
2d0053c
fix(android): app proof and secret each go on a new socket, never pooled
ciroprogamer Oct 15, 2026
86724c2
fix(android): Play FGS declaration covers downloads and swipe re-post
ciroprogamer Oct 15, 2026
bfaffb3
fix(android): correct stale EngineService comments
ciroprogamer Oct 15, 2026
0af73e3
fix(android): release check no longer ends on a 401 from /api/v1/engines
ciroprogamer Oct 15, 2026
53156fe
fix(litert-lm-server): bad tool call use-after-free; 16 KiB header cap
ciroprogamer Oct 15, 2026
a01449e
fix(android): document that backups carry plaintext third-party tokens
ciroprogamer Oct 15, 2026
1793522
fix(android): app proof and secret never go through a system proxy
ciroprogamer Oct 16, 2026
485b85f
fix(android): another app's proof answer can't stall or crash the app
ciroprogamer Oct 16, 2026
5ddf02f
fix(android): say the engine log line comes only on a clean first launch
ciroprogamer Oct 16, 2026
729228b
fix(litert-lm-server): error replies no longer race the decode thread
ciroprogamer Oct 16, 2026
4f0185c
fix(litert-lm-server): stalled stream reader no longer hangs the engine
ciroprogamer Oct 16, 2026
49d0b9e
fix(litert-lm-server): context-window cut-off reports length, not stop
ciroprogamer Oct 16, 2026
cffc1bb
fix(litert-lm-server): disconnect cancel is retried until the turn ends
ciroprogamer Oct 16, 2026
bd61188
fix(android): a notification dropped before a grant is posted again
ciroprogamer Oct 16, 2026
3f736e1
fix(android): mailto, xmpp and irc links no longer replace the UI
ciroprogamer Oct 16, 2026
670e391
fix(android): WebView remote debugging only in debuggable builds
ciroprogamer Oct 16, 2026
490a49c
fix(android): links to other apps carry CATEGORY_BROWSABLE
ciroprogamer Oct 17, 2026
4327a31
fix(android): https links to the daemon's port load over http in app
ciroprogamer Oct 17, 2026
c43cce0
docs: privacy notes list the WebView data and prefs that are backed up
ciroprogamer Oct 17, 2026
1a881f2
fix(litert-lm-server): streamed parallel tool calls get their own index
ciroprogamer Oct 17, 2026
7dc2f53
fix(litert-lm-server): web pages can no longer drive the engine port
ciroprogamer Oct 17, 2026
9c85d5b
fix(litert-lm-server): deeply nested JSON no longer kills the engine
ciroprogamer Oct 17, 2026
bafbc23
fix(litert-lm-server): running out of threads no longer kills the engine
ciroprogamer Oct 17, 2026
5780389
fix(litert-lm-server): no "usage": null on chunks before the usage one
ciroprogamer Oct 17, 2026
6963970
fix(android): keep the app's external files folder out of backups
ciroprogamer Oct 17, 2026
d3676dc
fix(litert-lm-server): parsing many objects in a body is linear again
ciroprogamer Oct 17, 2026
c84446e
fix(android): FGS declaration says why downloads keep specialUse
ciroprogamer Oct 18, 2026
eafb5d9
docs: a restore at install also leaves out the engine log line
ciroprogamer Oct 18, 2026
d5b676b
docs: name the staged daemon/package.json and its version field
ciroprogamer Oct 18, 2026
1581ad8
docs: privacy notes list the app's real permissions and engine source
ciroprogamer Oct 18, 2026
5966cb0
docs: privacy notes say when Auto Backup runs while the service is up
ciroprogamer Oct 18, 2026
7ee05e6
docs: privacy notes say where model folders can be outside the app
ciroprogamer Oct 18, 2026
754b6c0
fix(build): shell scripts check out with LF on Windows too
ciroprogamer Oct 18, 2026
7fd354e
docs: Play listing says Android's backup can copy chats off the phone
ciroprogamer Oct 18, 2026
c1a5db5
docs: Play listing and privacy notes name the app's network requests
ciroprogamer Oct 18, 2026
b44e008
fix(android): the app proof caps the answer's headers, not its body only
ciroprogamer Oct 18, 2026
865e5b3
fix(litert-lm-server): "stream": null is false, not a 500
ciroprogamer Oct 18, 2026
609a1c4
fix(android): a quick reopen after Back no longer unloads the model
ciroprogamer Oct 18, 2026
5e0e523
fix(android): drop the unused battery-optimization exemption permission
ciroprogamer Oct 18, 2026
9ba4791
docs: FGS video script loads a model the default engine runs
ciroprogamer Oct 18, 2026
b74b5bd
fix(android): the import sweep leaves a daemon download's .part alone
ciroprogamer Oct 19, 2026
6155a58
docs: privacy notes name the Kotlin code that talks to the daemon
ciroprogamer Oct 19, 2026
e6099a0
docs: FGS video script selects LiteRT-LM by the name Engines shows
ciroprogamer Oct 19, 2026
f435594
fix(android): the download notification shows its progress and speed
ciroprogamer Oct 21, 2026
2dfeec5
fix(litert-lm-server): liblitert-lm.so loads without libandroid.so
ciroprogamer Oct 21, 2026
e0b669c
fix(android): leaving the app no longer unloads the model
ciroprogamer Oct 21, 2026
93bf4a2
fix(android): Quit writes the chat database to disk before the app exits
ciroprogamer Oct 21, 2026
73bbbd0
fix(android): the WebView is destroyed when its screen goes away
ciroprogamer Oct 21, 2026
501f0ac
fix(android): a crashed WebView renderer no longer closes the app
ciroprogamer Oct 21, 2026
2d5f154
fix(android): the notification says what is running
ciroprogamer Oct 21, 2026
69264f8
fix(android): closing the app with nothing running quits it fully
ciroprogamer Oct 22, 2026
9f48cea
fix(android): the notification says Idle when nothing runs
ciroprogamer Oct 22, 2026
d82ca36
fix(android): an engine dies with the app process, by the kernel
ciroprogamer Oct 22, 2026
db0375c
fix(litert-lm-server): the server ends when the app does, on its own
ciroprogamer Oct 22, 2026
ac46561
fix(android): the WebView lays the UI out at its real height
ciroprogamer Oct 24, 2026
31f6a33
fix(android): an APK never carries a web UI older than its checkout
ciroprogamer Oct 24, 2026
26a42e6
fix(litert-lm-server): chats with LFM2 and gemma-3-270m no longer fail
ciroprogamer Oct 24, 2026
294c6c9
fix(litert-lm-server): LFM2 answers each chat from a fresh state
ciroprogamer Oct 24, 2026
243572a
fix(litert-lm-server): models that take images or audio get encoders
ciroprogamer Oct 24, 2026
54dfd59
fix(litert-lm-server): sampling starts from the model's, with a seed
ciroprogamer Oct 24, 2026
2e05b86
fix(litert-lm-server): top_k 0 is no limit; bad sampler values are 400s
ciroprogamer Oct 24, 2026
61b6cdd
fix(litert-lm-server): thinking follows what the request asks
ciroprogamer Oct 24, 2026
0b13b77
fix(litert-lm-server): a request's stop strings end the reply
ciroprogamer Oct 24, 2026
b9be217
fix(litert-lm-server): repetition penalties reach the model
ciroprogamer Oct 24, 2026
65a23f8
fix(litert-lm-server): tool_choice "none" withholds the tools
ciroprogamer Oct 24, 2026
09e8d4c
fix(litert-lm-server): a context larger than the model takes is said
ciroprogamer Oct 24, 2026
93b3f70
fix(litert-lm-server): with no backend set, the model's own is used
ciroprogamer Oct 24, 2026
6883593
fix(litert-lm-server): a developer message is a system message
ciroprogamer Oct 24, 2026
30cd219
fix(litert-lm-server): max_tokens is read, and a cap below 1 is a 400
ciroprogamer Oct 24, 2026
4f2132d
fix(litert-lm-server): n, logprobs and top_logprobs are refused
ciroprogamer Oct 24, 2026
424a925
fix(litert-lm-server): a reply the server cannot continue is a 400
ciroprogamer Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Files a Unix shell runs keep LF line endings on every checkout. Git for Windows checks text out
# with CRLF by default, and the WSL engine build runs scripts/*.sh straight from that checkout
# (BUILDING-ENGINES.md: bash "/mnt/d/.../scripts/build-static-engine.sh"), where bash stops at the
# first \r ("set: pipefail: invalid option name"). gradlew is the same for WSL, Linux and macOS.
*.sh text eol=lf
gradlew text eol=lf
10 changes: 6 additions & 4 deletions .github/workflows/pr-alpha.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,15 @@ jobs:
- name: Fetch libnode (nodejs-mobile ${{ env.NODEJS_MOBILE_VERSION }}, digidem 16 KB build)
run: node scripts/fetch-libnode.mjs

# Daemon + web UI. stage-daemon-assets.mjs rebuilds the Android daemon bundle itself but
# deliberately not the web UI, so that is built here first (into turbollm/src/webdist).
- name: Build the TurboLLM web UI and install daemon deps
# Daemon + web UI packages. stage-daemon-assets.mjs builds both itself: the Android daemon
# bundle every time, and the web UI (into turbollm/src/webdist) whenever web/ is not what it
# was last built from there, which on this fresh checkout is always. A web build here would
# leave no record of its sources, so staging would build it a second time.
- name: Install the TurboLLM daemon and web UI packages
working-directory: turbollm-src/turbollm
run: |
npm ci
(cd web && npm ci && npm run build)
(cd web && npm ci)

- name: Materialise signing + Play credentials
env:
Expand Down
420 changes: 397 additions & 23 deletions BUILDING-ENGINES.md

Large diffs are not rendered by default.

93 changes: 73 additions & 20 deletions PLAY-FGS-DECLARATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,36 @@ No standard type describes this work:
- `shortService` is limited to about 3 minutes.
- A WorkManager long-running worker still runs as a typed foreground service.

The same service also keeps a model import or an in-app model download going until it is done.
Taken alone, those transfers are `dataSync` work: Android's list for that type includes upload or
download and import or export. They are not why the service runs, though. It starts when the app
is opened, not for them, so it is already up for the inference server by the time the user starts
an import or a download from the app's own screens, and they only bring in the models that server
loads. Declaring the service `dataSync` instead would limit the inference server to 6 hours in 24
while the app is in the background (this app targets Android 15+). The declaration below says so
to Play. If Play asks for the transfers to be declared on their own: it allows more than one type
per service, but adding `dataSync` is a code change (`EngineService`'s `startForeground` type and
the manifest) that this page does not make.

The manifest's `PROPERTY_SPECIAL_USE_FGS_SUBTYPE` already states the use. The text below expands
it for the form.

## Declaration text

The answers describe what the code does (`EngineService.kt`). The service starts when the app is opened and stays
in the foreground, also while idle, until the user quits, so the text says so rather than suggesting it runs only
during generation.
in the foreground, also while idle, until the user quits or closes the app with nothing running, so the text says so
rather than suggesting it runs only during generation.

LAN access, remote access and Turbo Link are read from the daemon the app bundles: the `serving` field of its GET
/api/v1/status, from TurboLLM's feat(api): /api/v1/status says which server features are on. A daemon without that
field reports them all off (the app reads a missing or malformed `serving` as nothing served), so a close with only
one of them on would quit: the text below holds only for an APK built with a TurboLLM that has it.

A close made while the app is still starting, before its daemon answers (a swipe from Recents during the splash
screen, and before Android 12 Back there too), waits for the daemon, for up to 30 seconds, and is then decided as the
text says. So the text's daemon that gives "no answer" is one that has not answered by then, or one that stops
answering once it is up. Such a close with nothing running ends the app about 3 seconds after the close, or, when the
daemon takes longer to answer, right after it does.

**Which feature uses the foreground service?**

Expand All @@ -38,44 +60,75 @@ during generation.
> The service keeps that local server and the loaded model available while the user is in another app. Without it,
> Android freezes the app as soon as it leaves the screen: a reply being written stops mid-sentence and is lost, and
> the model is reclaimed, so the next message waits for it to load again. Replies on a phone take from seconds to
> minutes, and users switch apps while they wait. The service also keeps a model import (a multi-GB file copy) from
> being frozen halfway.
> minutes, and users switch apps while they wait. The service also keeps a model import (a multi-GB file copy) or an
> in-app model download from being frozen halfway.

**What does the user see, and how do they stop it?**

> From the moment the app is opened, a low-importance ongoing notification titled "TurboLLM", with a **Quit** action
> that stops the service, unloads the model and closes the app. Its text says what is happening: "Running in the
> background" while idle, "Generating" while a reply is being written, "Importing models" during an import, and,
> when the phone is thermally throttled, "Running. The phone is hot, so generation will be slower" or "Generating.
> The phone is hot, so it is throttling speed". If the user has turned notifications off, so Quit cannot be reached,
> leaving the app (Back, or swiping it away from Recents) unloads the model and stops the service as soon as nothing
> is generating or importing. A partial wake lock is held only while a reply is being generated or a model imported,
> never while idle.
> that stops the service, unloads the model and closes the app, about 3 seconds after the tap. On Android 14 and
> later, where the user can swipe this notification away, it is posted again at once, so Quit stays reachable. Its
> text says what is happening: "Idle" while no work is in progress, or "Idle · " and the model's name while a model is
> loaded (the chat model's name, or a text classification model's while no chat model is loaded; an embedding model
> loaded alone is not named), "Generating" while a reply is being written, "Loading a model", "Auto-tuning" or
> "Writing a skill" while the app does one of those, "Waiting for your approval" while a reply waits for the user to
> allow a tool call, "Importing models" during an import, the progress while a model download is running or queued
> (the file's name as the title, or "Downloading models" for several, how much has arrived and how fast, and a
> progress bar), and, when the phone is thermally throttled, "Idle. The phone is hot, so generation will be slower" or
> what is running followed by "The phone is hot, so it is throttling speed" (for example "Generating. The phone is
> hot, so it is throttling speed"). Leaving the app never unloads the model by itself: the user does, in the app or
> with Quit, or the app's idle timer does once the model has gone unused for a while (60 minutes by default; the user
> can change it or turn it off in Settings). Closing the app (Back from its main screen, or swiping it away from
> Recents; pressing Home does not close it) with nothing running works as Quit does, whether notifications are on or
> off: about 3 seconds later the service stops and the app's process ends. Opening the app again within those 3
> seconds, after Quit or such a close, keeps it running, as does the app coming back on screen after Quit (for example
> the phone unlocked onto it). Nothing is running when no model is loaded or loading (any model, an embedding model
> included), nothing is being generated, imported or downloaded (a paused or failed download does not count), no reply
> waits for the user's approval, no routine is running, and no access from other devices is on: LAN access, remote
> access (a tunnel, Tailscale Serve or Funnel), or Turbo Link letting other devices use this phone's models; when the
> app gets no answer from the daemon, or one it cannot read, it counts as running. Closed with something running, the
> app keeps the service, also after that work has ended, after the idle timer has unloaded the model and after that
> access has been turned off, until the user taps Quit or closes the app again with nothing running (after opening it,
> or by swiping away the card it left in Recents). A routine only scheduled for later does not count as running: such
> a close stops the routine scheduler with the app, until the app is opened again. If the user has turned
> notifications off (or the notification's channel), there is no Quit to tap: on Android 13 and later the system's
> list of active apps can still stop the app, and closing it again with nothing running quits. A partial wake lock is
> held only while a reply is being generated, a model being loaded, auto-tuned, imported or downloaded, or a skill
> written, never while idle or while a reply waits for the user's approval.

**Why does no other foreground service type fit?**

> The work is a local, user-started AI inference server. It is not data sync, media playback or processing,
> location, a phone call, health or connected-device work, and it lasts as long as the user keeps the app in use,
> longer than shortService allows. The other candidate types are capped at 6 hours on Android 15 and describe
> different work.
> The work is a local, user-started AI inference server, kept running for as long as the user keeps the app in use.
> It is not media playback or processing, location, a phone call, health or connected-device work, and it lasts
> longer than shortService allows. The model imports and downloads the service also keeps going are data transfers,
> but they are not why it runs: it starts when the app is opened, not for them, they run inside it while it is
> already up for the inference server, and they only bring in the models that server loads. dataSync fits those
> transfers alone, and like mediaProcessing it is capped at 6 hours in 24 on Android 15, while the inference server
> runs for as long as the app is in use.

## Video script (about 45 seconds, screen recording on a phone)

Record with the system screen recorder, with touches shown (Developer options → Show taps). Use
a small model, so the first load is quick and the reply visibly continues.

1. **0:00** Open TurboLLM. On the Models screen, load a small model, for example a
`.litertlm` such as LFM2.5-230M.
1. **0:00** Open TurboLLM. On the Models screen, load a small GGUF model, for example
Qwen2.5-0.5B-Instruct (Q4_K_M). It runs on llama.cpp, the engine the app selects until you
pick another. A `.litertlm` model such as LFM2.5-230M runs only on LiteRT-LM, which the app
never selects by itself: under llama.cpp the Models screen hides it and a load started
anyway fails. To record with one, select LiteRT-LM in Engines before you start recording
(tap Use on its card under "Built into the app", or pick "LiteRT-LM" in the engine selector
at the top of that screen).
2. **0:08** In Workspace, ask for something long: *"Write a 500-word story about a lighthouse
keeper."* Let a line or two of the reply appear.
3. **0:15** Pull down the notification shade. Show the TurboLLM notification reading
"Generating", with the Quit action.
4. **0:20** Press Home and open another app (for example Settings) for about 10 seconds.
5. **0:30** Return to TurboLLM. The reply has kept growing while the app was in the background,
and is complete or nearly so.
6. **0:38** Pull down the shade again. The notification now reads "Running in the background":
the model stays loaded, ready for the next message, which is what the service is for.
Tap **Quit**. The notification disappears and the app closes, freeing the model.
6. **0:38** Pull down the shade again. The notification now reads "Idle · " followed by the
model's name: the reply is done, and the model stays loaded, ready for the next message,
which is what the service is for. Tap **Quit**. About 3 seconds later the notification
disappears and the app closes, freeing the model.

Upload the video unlisted (for example on YouTube) and paste the link into the declaration.

Expand Down
12 changes: 12 additions & 0 deletions PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ Not vendored: `scripts/stage-litert-lm-engine.mjs` fetches it at build time from
The extracted arm64 `liblitert-lm.so` (39.9 MB, sha256 `3d591b09…dfd9`) is byte-identical to the
one the Termux path installs. License: Apache-2.0 (google-ai-edge/LiteRT-LM).

The APK carries it with one change. The engine process cannot load `libandroid.so`, so the script
renames that `DT_NEEDED` entry in place to `liblrtandr.so` (7 bytes of `.dynstr`; nothing else
differs, the `readelf -d -V --dyn-syms` output only in that line) and ships `liblrtandr.so`, a shim
it generates from the library's imports and builds with the NDK: each of its functions aborts with
a message if called (BUILDING-ENGINES.md, "libandroid.so and its shim"). The patched file is
pinned as well, and the build refuses any other result:

| ABI | SHA-256 (`liblitert-lm.so` from the wheel) | SHA-256 (as shipped) |
|---|---|---|
| arm64-v8a | `3d591b09116e7be3ab6b2b5b1a27bb3079dda0eb66550271533e6aee19e2dfd9` | `ae7e50538d9298e113686bc2ac07ebf33aac1325e6e3cbff299a1024a1b16d6a` |
| x86_64 | `cc8fdef43d1900234d3bebecd29b3610a7b09c62e0a3b3cdb08499e4b8e8e4c7` | `681f770323d7021ca47a0481ae49744699d80a62c79f759cd1ce443e21becb22` |

Vendored in `engines/litert-lm-server/third_party/`: LiteRT-LM's C API headers from tag
`v0.18.0` (`c/engine.h`, `c/conversation.h`, `c/error_reporter.h`, `c/api_export.h`;
Apache-2.0, LICENSE alongside) and nlohmann/json 3.11.3 (`json.hpp`, sha256 `9bea4c80…3ea6`; MIT,
Expand Down
22 changes: 14 additions & 8 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ The script refuses to overwrite an existing keystore for the same reason.
```

That single command also stages the Node daemon into the APK (`stageDaemonAssets`), which
rebuilds the daemon bundle from the `turbollm` checkout first. If turbollm is not a sibling
directory, pass `-Pturbollm.dir=<path>`.
rebuilds the daemon bundle from the `turbollm` checkout first, and its web UI too whenever
`turbollm/web` has changed since the web UI was last built there (BUILDING-ENGINES.md, "The
daemon payload"). If turbollm is not a sibling directory, pass `-Pturbollm.dir=<path>`.

Use `:composeApp:assembleRelease` instead if you want an installable `.apk` (for sideloading a
beta build); Play takes the `.aab`.
Expand Down Expand Up @@ -108,6 +109,11 @@ not change `minSdk`, which stays at 24 — only the Vulkan binary itself needs A
wsl -d Ubuntu-24.04 -u root -- bash "/mnt/d/llama-turbo/TurboLLM Android/scripts/build-static-engine.sh"
```

On a Windows checkout that had the scripts before `.gitattributes` pinned them to LF, this stops
at once with "set: pipefail: invalid option name" until they are written again; the one-time
commands for that are in
[BUILDING-ENGINES.md](BUILDING-ENGINES.md#the-build-script--scriptsbuild-static-enginesh).

That script verifies the result (AArch64, system-only `NEEDED`, embedded commit == `$SRC`
HEAD, size, sha256) and installs it into `composeApp/src/androidMain/jniLibs/arm64-v8a/`
itself. See [BUILDING-ENGINES.md](BUILDING-ENGINES.md#build-b--the-vulkan-gpu-engine).
Expand Down Expand Up @@ -180,12 +186,12 @@ still on Gradle 8.x. Revisit the version once the toolchain itself moves to Grad
`.github/workflows/pr-alpha.yml` builds a signed release bundle and runs `publishReleaseBundle`
against the **alpha** track on every PR open / push / reopen (markdown-only changes and fork PRs
are skipped; a newer push cancels an in-flight build for the same PR). It fetches the TurboLLM
daemon from `mohitsoni48/TurboLLM@main`, builds its web UI, fetches `libnode.so` with
`scripts/fetch-libnode.mjs` (the digidem 16 KB-aligned nodejs-mobile build, verified against the
SHA-256s in the script and PROVENANCE.md), stages the LiteRT-LM engine from PyPI, and uses the
tracked CPU llama.cpp engine in `jniLibs/`. The Vulkan engine is not built in CI. Because the daemon
comes from TurboLLM `main`, a PR here that needs daemon changes builds a working alpha only once
those daemon changes are merged.
daemon from `mohitsoni48/TurboLLM@main` and installs its packages (staging builds its web UI),
fetches `libnode.so` with `scripts/fetch-libnode.mjs` (the digidem 16 KB-aligned nodejs-mobile build,
verified against the SHA-256s in the script and PROVENANCE.md), stages the LiteRT-LM engine from
PyPI, and uses the tracked CPU llama.cpp engine in `jniLibs/`. The Vulkan engine is not built in CI.
Because the daemon comes from TurboLLM `main`, a PR here that needs daemon changes builds a working
alpha only once those daemon changes are merged.

Secrets live in the **`play-alpha` environment** (Settings → Environments), not as repo secrets. That environment requires the owner's approval for every run, `main` requires an owner-approved PR (`.github/CODEOWNERS`), and fork PRs never get secrets. Already created:

Expand Down
Loading