Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
228 changes: 228 additions & 0 deletions .github/workflows/clu-android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
name: CLU Android IDE

# Runs on changes to the CLU Android sub-project.
# Modelled after examples/demo-compose-app.yml — independent Gradle project,
# separate working-directory, same JDK/Gradle toolchain versions.

on:
push:
branches: ["main", "develop"]
paths:
- 'clu-android/**'
- '.github/workflows/clu-android.yml'
pull_request:
paths:
- 'clu-android/**'
- '.github/workflows/clu-android.yml'
workflow_dispatch:

# Cancel superseded runs on the same branch/PR, but never cancel main or develop.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}

env:
JAVA_VERSION: '17'
JAVA_DISTRIBUTION: 'corretto'
# Must match distributionUrl in clu-android/gradle/wrapper/gradle-wrapper.properties
GRADLE_VERSION: '9.2.1'
# Gradle JVM memory for the Android build daemon
GRADLE_OPTS: '-Xmx4g -Dfile.encoding=UTF-8'

# ─────────────────────────────────────────────────────────────────────────────
jobs:

# ── 1. Lint ──────────────────────────────────────────────────────────────
lint:
name: Lint
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Configure Git line endings
run: git config --global core.autocrlf input

- uses: actions/checkout@v6

- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v5
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
gradle-home-cache-includes: |
caches
notifications

- name: Accept Android SDK licenses
run: |
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \
> /dev/null 2>&1 || true

- name: Install Android compileSdk platform (API 36)
run: |
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-36" \
"build-tools;36.0.0" \
> /dev/null 2>&1 || true

- name: Lint
working-directory: ./clu-android
run: ./gradlew :app:lintDebug --stacktrace

- name: Upload lint report
if: always()
uses: actions/upload-artifact@v7
with:
name: lint-report
path: clu-android/app/build/reports/lint-results-debug.html
retention-days: 7

# ── 2. Unit tests (JVM — no emulator required) ───────────────────────────
unit-tests:
name: Unit Tests (JVM)
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Configure Git line endings
run: git config --global core.autocrlf input

- uses: actions/checkout@v6

- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v5
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
gradle-home-cache-includes: |
caches
notifications

- name: Accept Android SDK licenses
run: |
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \
> /dev/null 2>&1 || true

- name: Install Android compileSdk platform (API 36)
run: |
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-36" \
"build-tools;36.0.0" \
> /dev/null 2>&1 || true

- name: Run unit tests
working-directory: ./clu-android
# --continue: collect all test failures before stopping
run: ./gradlew :app:testDebugUnitTest --continue --stacktrace

- name: Publish test report
uses: mikepenz/action-junit-report@v6
if: ${{ !cancelled() }}
with:
report_paths: 'clu-android/app/build/test-results/**/TEST-*.xml'
detailed_summary: true
flaky_summary: true
include_empty_in_summary: false
annotate_only: true

- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: unit-test-results
path: clu-android/app/build/reports/tests/testDebugUnitTest/
retention-days: 7

# ── 3. Assemble debug APK (gated on unit tests passing) ──────────────────
assemble-debug:
name: Assemble Debug APK
runs-on: ubuntu-latest
needs: unit-tests
permissions:
contents: read

steps:
- name: Configure Git line endings
run: git config --global core.autocrlf input

- uses: actions/checkout@v6

- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v5
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }}

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
gradle-home-cache-includes: |
caches
notifications

- name: Accept Android SDK licenses
run: |
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \
> /dev/null 2>&1 || true

- name: Install Android compileSdk platform (API 36)
run: |
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-36" \
"build-tools;36.0.0" \
> /dev/null 2>&1 || true

- name: Assemble debug APK
working-directory: ./clu-android
run: ./gradlew :app:assembleDebug --stacktrace

- name: Upload debug APK
uses: actions/upload-artifact@v7
with:
name: clu-debug-${{ github.sha }}
path: clu-android/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
retention-days: 14

- name: Upload build reports on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: build-failure-reports
path: clu-android/app/build/reports/
retention-days: 3

# ── 4. Summary gate — branch protection should require this job ──────────
# Matrix jobs and skipped jobs don't report status correctly for required
# checks. This job always runs and reflects the real result.
build-result:
name: Build Result
needs: [lint, unit-tests, assemble-debug]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check all jobs
run: |
if [[ "${{ needs.lint.result }}" == "failure" ]]; then
echo "Lint failed"
exit 1
fi
if [[ "${{ needs.unit-tests.result }}" == "failure" ]]; then
echo "Unit tests failed"
exit 1
fi
if [[ "${{ needs.assemble-debug.result }}" == "failure" ]]; then
echo "APK assembly failed"
exit 1
fi
echo "All checks passed"
54 changes: 42 additions & 12 deletions clu-android/app/src/main/kotlin/ai/clu/CluApplication.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package ai.clu

import android.app.Application
import android.content.ComponentCallbacks2
import android.util.Log
import ai.clu.build.BuildGate
import ai.clu.build.RamDisk
import ai.clu.memory.ObjectBoxStore
import ai.clu.shell.LogcatReader
import ai.clu.shell.ShellSessionManager
Expand All @@ -14,38 +17,65 @@ import kotlinx.coroutines.launch
class CluApplication : Application() {

/**
* App-wide [CoroutineExceptionHandler] that intercepts any unhandled exception
* from agentic loops, build runners, or background indexing tasks.
* Exceptions are logged and piped to the logcat ring buffer — never crash the JVM.
* App-wide [CoroutineExceptionHandler] that intercepts any unhandled exception from
* agentic loops, build runners, or background indexing tasks. Exceptions are logged and
* captured by the logcat ring buffer — the agent can query them via [ReadLogcatTool].
* The handler is installed on the supervisor scope so a single failure never kills the JVM.
*/
val globalExceptionHandler = CoroutineExceptionHandler { context, throwable ->
Log.e(TAG, "Unhandled coroutine exception in $context", throwable)
// Ring buffer already captures this via LogcatReader — agent can query it
val globalExceptionHandler = CoroutineExceptionHandler { ctx, throwable ->
Log.e(TAG, "Unhandled coroutine exception in $ctx", throwable)
}

/** Supervisor-scoped coroutine scope for app-lifetime background work. */
/** Supervisor-scoped scope for app-lifetime background work. */
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + globalExceptionHandler)

/** tmpfs RAM disk for on-device builds — mounted after the shell session is live. */
lateinit var ramDisk: RamDisk
private set

override fun onCreate() {
super.onCreate()
instance = this

// Phase 3: Initialize ObjectBox (on-device RAG vector database)
// Phase 3: Initialize ObjectBox (on-device RAG vector database).
runCatching { ObjectBoxStore.init(this) }.onFailure { e ->
Log.e(TAG, "ObjectBox init failed — RAG disabled", e)
}

// Phase 2: Bootstrap the persistent shell session manager
runCatching { ShellSessionManager.init(this) }.onFailure { e ->
// Phase 2 + 4: Construct the RamDisk first so ShellSessionManager can mount it
// inside the same coroutine that confirms the libsu session is live. This eliminates
// the race condition where mount() fires before the shell session is ready.
ramDisk = RamDisk(this) { cmd -> ShellSessionManager.getExecutor().execute(cmd) }
runCatching { ShellSessionManager.init(this, ramDisk) }.onFailure { e ->
Log.e(TAG, "ShellSessionManager init failed", e)
// Fallback: mount with no shell → JVM_CACHE_FALLBACK path
appScope.launch(Dispatchers.IO) { ramDisk.mount() }
}

// Phase 4: Start logcat ring buffer for agent self-diagnostics
appScope.launch { LogcatReader.instance.stream(appScope).collect { /* ring buffer fills automatically */ } }
// Phase 4: Start logcat ring buffer for agent self-diagnostics.
appScope.launch { LogcatReader.instance.stream(appScope).collect { /* ring buffer fills */ } }
}

/**
* Suspend on-device Gradle builds when the system signals critical memory pressure.
* [BuildGate] is re-enabled when the system reports pressure below RUNNING_LOW.
*/
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
when {
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
BuildGate.suspend()
Log.w(TAG, "Memory pressure level=$level — on-device builds suspended")
}
level < ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> {
BuildGate.resume()
}
}
}

override fun onTerminate() {
super.onTerminate()
appScope.launch(Dispatchers.IO) { runCatching { ramDisk.unmount() } }
runCatching { ObjectBoxStore.close() }
runCatching { ShellSessionManager.getExecutor().close() }
LogcatReader.instance.stop()
Expand Down
Loading
Loading