feat: add :backend:loader module with GatewayProcessManager for embedded flavor
Go binary (libclawdroid.so) process lifecycle manager that handles process start/stop, health check polling, log forwarding, API key generation, exponential backoff restart, and settings observation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d36b625125
commit
834714d700
5 changed files with 212 additions and 0 deletions
|
|
@ -80,6 +80,7 @@ dependencies {
|
||||||
implementation(project(":backend:config"))
|
implementation(project(":backend:config"))
|
||||||
|
|
||||||
"termuxImplementation"(project(":backend:loader-noop"))
|
"termuxImplementation"(project(":backend:loader-noop"))
|
||||||
|
"embeddedImplementation"(project(":backend:loader"))
|
||||||
|
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
|
|
|
||||||
18
android/backend/loader/build.gradle.kts
Normal file
18
android/backend/loader/build.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.clawdroid.backend.loader"
|
||||||
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
|
defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() }
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":backend:api"))
|
||||||
|
implementation(libs.coroutines.android)
|
||||||
|
}
|
||||||
15
android/backend/loader/src/main/AndroidManifest.xml
Normal file
15
android/backend/loader/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<service
|
||||||
|
android:name=".GatewayService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="Local AI agent backend process" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
package io.clawdroid.backend.loader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import io.clawdroid.backend.api.BackendState
|
||||||
|
import io.clawdroid.backend.api.GatewaySettingsStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class GatewayProcessManager(
|
||||||
|
private val context: Context,
|
||||||
|
private val settingsStore: GatewaySettingsStore,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val _state = MutableStateFlow(BackendState.STOPPED)
|
||||||
|
val state: StateFlow<BackendState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var process: Process? = null
|
||||||
|
private var logJob: Job? = null
|
||||||
|
private var watchdogJob: Job? = null
|
||||||
|
private var healthCheckJob: Job? = null
|
||||||
|
private var settingsObserverJob: Job? = null
|
||||||
|
|
||||||
|
private val binaryPath: String
|
||||||
|
get() = context.applicationInfo.nativeLibraryDir + "/libclawdroid.so"
|
||||||
|
|
||||||
|
suspend fun start() {
|
||||||
|
ensureApiKey()
|
||||||
|
startProcess()
|
||||||
|
startSettingsObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun stop() {
|
||||||
|
settingsObserverJob?.cancel()
|
||||||
|
settingsObserverJob = null
|
||||||
|
stopProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureApiKey() {
|
||||||
|
val settings = settingsStore.settings.value
|
||||||
|
if (settings.apiKey.isEmpty()) {
|
||||||
|
settingsStore.update(settings.copy(apiKey = UUID.randomUUID().toString()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startProcess() {
|
||||||
|
_state.value = BackendState.STARTING
|
||||||
|
|
||||||
|
val settings = settingsStore.settings.value
|
||||||
|
val env = mapOf(
|
||||||
|
"HOME" to context.filesDir.absolutePath,
|
||||||
|
"CLAWDROID_GATEWAY_API_KEY" to settings.apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
val pb = ProcessBuilder(binaryPath, "gateway", "run")
|
||||||
|
pb.environment().putAll(env)
|
||||||
|
pb.directory(context.filesDir)
|
||||||
|
pb.redirectErrorStream(true)
|
||||||
|
|
||||||
|
val proc = pb.start()
|
||||||
|
process = proc
|
||||||
|
Log.i(TAG, "Started gateway process")
|
||||||
|
|
||||||
|
logJob = scope.launch { forwardLogs(proc) }
|
||||||
|
watchdogJob = scope.launch { watchProcess(proc) }
|
||||||
|
healthCheckJob = scope.launch { pollHealth(settings.httpPort) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopProcess() {
|
||||||
|
healthCheckJob?.cancel()
|
||||||
|
healthCheckJob = null
|
||||||
|
watchdogJob?.cancel()
|
||||||
|
watchdogJob = null
|
||||||
|
logJob?.cancel()
|
||||||
|
logJob = null
|
||||||
|
|
||||||
|
process?.let { proc ->
|
||||||
|
proc.destroy()
|
||||||
|
proc.waitFor()
|
||||||
|
Log.i(TAG, "Stopped gateway process")
|
||||||
|
}
|
||||||
|
process = null
|
||||||
|
_state.value = BackendState.STOPPED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun forwardLogs(proc: Process) {
|
||||||
|
proc.inputStream.bufferedReader().use { reader: BufferedReader ->
|
||||||
|
reader.forEachLine { line ->
|
||||||
|
Log.i(TAG, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pollHealth(port: Int) {
|
||||||
|
val url = "http://127.0.0.1:$port/api/config"
|
||||||
|
while (scope.isActive) {
|
||||||
|
try {
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
conn.connectTimeout = 1000
|
||||||
|
conn.readTimeout = 1000
|
||||||
|
try {
|
||||||
|
if (conn.responseCode == 200) {
|
||||||
|
if (_state.value == BackendState.STARTING) {
|
||||||
|
_state.value = BackendState.RUNNING
|
||||||
|
Log.i(TAG, "Gateway health check passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Not ready yet
|
||||||
|
}
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun watchProcess(proc: Process) {
|
||||||
|
var backoffMs = 1000L
|
||||||
|
proc.waitFor()
|
||||||
|
val exitCode = proc.exitValue()
|
||||||
|
Log.w(TAG, "Gateway process exited: code=$exitCode")
|
||||||
|
|
||||||
|
if (_state.value == BackendState.STOPPED) return
|
||||||
|
|
||||||
|
_state.value = BackendState.ERROR
|
||||||
|
|
||||||
|
while (scope.isActive && _state.value == BackendState.ERROR) {
|
||||||
|
Log.i(TAG, "Restarting gateway process (backoff=${backoffMs}ms)")
|
||||||
|
delay(backoffMs)
|
||||||
|
backoffMs = (backoffMs * 2).coerceAtMost(30_000)
|
||||||
|
try {
|
||||||
|
stopProcess()
|
||||||
|
startProcess()
|
||||||
|
return
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Restart failed", e)
|
||||||
|
_state.value = BackendState.ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSettingsObserver() {
|
||||||
|
settingsObserverJob = scope.launch {
|
||||||
|
settingsStore.settings
|
||||||
|
.map { it.apiKey }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.drop(1)
|
||||||
|
.collect { apiKey ->
|
||||||
|
if (process != null && apiKey.isNotEmpty()) {
|
||||||
|
Log.i(TAG, "API key changed, restarting gateway")
|
||||||
|
stopProcess()
|
||||||
|
startProcess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "GatewayProcess"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,3 +21,4 @@ include(":core:ui")
|
||||||
include(":backend:api")
|
include(":backend:api")
|
||||||
include(":backend:config")
|
include(":backend:config")
|
||||||
include(":backend:loader-noop")
|
include(":backend:loader-noop")
|
||||||
|
include(":backend:loader")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue