Initialize Meatbag SDUI platform

This commit is contained in:
2026-06-30 15:12:58 -06:00
commit 8b4cb8eace
28 changed files with 3058 additions and 0 deletions

29
backend/build.gradle.kts Normal file
View File

@@ -0,0 +1,29 @@
plugins {
kotlin("jvm")
id("io.ktor.plugin")
}
group = "meatbag"
version = "1.0-SNAPSHOT"
application {
mainClass.set("meatbag.backend.ApplicationKt")
}
dependencies {
implementation(project(":common"))
implementation("io.ktor:ktor-server-core-jvm:2.3.11")
implementation("io.ktor:ktor-server-netty-jvm:2.3.11")
implementation("io.ktor:ktor-server-websockets-jvm:2.3.11")
implementation("io.ktor:ktor-server-content-negotiation-jvm:2.3.11")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:2.3.11")
implementation("io.ktor:ktor-server-cors-jvm:2.3.11")
implementation("io.ktor:ktor-server-call-logging-jvm:2.3.11")
implementation("ch.qos.logback:logback-classic:1.4.14")
// Test dependencies including Ktor client plugins for testApplication
testImplementation("io.ktor:ktor-server-tests-jvm:2.3.11")
testImplementation("io.ktor:ktor-client-content-negotiation-jvm:2.3.11")
testImplementation("io.ktor:ktor-client-websockets-jvm:2.3.11")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.23")
}

View File

@@ -0,0 +1,145 @@
package meatbag.backend
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import meatbag.common.*
import java.time.Duration
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module).start(wait = true)
}
fun Application.module() {
install(CallLogging)
install(CORS) {
anyHost()
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Get)
}
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(15)
maxFrameSize = Long.MAX_VALUE
masking = false
}
install(ContentNegotiation) {
json(SDUIJson.json)
}
val deviceRegistry = DeviceRegistry()
val routingEngine = RoutingEngine(deviceRegistry)
routing {
// Client WebSocket connection endpoint
webSocket("/ws/connect") {
val deviceId = call.parameters["deviceId"] ?: java.util.UUID.randomUUID().toString()
val deviceTypeStr = call.parameters["deviceType"] ?: "DESKTOP"
val deviceType = try {
DeviceType.valueOf(deviceTypeStr.uppercase())
} catch (e: IllegalArgumentException) {
DeviceType.DESKTOP
}
val device = ActiveDevice(deviceId, deviceType, this)
deviceRegistry.register(device)
println("[WebSocket] Device registered: ID=$deviceId, Type=$deviceType")
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
println("[WebSocket] Message from device $deviceId: $text")
}
}
} catch (e: Exception) {
println("[WebSocket] Connection error or close for device $deviceId: ${e.localizedMessage}")
} finally {
deviceRegistry.unregister(deviceId)
println("[WebSocket] Device unregistered: ID=$deviceId")
}
}
// Endpoint for receiving responses from clients
post("/api/respond") {
val response = try {
call.receive<ApprovalResponse>()
} catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, "Invalid Response body structure: ${e.localizedMessage}")
return@post
}
val matchedPendingRequest = routingEngine.handleResponse(response)
call.respond(
HttpStatusCode.OK,
mapOf(
"status" to "Response processed",
"matchedPendingRequest" to matchedPendingRequest
)
)
}
// Endpoint for receiving requests from agents
post("/api/request") {
val rawJson = call.receiveText()
val uiPrimitive = try {
SDUIJson.decodeFromString(rawJson)
} catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, "Invalid UI Primitive format: ${e.localizedMessage}")
return@post
}
try {
val approvalResult = routingEngine.routeRequest(
AgentRequestContext(
uiPrimitive = uiPrimitive,
urgent = call.request.queryParameters["urgent"]?.toBoolean() ?: false,
requestedAttention = parseAttentionTier(call.request.queryParameters["attentionTier"]),
preferredDeviceTypes = parseDeviceTypes(call.request.queryParameters["deviceTypes"])
)
)
call.respond(HttpStatusCode.OK, approvalResult)
} catch (e: IllegalStateException) {
call.respond(HttpStatusCode.ServiceUnavailable, mapOf("error" to e.localizedMessage))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to 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))
}
}
}
private fun parseAttentionTier(value: String?): AttentionTier {
return value
?.let { runCatching { AttentionTier.valueOf(it.uppercase()) }.getOrNull() }
?: AttentionTier.BACKGROUND_NOTIFICATION
}
private fun parseDeviceTypes(value: String?): List<DeviceType> {
return value
?.split(",")
?.mapNotNull { rawType -> runCatching { DeviceType.valueOf(rawType.trim().uppercase()) }.getOrNull() }
?.distinct()
?: emptyList()
}

