Implement client ConnectionState, satirical idle sayings, response cycling, and complete MVVM/Compose multiplatform scaffolding
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
backend/src/main/kotlin/meatbag/backend/ProfileStore.kt
Normal file
58
backend/src/main/kotlin/meatbag/backend/ProfileStore.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user