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

22
common/build.gradle.kts Normal file
View File

@@ -0,0 +1,22 @@
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
}
kotlin {
jvm() // Targets the JVM. Additional targets (android, ios, etc.) can be configured later.
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
}
}

View File

@@ -0,0 +1,351 @@
package meatbag.common
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
sealed class UiPrimitive {
abstract val id: String
}
@Serializable
@SerialName("ApprovalRequest")
data class ApprovalRequest(
override val id: String,
val prompt: String,
val target: String,
val title: String = "Approval required",
val body: String? = null,
val approveAction: ActionItem = ActionItem(
id = "approve",
label = "Approve",
intent = ActionIntent.APPROVE,
),
val rejectAction: ActionItem = ActionItem(
id = "reject",
label = "Reject",
intent = ActionIntent.REJECT,
style = ActionStyle.DESTRUCTIVE,
),
val timeoutMs: Long? = null,
val callback: CallbackRequest? = null,
) : UiPrimitive()
@Serializable
@SerialName("ActionList")
data class ActionList(
override val id: String,
val title: String,
val actions: List<ActionItem>,
val subtitle: String? = null,
val selectionMode: SelectionMode = SelectionMode.SINGLE,
) : UiPrimitive()
@Serializable
@SerialName("TextInput")
data class TextInput(
override val id: String,
val prompt: String,
val placeholder: String,
val title: String = prompt,
val submitAction: ActionItem = ActionItem(
id = "submit",
label = "Submit",
intent = ActionIntent.SUBMIT,
),
val callback: CallbackRequest? = null,
) : UiPrimitive()
@Serializable
@SerialName("Message")
data class Message(
override val id: String,
val title: String,
val body: String,
val tone: MessageTone = MessageTone.INFO,
val primaryAction: ActionItem? = null,
) : UiPrimitive()
@Serializable
data class ActionItem(
val id: String,
val label: String,
val intent: ActionIntent = ActionIntent.CUSTOM,
val style: ActionStyle = ActionStyle.DEFAULT,
val callback: CallbackRequest? = null,
val enabled: Boolean = true,
)
@Serializable
enum class ActionIntent {
@SerialName("APPROVE")
APPROVE,
@SerialName("REJECT")
REJECT,
@SerialName("ACKNOWLEDGE")
ACKNOWLEDGE,
@SerialName("SUBMIT")
SUBMIT,
@SerialName("CUSTOM")
CUSTOM,
}
@Serializable
enum class ActionStyle {
@SerialName("DEFAULT")
DEFAULT,
@SerialName("PRIMARY")
PRIMARY,
@SerialName("DESTRUCTIVE")
DESTRUCTIVE,
}
@Serializable
enum class SelectionMode {
@SerialName("SINGLE")
SINGLE,
@SerialName("MULTIPLE")
MULTIPLE,
}
@Serializable
enum class MessageTone {
@SerialName("INFO")
INFO,
@SerialName("SUCCESS")
SUCCESS,
@SerialName("WARNING")
WARNING,
@SerialName("ERROR")
ERROR,
}
@Serializable
enum class AttentionTier {
@SerialName("SILENT_HAPTIC")
SILENT_HAPTIC,
@SerialName("BACKGROUND_NOTIFICATION")
BACKGROUND_NOTIFICATION,
@SerialName("OVERRIDE_SCREEN")
OVERRIDE_SCREEN,
}
@Serializable
data class AttentionPolicy(
val tier: AttentionTier,
val hapticPattern: HapticPattern? = null,
val presentation: PresentationMode = PresentationMode.AUTO,
val expiresAtEpochMs: Long? = null,
)
@Serializable
enum class HapticPattern {
@SerialName("NONE")
NONE,
@SerialName("SHORT")
SHORT,
@SerialName("DOUBLE_SHORT")
DOUBLE_SHORT,
@SerialName("URGENT_LOOP")
URGENT_LOOP,
}
@Serializable
enum class PresentationMode {
@SerialName("AUTO")
AUTO,
@SerialName("WATCH_ONLY")
WATCH_ONLY,
@SerialName("PHONE_ONLY")
PHONE_ONLY,
@SerialName("ALL_DEVICES")
ALL_DEVICES,
}
@Serializable
data class RequestContext(
val requestId: String,
val source: RequestSource,
val correlationId: String? = null,
val createdAtEpochMs: Long? = null,
val callback: CallbackRequest? = null,
)
@Serializable
data class RequestSource(
val system: String,
val actor: String? = null,
val reason: String? = null,
)
@Serializable
data class DeviceContext(
val deviceId: String,
val deviceClass: DeviceClass,
val platform: DevicePlatform,
val capabilities: Set<DeviceCapability> = emptySet(),
val locale: String? = null,
val timezone: String? = null,
)
@Serializable
enum class DeviceClass {
@SerialName("PHONE")
PHONE,
@SerialName("WEARABLE")
WEARABLE,
@SerialName("DESKTOP")
DESKTOP,
@SerialName("TABLET")
TABLET,
}
@Serializable
enum class DevicePlatform {
@SerialName("ANDROID")
ANDROID,
@SerialName("WEAR_OS")
WEAR_OS,
@SerialName("IOS")
IOS,
@SerialName("DESKTOP")
DESKTOP,
@SerialName("WEB")
WEB,
}
@Serializable
enum class DeviceCapability {
@SerialName("HAPTICS")
HAPTICS,
@SerialName("NOTIFICATIONS")
NOTIFICATIONS,
@SerialName("FULL_SCREEN_INTENT")
FULL_SCREEN_INTENT,
@SerialName("TEXT_INPUT")
TEXT_INPUT,
}
@Serializable
data class CallbackRequest(
val id: String,
val target: CallbackTarget,
val method: CallbackMethod = CallbackMethod.POST,
)
@Serializable
sealed class CallbackTarget {
@Serializable
@SerialName("Http")
data class Http(
val url: String,
val headers: Map<String, String> = emptyMap(),
) : CallbackTarget()
@Serializable
@SerialName("WebSocket")
data class WebSocket(
val channel: String,
) : CallbackTarget()
@Serializable
@SerialName("Local")
data class Local(
val handler: String,
) : CallbackTarget()
}
@Serializable
enum class CallbackMethod {
@SerialName("POST")
POST,
@SerialName("PUT")
PUT,
}
@Serializable
data class WebSocketPayload(
val attentionTier: AttentionTier,
val uiPrimitive: UiPrimitive,
val request: RequestContext? = null,
val deviceContext: DeviceContext? = null,
val attentionPolicy: AttentionPolicy = AttentionPolicy(attentionTier),
)
@Serializable
data class ApprovalResponse(
val requestId: String,
val approved: Boolean,
val actionId: String? = null,
val text: String? = null,
val callbackId: String? = null,
val deviceId: String? = null,
)
object SDUIJson {
val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
classDiscriminator = "type"
}
fun encodeToString(primitive: UiPrimitive): String {
return json.encodeToString(primitive)
}
fun decodeFromString(jsonString: String): UiPrimitive {
return json.decodeFromString(jsonString)
}
fun encodeAttentionTier(tier: AttentionTier): String {
return json.encodeToString(tier)
}
fun decodeAttentionTier(jsonString: String): AttentionTier {
return json.decodeFromString(jsonString)
}
}
fun UiPrimitive.toJson(): String = SDUIJson.encodeToString(this)
fun String.toUiPrimitive(): UiPrimitive = SDUIJson.decodeFromString(this)
fun AttentionTier.toJson(): String = SDUIJson.encodeAttentionTier(this)
fun String.toAttentionTier(): AttentionTier = SDUIJson.decodeAttentionTier(this)
fun WebSocketPayload.toJson(): String = SDUIJson.json.encodeToString(this)
fun String.toWebSocketPayload(): WebSocketPayload = SDUIJson.json.decodeFromString(this)
fun ApprovalResponse.toJson(): String = SDUIJson.json.encodeToString(this)
fun String.toApprovalResponse(): ApprovalResponse = SDUIJson.json.decodeFromString(this)