View File

@@ -0,0 +1,31 @@
package meatbag.backend
import java.util.concurrent.ConcurrentHashMap
class DeviceRegistry {
private val devices = ConcurrentHashMap<String, ActiveDevice>()
fun register(device: ActiveDevice) {
devices[device.id] = device
}
fun unregister(deviceId: String) {
devices.remove(deviceId)
}
fun getDevice(deviceId: String): ActiveDevice? {
return devices[deviceId]
}
fun getAllDevices(): List<ActiveDevice> {
return devices.values.toList()
}
fun getDevicesByType(deviceTypes: Set<DeviceType>): List<ActiveDevice> {
if (deviceTypes.isEmpty()) return emptyList()
return devices.values
.filter { it.type in deviceTypes }
.sortedWith(compareBy<ActiveDevice> { it.type.ordinal }.thenBy { it.id })
}
}

View File

@@ -0,0 +1,53 @@
package meatbag.backend
import io.ktor.websocket.*
import meatbag.common.AttentionTier
import meatbag.common.UiPrimitive
enum class DeviceType {
WEAR_OS_WATCH,
PHONE,
DESKTOP
}
data class ActiveDevice(
val id: String,
val type: DeviceType,
val session: WebSocketSession
)
data class AgentRequestContext(
val uiPrimitive: UiPrimitive,
val requestedAttention: AttentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
val urgent: Boolean = false,
val preferredDeviceTypes: List<DeviceType> = emptyList()
)
data class UserRuleSet(
val id: String = "mock-default",
val criticalKeywords: Set<String> = setOf(
"rm -rf",
"delete",
"destroy",
"kill",
"urgent",
"critical",
"sudo",
"permission",
"allow"
),
val watchFirstForSilentHaptic: Boolean = true,
val fanOutOverrideScreen: Boolean = true
)
data class RoutingDecision(
val attentionTier: AttentionTier,
val deviceTypes: List<DeviceType>,
val fanOut: Boolean = false
)
data class DeliveryResult(
val requestId: String,
val attentionTier: AttentionTier,
val deviceIds: List<String>
)

View File

