Implement client ConnectionState, satirical idle sayings, response cycling, and complete MVVM/Compose multiplatform scaffolding

This commit is contained in:
2026-07-02 13:11:59 -06:00
parent c0e8d4fa0a
commit 0f834078d5
14 changed files with 924 additions and 110 deletions

View File

@@ -13,6 +13,7 @@ import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.serialization.json.*
import meatbag.common.*
import java.time.Duration
@@ -45,6 +46,7 @@ fun Application.module() {
val deviceRegistry = DeviceRegistry()
val routingEngine = RoutingEngine(deviceRegistry)
val profileStore = ProfileStore()
routing {
// Client WebSocket connection endpoint
@@ -88,10 +90,10 @@ fun Application.module() {
val matchedPendingRequest = routingEngine.handleResponse(response)
call.respond(
HttpStatusCode.OK,
mapOf(
"status" to "Response processed",
"matchedPendingRequest" to matchedPendingRequest
)
buildJsonObject {
put("status", "Response processed")
put("matchedPendingRequest", matchedPendingRequest)
}
)
}
@@ -116,16 +118,80 @@ fun Application.module() {
)
call.respond(HttpStatusCode.OK, approvalResult)
} catch (e: IllegalStateException) {
call.respond(HttpStatusCode.ServiceUnavailable, mapOf("error" to e.localizedMessage))
call.respond(HttpStatusCode.ServiceUnavailable, buildJsonObject { put("error", e.localizedMessage ?: "") })
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.localizedMessage))
call.respond(HttpStatusCode.InternalServerError, buildJsonObject { put("error", e.localizedMessage ?: "") })
}
}
// Diagnostic / status endpoint
get("/api/status") {
val devices = deviceRegistry.getAllDevices().map { mapOf("id" to it.id, "type" to it.type.name) }
call.respond(mapOf("status" to "OK", "connectedDevices" to devices))
val devices = deviceRegistry.getAllDevices()
call.respond(
buildJsonObject {
put("status", "OK")
put("connectedDevices", buildJsonArray {
devices.forEach { device ->
addJsonObject {
put("id", device.id)
put("type", device.type.name)
}
}
})
}
)
}
// Get all profiles
get("/api/profiles") {
val profiles = profileStore.listProfiles()
call.respond(HttpStatusCode.OK, profiles)
}
// Get profile by id
get("/api/profiles/{id}") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Missing profile ID")
return@get
}
val profile = profileStore.getProfile(id)
if (profile == null) {
call.respond(HttpStatusCode.NotFound, "Profile not found")
} else {
call.respond(HttpStatusCode.OK, profile)
}
}
// Create / Update profile
post("/api/profiles") {
val profile = try {
call.receive<UserProfile>()
} catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, "Invalid UserProfile body: ${e.localizedMessage}")
return@post
}
val success = profileStore.saveProfile(profile)
if (success) {
call.respond(HttpStatusCode.Created, buildJsonObject { put("status", "Profile saved") })
} else {
call.respond(HttpStatusCode.InternalServerError, "Failed to save profile")
}
}
// Delete profile
delete("/api/profiles/{id}") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Missing profile ID")
return@delete
}
val deleted = profileStore.deleteProfile(id)
if (deleted) {
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "Profile deleted") })
} else {
call.respond(HttpStatusCode.NotFound, "Profile not found or delete failed")
}
}
}
}

View File

@@ -0,0 +1,58 @@
package meatbag.backend
import meatbag.common.UserProfile
import meatbag.common.toUserProfile
import java.io.File
class ProfileStore(private val baseDir: File = File("profiles")) {
init {
if (!baseDir.exists()) {
baseDir.mkdirs()
}
}
fun getProfile(id: String): UserProfile? {
val file = File(baseDir, "$id.json")
if (!file.exists()) return null
return try {
file.readText().toUserProfile()
} catch (e: Exception) {
println("Error reading profile $id: ${e.message}")
null
}
}
fun saveProfile(profile: UserProfile): Boolean {
return try {
val file = File(baseDir, "${profile.id}.json")
file.writeText(profile.toJson())
true
} catch (e: Exception) {
println("Error saving profile ${profile.id}: ${e.message}")
false
}
}
fun listProfiles(): List<UserProfile> {
val files = baseDir.listFiles { _, name -> name.endsWith(".json") } ?: return emptyList()
return files.mapNotNull { file ->
try {
file.readText().toUserProfile()
} catch (e: Exception) {
println("Error listing profile ${file.name}: ${e.message}")
null
}
}
}
fun deleteProfile(id: String): Boolean {
val file = File(baseDir, "$id.json")
if (!file.exists()) return false
return try {
file.delete()
} catch (e: Exception) {
println("Error deleting profile $id: ${e.message}")
false
}
}
}