View File

@@ -0,0 +1,145 @@
package meatbag.common
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class UiPrimitiveTest {
@Test
fun testApprovalRequestSerialization() {
val approval = ApprovalRequest(
id = "req-1",
prompt = "Grant admin access?",
target = "prod-db",
callback = CallbackRequest(
id = "cb-1",
target = CallbackTarget.Http("https://backend.test/approval"),
),
)
val json = approval.toJson()
assertTrue(json.contains("\"type\": \"ApprovalRequest\""))
assertTrue(json.contains("\"id\": \"req-1\""))
assertTrue(json.contains("\"prompt\": \"Grant admin access?\""))
assertTrue(json.contains("\"target\": \"prod-db\""))
assertTrue(json.contains("\"intent\": \"APPROVE\""))
assertTrue(json.contains("\"type\": \"Http\""))
val decoded = json.toUiPrimitive()
assertTrue(decoded is ApprovalRequest)
assertEquals(approval.id, decoded.id)
assertEquals(approval.prompt, decoded.prompt)
assertEquals(approval.target, decoded.target)
assertEquals(ActionIntent.APPROVE, decoded.approveAction.intent)
assertTrue(decoded.callback?.target is CallbackTarget.Http)
}
@Test
fun testActionListSerialization() {
val actionList = ActionList(
id = "act-1",
title = "Choose Action",
actions = listOf(
ActionItem("1", "Acknowledge"),
ActionItem("2", "Reject")
)
)
val json = actionList.toJson()
assertTrue(json.contains("\"type\": \"ActionList\""))
assertTrue(json.contains("\"title\": \"Choose Action\""))
val decoded = json.toUiPrimitive()
assertTrue(decoded is ActionList)
assertEquals(2, decoded.actions.size)
assertEquals("Acknowledge", decoded.actions[0].label)
assertEquals(ActionIntent.CUSTOM, decoded.actions[0].intent)
assertEquals(SelectionMode.SINGLE, decoded.selectionMode)
}
@Test
fun testTextInputSerialization() {
val textInput = TextInput(
id = "txt-1",
prompt = "Enter passcode",
placeholder = "1234"
)
val json = textInput.toJson()
assertTrue(json.contains("\"type\": \"TextInput\""))
val decoded = json.toUiPrimitive()
assertTrue(decoded is TextInput)
assertEquals("1234", decoded.placeholder)
}
@Test
fun testAttentionTierSerialization() {
val tier = AttentionTier.SILENT_HAPTIC
val json = tier.toJson()
assertEquals("\"SILENT_HAPTIC\"", json.trim())
val decoded = json.toAttentionTier()
assertEquals(tier, decoded)
}
@Test
fun testWebSocketPayloadSerialization() {
val payload = WebSocketPayload(
attentionTier = AttentionTier.OVERRIDE_SCREEN,
uiPrimitive = Message(
id = "msg-1",
title = "Manual intervention",
body = "A deployment requires attention.",
tone = MessageTone.WARNING,
),
request = RequestContext(
requestId = "req-2",
source = RequestSource(system = "deployments", actor = "agent-7"),
callback = CallbackRequest(
id = "cb-2",
target = CallbackTarget.WebSocket(channel = "approvals"),
),
),
deviceContext = DeviceContext(
deviceId = "watch-1",
deviceClass = DeviceClass.WEARABLE,
platform = DevicePlatform.WEAR_OS,
capabilities = setOf(
DeviceCapability.HAPTICS,
DeviceCapability.FULL_SCREEN_INTENT,
),
),
attentionPolicy = AttentionPolicy(
tier = AttentionTier.OVERRIDE_SCREEN,
hapticPattern = HapticPattern.URGENT_LOOP,
presentation = PresentationMode.ALL_DEVICES,
),
)
val json = payload.toJson()
assertTrue(json.contains("\"type\": \"Message\""))
assertTrue(json.contains("\"type\": \"WebSocket\""))
assertTrue(json.contains("\"tier\": \"OVERRIDE_SCREEN\""))
assertTrue(json.contains("\"deviceClass\": \"WEARABLE\""))
val decoded = json.toWebSocketPayload()
assertEquals(AttentionTier.OVERRIDE_SCREEN, decoded.attentionTier)
assertTrue(decoded.uiPrimitive is Message)
assertEquals("req-2", decoded.request?.requestId)
assertEquals(DevicePlatform.WEAR_OS, decoded.deviceContext?.platform)
assertEquals(HapticPattern.URGENT_LOOP, decoded.attentionPolicy.hapticPattern)
}
@Test
fun testApprovalResponseSerialization() {
val response = ApprovalResponse(
requestId = "req-3",
approved = true,
actionId = "approve",
callbackId = "cb-3",
deviceId = "phone-1",
)
val decoded = response.toJson().toApprovalResponse()
assertEquals(response, decoded)
}
}