@@ -0,0 +1,235 @@
package meatbag.backend
import io.ktor.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.serialization.encodeToString
import meatbag.common.*
import java.util.concurrent.ConcurrentHashMap
interface AgentCallback {
suspend fun onHumanResponse(response: ApprovalResponse)
}
class RecordingAgentCallback : AgentCallback {
private val responses = ConcurrentHashMap<String, ApprovalResponse>()
override suspend fun onHumanResponse(response: ApprovalResponse) {
responses[response.requestId] = response
}
fun getResponse(requestId: String): ApprovalResponse? = responses[requestId]
}
class RoutingEngine(
private val deviceRegistry: DeviceRegistry,
private val userRuleSet: UserRuleSet = UserRuleSet(),
private val agentCallback: AgentCallback = RecordingAgentCallback()
) {
// Tracks pending requests: requestId -> CompletableDeferred<ApprovalResponse>
private val pendingRequests = ConcurrentHashMap<String, CompletableDeferred<ApprovalResponse>>()
suspend fun routeRequest(
uiPrimitive: UiPrimitive,
isUrgent: Boolean,
requestedAttention: AttentionTier
): ApprovalResponse {
return routeRequest(
AgentRequestContext(
uiPrimitive = uiPrimitive,
requestedAttention = requestedAttention,
urgent = isUrgent
)
)
}
suspend fun routeRequest(context: AgentRequestContext): ApprovalResponse {
val activeDevices = deviceRegistry.getAllDevices()
if (activeDevices.isEmpty()) {
throw IllegalStateException("No active client devices connected")
}
val decision = evaluateRules(context)
val targetDevices = selectDevices(decision, activeDevices)
val deferred = CompletableDeferred<ApprovalResponse>()
pendingRequests[context.uiPrimitive.id] = deferred
println(
"Routing request ${context.uiPrimitive.id} to devices " +
targetDevices.joinToString { "${it.id} (${it.type})" } +
" with tier ${decision.attentionTier}"
)
try {
targetDevices.forEach { device ->
device.session.send(Frame.Text(payloadJson(context, decision, device)))
}
return deferred.await()
} catch (e: Exception) {
pendingRequests.remove(context.uiPrimitive.id)
throw e
}
}
suspend fun deliverRequest(context: AgentRequestContext): DeliveryResult {
val activeDevices = deviceRegistry.getAllDevices()
if (activeDevices.isEmpty()) {
throw IllegalStateException("No active client devices connected")
}
val decision = evaluateRules(context)
val targetDevices = selectDevices(decision, activeDevices)
targetDevices.forEach { device ->
device.session.send(Frame.Text(payloadJson(context, decision, device)))
}
return DeliveryResult(
requestId = context.uiPrimitive.id,
attentionTier = decision.attentionTier,
deviceIds = targetDevices.map { it.id }
)
}
fun evaluateRules(context: AgentRequestContext): RoutingDecision {
if (context.preferredDeviceTypes.isNotEmpty()) {
return RoutingDecision(
attentionTier = context.requestedAttention,
deviceTypes = context.preferredDeviceTypes,
fanOut = context.preferredDeviceTypes.size > 1
)
}
val promptRequiresAttention = (context.uiPrimitive as? ApprovalRequest)
?.prompt
?.lowercase()
?.let { prompt -> userRuleSet.criticalKeywords.any(prompt::contains) }
?: false
val attentionTier = when {
context.requestedAttention == AttentionTier.OVERRIDE_SCREEN -> AttentionTier.OVERRIDE_SCREEN
context.urgent || promptRequiresAttention -> AttentionTier.SILENT_HAPTIC
else -> context.requestedAttention
}
val deviceTypes = when (attentionTier) {
AttentionTier.SILENT_HAPTIC -> {
if (userRuleSet.watchFirstForSilentHaptic) {
listOf(DeviceType.WEAR_OS_WATCH, DeviceType.PHONE, DeviceType.DESKTOP)
} else {
listOf(DeviceType.PHONE, DeviceType.WEAR_OS_WATCH, DeviceType.DESKTOP)
}
}
AttentionTier.OVERRIDE_SCREEN -> {
if (userRuleSet.fanOutOverrideScreen) {
listOf(DeviceType.PHONE, DeviceType.DESKTOP, DeviceType.WEAR_OS_WATCH)
} else {
listOf(DeviceType.PHONE, DeviceType.DESKTOP)
}
}
AttentionTier.BACKGROUND_NOTIFICATION -> listOf(DeviceType.PHONE, DeviceType.DESKTOP, DeviceType.WEAR_OS_WATCH)
}
return RoutingDecision(
attentionTier = attentionTier,
deviceTypes = deviceTypes,
fanOut = attentionTier == AttentionTier.OVERRIDE_SCREEN && userRuleSet.fanOutOverrideScreen
)
}
suspend fun handleResponse(response: ApprovalResponse): Boolean {
val deferred = pendingRequests.remove(response.requestId)
if (deferred != null) {
agentCallback.onHumanResponse(response)
deferred.complete(response)
println("Completed request ${response.requestId} with approved=${response.approved}")
return true
} else {
println("No pending request found for ID ${response.requestId}")
return false
}
}
private fun selectDevices(decision: RoutingDecision, activeDevices: List<ActiveDevice>): List<ActiveDevice> {
val orderedMatches = decision.deviceTypes.flatMap { desiredType ->
activeDevices.filter { it.type == desiredType }.sortedBy { it.id }
}
return when {
orderedMatches.isNotEmpty() && decision.fanOut -> orderedMatches
orderedMatches.isNotEmpty() -> listOf(orderedMatches.first())
else -> listOf(activeDevices.sortedWith(compareBy<ActiveDevice> { it.type.ordinal }.thenBy { it.id }).first())
}
}
private fun payloadJson(
context: AgentRequestContext,
decision: RoutingDecision,
device: ActiveDevice
): String {
return SDUIJson.json.encodeToString(
WebSocketPayload(
attentionTier = decision.attentionTier,
uiPrimitive = context.uiPrimitive,
request = RequestContext(
requestId = context.uiPrimitive.id,
source = RequestSource(
system = "agent",
reason = if (context.urgent) "urgent" else null
)
),
deviceContext = device.toDeviceContext(),
attentionPolicy = AttentionPolicy(
tier = decision.attentionTier,
hapticPattern = decision.attentionTier.toHapticPattern(),
presentation = decision.toPresentationMode()
)
)
)
}
private fun ActiveDevice.toDeviceContext(): DeviceContext {
return when (type) {
DeviceType.WEAR_OS_WATCH -> DeviceContext(
deviceId = id,
deviceClass = DeviceClass.WEARABLE,
platform = DevicePlatform.WEAR_OS,
capabilities = setOf(DeviceCapability.HAPTICS, DeviceCapability.NOTIFICATIONS)
)
DeviceType.PHONE -> DeviceContext(
deviceId = id,
deviceClass = DeviceClass.PHONE,
platform = DevicePlatform.ANDROID,
capabilities = setOf(
DeviceCapability.HAPTICS,
DeviceCapability.NOTIFICATIONS,
DeviceCapability.FULL_SCREEN_INTENT,
DeviceCapability.TEXT_INPUT
)
)
DeviceType.DESKTOP -> DeviceContext(
deviceId = id,
deviceClass = DeviceClass.DESKTOP,
platform = DevicePlatform.DESKTOP,
capabilities = setOf(DeviceCapability.NOTIFICATIONS, DeviceCapability.TEXT_INPUT)
)
}
}
private fun AttentionTier.toHapticPattern(): HapticPattern {
return when (this) {
AttentionTier.SILENT_HAPTIC -> HapticPattern.DOUBLE_SHORT
AttentionTier.BACKGROUND_NOTIFICATION -> HapticPattern.SHORT
AttentionTier.OVERRIDE_SCREEN -> HapticPattern.URGENT_LOOP
}
}
private fun RoutingDecision.toPresentationMode(): PresentationMode {
return when {
fanOut -> PresentationMode.ALL_DEVICES
deviceTypes.firstOrNull() == DeviceType.WEAR_OS_WATCH -> PresentationMode.WATCH_ONLY
deviceTypes.firstOrNull() == DeviceType.PHONE -> PresentationMode.PHONE_ONLY
else -> PresentationMode.AUTO
}
}
}