View File

@@ -10,6 +10,7 @@ import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.testing.*
import io.ktor.websocket.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import meatbag.common.*
import org.junit.Test
@@ -104,67 +105,73 @@ class RoutingEngineTest {
}
}
val phonePayloadDeferred = async {
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
coroutineScope {
val phonePayloadDeferred = async {
var payload: WebSocketPayload? = null
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
payload = SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
}
payload!!
}
}
val desktopPayloadDeferred = async {
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
val desktopPayloadDeferred = async {
var payload: WebSocketPayload? = null
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
payload = SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
}
payload!!
}
}
var connected = false
var attempts = 0
while (!connected && attempts < 20) {
val response = httpClient.get("/api/status")
val body = response.bodyAsText()
if (body.contains("phone_123") && body.contains("desktop_123")) {
connected = true
break
var connected = false
var attempts = 0
while (!connected && attempts < 20) {
val response = httpClient.get("/api/status")
val body = response.bodyAsText()
if (body.contains("phone_123") && body.contains("desktop_123")) {
connected = true
break
}
attempts += 1
delay(25)
}
attempts += 1
delay(25)
}
assertTrue(connected, "Expected both devices to connect before routing")
assertTrue(connected, "Expected both devices to connect before routing")
val agentResponseDeferred = async {
httpClient.post("/api/request?attentionTier=OVERRIDE_SCREEN") {
contentType(ContentType.Application.Json)
setBody<UiPrimitive>(
ApprovalRequest(
id = "req_override",
prompt = "Approve remote screen takeover?",
target = "system-agent"
val agentResponseDeferred = async {
httpClient.post("/api/request?attentionTier=OVERRIDE_SCREEN") {
contentType(ContentType.Application.Json)
setBody<UiPrimitive>(
ApprovalRequest(
id = "req_override",
prompt = "Approve remote screen takeover?",
target = "system-agent"
)
)
)
}
}
val phonePayload = phonePayloadDeferred.await()
val desktopPayload = desktopPayloadDeferred.await()
assertEquals(AttentionTier.OVERRIDE_SCREEN, phonePayload.attentionTier)
assertEquals(AttentionTier.OVERRIDE_SCREEN, desktopPayload.attentionTier)
assertEquals("req_override", phonePayload.uiPrimitive.id)
assertEquals("req_override", desktopPayload.uiPrimitive.id)
val respondResponse = httpClient.post("/api/respond") {
contentType(ContentType.Application.Json)
setBody(ApprovalResponse(requestId = "req_override", approved = false))
}
assertEquals(HttpStatusCode.OK, respondResponse.status)
val agentResponse = agentResponseDeferred.await()
assertEquals(HttpStatusCode.OK, agentResponse.status)
val result = agentResponse.body<ApprovalResponse>()
assertEquals("req_override", result.requestId)
assertEquals(false, result.approved)
}
val phonePayload = phonePayloadDeferred.await()
val desktopPayload = desktopPayloadDeferred.await()
assertEquals(AttentionTier.OVERRIDE_SCREEN, phonePayload.attentionTier)
assertEquals(AttentionTier.OVERRIDE_SCREEN, desktopPayload.attentionTier)
assertEquals("req_override", phonePayload.uiPrimitive.id)
assertEquals("req_override", desktopPayload.uiPrimitive.id)
val respondResponse = httpClient.post("/api/respond") {
contentType(ContentType.Application.Json)
setBody(ApprovalResponse(requestId = "req_override", approved = false))
}
assertEquals(HttpStatusCode.OK, respondResponse.status)
val agentResponse = agentResponseDeferred.await()
assertEquals(HttpStatusCode.OK, agentResponse.status)
val result = agentResponse.body<ApprovalResponse>()
assertEquals("req_override", result.requestId)
assertEquals(false, result.approved)
}
}