View File

@@ -0,0 +1,14 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>

View File

@@ -0,0 +1,170 @@
package meatbag.backend
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.websocket.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.testing.*
import io.ktor.websocket.*
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import meatbag.common.*
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class RoutingEngineTest {
@Test
fun testCompleteAgentRoutingFlow() = testApplication {
// Configure Ktor backend module
application {
module()
}
// Client for WebSocket connection
val wsClient = createClient {
install(WebSockets)
install(ContentNegotiation) {
json(SDUIJson.json)
}
}
// Client for HTTP API calls
val httpClient = createClient {
install(ContentNegotiation) {
json(SDUIJson.json)
}
}
// Run the client WebSocket session and agent HTTP request concurrently
wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123") {
val agentResponseDeferred = async {
val approvalReq = ApprovalRequest(
id = "req_999",
prompt = "Urgent: Allow system update?",
target = "system-agent"
)
// Post approval request to backend polymorphically
httpClient.post("/api/request") {
contentType(ContentType.Application.Json)
setBody<UiPrimitive>(approvalReq)
}
}
// Client receives the websocket notification frame
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
val textFrame = frame as Frame.Text
val payloadJson = textFrame.readText()
// Decode payload using common module's serializer
val payload = SDUIJson.json.decodeFromString<WebSocketPayload>(payloadJson)
// Due to "Urgent" in prompt, it should resolve to SILENT_HAPTIC and target watch_123
assertEquals(AttentionTier.SILENT_HAPTIC, payload.attentionTier)
assertEquals("req_999", payload.uiPrimitive.id)
assertTrue(payload.uiPrimitive is ApprovalRequest)
assertEquals("Urgent: Allow system update?", (payload.uiPrimitive as ApprovalRequest).prompt)
// Simulate user clicking Yes on their watch by sending response to /api/respond
val respondResponse = httpClient.post("/api/respond") {
contentType(ContentType.Application.Json)
setBody(ApprovalResponse(requestId = "req_999", approved = true))
}
assertEquals(HttpStatusCode.OK, respondResponse.status)
// Await agent response and verify the callback output
val agentResponse = agentResponseDeferred.await()
assertEquals(HttpStatusCode.OK, agentResponse.status)
val result = agentResponse.body<ApprovalResponse>()
assertEquals("req_999", result.requestId)
assertEquals(true, result.approved)
}
}
@Test
fun testOverrideScreenFansOutToPhoneAndDesktop() = testApplication {
application {
module()
}
val wsClient = createClient {
install(WebSockets)
install(ContentNegotiation) {
json(SDUIJson.json)
}
}
val httpClient = createClient {
install(ContentNegotiation) {
json(SDUIJson.json)
}
}
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())
}
}
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())
}
}
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)
}
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 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)
}
}