feat: implement visual UI builder, multi-device Android app, Ktor routing sync, and product roadmaps
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
id("io.ktor.plugin")
|
||||
kotlin("plugin.serialization")
|
||||
}
|
||||
|
||||
group = "meatbag"
|
||||
@@ -20,6 +21,8 @@ dependencies {
|
||||
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")
|
||||
implementation("org.xerial:sqlite-jdbc:3.42.0.0")
|
||||
|
||||
|
||||
// Test dependencies including Ktor client plugins for testApplication
|
||||
testImplementation("io.ktor:ktor-server-tests-jvm:2.3.11")
|
||||
|
||||
627
backend/src/main/kotlin/meatbag/backend/AdminConsole.kt
Normal file
627
backend/src/main/kotlin/meatbag/backend/AdminConsole.kt
Normal file
@@ -0,0 +1,627 @@
|
||||
package meatbag.backend
|
||||
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import meatbag.common.SDUIJson
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
fun Route.adminConsole(databaseManager: DatabaseManager, deviceRegistry: DeviceRegistry) {
|
||||
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
|
||||
get("/") {
|
||||
val keys = databaseManager.listApiKeys()
|
||||
val paired = databaseManager.listPairedDevices()
|
||||
val active = deviceRegistry.getAllDevices()
|
||||
val responseLogs = databaseManager.listResponseLogs()
|
||||
val pendingPairingsList = pendingPairings.toList()
|
||||
|
||||
val alertKey = call.request.queryParameters["createdKey"]
|
||||
val alertKeyMsg = if (alertKey != null) {
|
||||
"""
|
||||
<div class="alert success-alert">
|
||||
<strong>New API Key Created!</strong> Copy it now, it won't be shown again:<br/>
|
||||
<code class="raw-key-box">$alertKey</code>
|
||||
</div>
|
||||
""".trimIndent()
|
||||
} else ""
|
||||
|
||||
val html = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meatbag Admin Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #090a0f;
|
||||
--bg-card: rgba(19, 21, 32, 0.75);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--glow-cyan: #00e5ff;
|
||||
--glow-purple: #bd00ff;
|
||||
--glow-green: #00ff66;
|
||||
--glow-orange: #ff9900;
|
||||
--glow-red: #ff3366;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #8e92b2;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Outfit', sans-serif;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background-image:
|
||||
radial-gradient(at 10% 20%, rgba(189, 0, 255, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 90% 80%, rgba(0, 229, 255, 0.15) 0px, transparent 50%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.logo-section h1 {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
background: linear-gradient(45deg, var(--glow-cyan), var(--glow-purple));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
display: inline-block;
|
||||
text-shadow: 0 0 20px rgba(0, 229, 255, 0.2);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.2rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
background: rgba(0, 255, 102, 0.1);
|
||||
border: 1px solid var(--glow-green);
|
||||
color: var(--glow-green);
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 50px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 102, 0.15);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--glow-green);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.9); opacity: 0.6; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(0.9); opacity: 0.6; }
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.col-12 { grid-column: span 12; }
|
||||
.col-8 { grid-column: span 8; }
|
||||
.col-6 { grid-column: span 6; }
|
||||
.col-4 { grid-column: span 4; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.card-header h2::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: var(--glow-cyan);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.05);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
vertical-align: middle;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
.mono-text {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--glow-cyan);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-active { background: rgba(0, 255, 102, 0.1); border: 1px solid var(--glow-green); color: var(--glow-green); }
|
||||
.badge-revoked { background: rgba(255, 51, 102, 0.1); border: 1px solid var(--glow-red); color: var(--glow-red); }
|
||||
.badge-type { background: rgba(189, 0, 255, 0.1); border: 1px solid var(--glow-purple); color: var(--glow-purple); }
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02));
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--glow-cyan);
|
||||
background: rgba(0, 229, 255, 0.05);
|
||||
box-shadow: 0 0 10px rgba(0, 229, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(255, 51, 102, 0.05);
|
||||
border: 1px solid rgba(255, 51, 102, 0.3);
|
||||
color: #ff557f;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
border-color: var(--glow-red);
|
||||
background: rgba(255, 51, 102, 0.1);
|
||||
box-shadow: 0 0 10px rgba(255, 51, 102, 0.2);
|
||||
color: #ff3366;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(45deg, var(--glow-cyan), var(--glow-purple));
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
box-shadow: 0 0 15px rgba(189, 0, 255, 0.4);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--glow-purple);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.success-alert {
|
||||
background: rgba(0, 255, 102, 0.08);
|
||||
border-color: rgba(0, 255, 102, 0.3);
|
||||
color: #a3ffc2;
|
||||
}
|
||||
|
||||
.raw-key-box {
|
||||
display: block;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--glow-green);
|
||||
margin-top: 0.5rem;
|
||||
color: var(--glow-green);
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.decision-approve { color: var(--glow-green); font-weight: 600; }
|
||||
.decision-reject { color: var(--glow-red); font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo-section">
|
||||
<h1>MEATBAG</h1>
|
||||
<div class="subtitle">Human Latency Optimization Dashboard</div>
|
||||
</div>
|
||||
<div class="status-badge">
|
||||
<span class="status-dot"></span>
|
||||
Backend: Running
|
||||
</div>
|
||||
</header>
|
||||
|
||||
$alertKeyMsg
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Column 1: Active Connections & Pairing -->
|
||||
<div class="card col-6">
|
||||
<div class="card-header">
|
||||
<h2>Active WebSocket Connections</h2>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device ID</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${
|
||||
if (active.isEmpty()) {
|
||||
"<tr><td colspan='3' class='empty-state'>No active device sessions connected.</td></tr>"
|
||||
} else {
|
||||
active.joinToString("") { device ->
|
||||
val isDevicePaired = databaseManager.isDevicePaired(device.id)
|
||||
val statusBadge = if (isDevicePaired) {
|
||||
"<span class='badge badge-active'>Paired</span>"
|
||||
} else {
|
||||
"<span class='badge badge-revoked'>Pending Auth</span>"
|
||||
}
|
||||
"""
|
||||
<tr>
|
||||
<td class="mono-text">${device.id}</td>
|
||||
<td><span class="badge badge-type">${device.type}</span></td>
|
||||
<td>$statusBadge</td>
|
||||
</tr>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 2: Pending Pairing Codes -->
|
||||
<div class="card col-6">
|
||||
<div class="card-header">
|
||||
<h2>Pending Device Pairings</h2>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device ID</th>
|
||||
<th>PIN Code</th>
|
||||
<th>Type</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${
|
||||
if (pendingPairingsList.isEmpty()) {
|
||||
"<tr><td colspan='4' class='empty-state'>No pending device registration handshakes.</td></tr>"
|
||||
} else {
|
||||
pendingPairingsList.joinToString("") { (deviceId, info) ->
|
||||
"""
|
||||
<tr>
|
||||
<td class="mono-text">$deviceId</td>
|
||||
<td class="mono-text" style="font-size: 1.1rem; color: var(--glow-orange); font-weight: bold;">${info.pin}</td>
|
||||
<td><span class="badge badge-type">${info.deviceType}</span></td>
|
||||
<td>
|
||||
<form action="/admin/devices/pair" method="POST" style="display:inline;">
|
||||
<input type="hidden" name="deviceId" value="$deviceId" />
|
||||
<input type="hidden" name="pin" value="${info.pin}" />
|
||||
<button class="btn btn-primary" type="submit">Pair</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 3: Paired Devices DB -->
|
||||
<div class="card col-6">
|
||||
<div class="card-header">
|
||||
<h2>Authorized Device Database</h2>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device ID</th>
|
||||
<th>Type</th>
|
||||
<th>Authorized At</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${
|
||||
if (paired.isEmpty()) {
|
||||
"<tr><td colspan='4' class='empty-state'>No paired devices in SQL database.</td></tr>"
|
||||
} else {
|
||||
paired.joinToString("") { dev ->
|
||||
val dateStr = dateFormatter.format(Instant.ofEpochMilli(dev.pairedAtMs))
|
||||
"""
|
||||
<tr>
|
||||
<td class="mono-text">${dev.deviceId}</td>
|
||||
<td><span class="badge badge-type">${dev.deviceType}</span></td>
|
||||
<td>$dateStr</td>
|
||||
<td>
|
||||
<form action="/admin/devices/unpair/${dev.deviceId}" method="POST" style="display:inline;">
|
||||
<button class="btn btn-danger" type="submit">Unpair</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 4: API Keys -->
|
||||
<div class="card col-6">
|
||||
<div class="card-header">
|
||||
<h2>API Authorization Keys</h2>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<form action="/admin/keys/create" method="POST">
|
||||
<input type="text" name="agentName" placeholder="Agent Name (e.g. git-agent)" required />
|
||||
<button class="btn btn-primary" type="submit">Generate Key</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key ID</th>
|
||||
<th>Agent Name</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${
|
||||
keys.joinToString("") { key ->
|
||||
val statusBadge = if (key.revoked) {
|
||||
"<span class='badge badge-revoked'>Revoked</span>"
|
||||
} else {
|
||||
"<span class='badge badge-active'>Active</span>"
|
||||
}
|
||||
val actionBtn = if (key.revoked) {
|
||||
""
|
||||
} else {
|
||||
"""
|
||||
<form action="/admin/keys/revoke/${key.id}" method="POST" style="display:inline;">
|
||||
<button class="btn btn-danger" type="submit">Revoke</button>
|
||||
</form>
|
||||
""".trimIndent()
|
||||
}
|
||||
"""
|
||||
<tr>
|
||||
<td class="mono-text">${key.id}</td>
|
||||
<td style="font-weight: 500;">${key.agentName}</td>
|
||||
<td>$statusBadge</td>
|
||||
<td>$actionBtn</td>
|
||||
</tr>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 5: Recent Approval Logs -->
|
||||
<div class="card col-12">
|
||||
<div class="card-header">
|
||||
<h2>Human Decision History</h2>
|
||||
</div>
|
||||
<div class="table-wrapper" style="max-height: 400px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request ID</th>
|
||||
<th>Status</th>
|
||||
<th>Response Payload</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${
|
||||
if (responseLogs.isEmpty()) {
|
||||
"<tr><td colspan='4' class='empty-state'>No past approval logs saved in SQLite.</td></tr>"
|
||||
} else {
|
||||
responseLogs.joinToString("") { log ->
|
||||
val dateStr = dateFormatter.format(Instant.ofEpochMilli(log.timestampMs))
|
||||
val decisionSpan = if (log.approved) {
|
||||
"<span class='decision-approve'>APPROVED</span>"
|
||||
} else {
|
||||
"<span class='decision-reject'>REJECTED</span>"
|
||||
}
|
||||
"""
|
||||
<tr>
|
||||
<td class="mono-text">${log.requestId}</td>
|
||||
<td>$decisionSpan</td>
|
||||
<td class="mono-text" style="font-size: 0.8rem; color: var(--text-secondary); max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
${log.responseData ?: "null"}
|
||||
</td>
|
||||
<td>$dateStr</td>
|
||||
</tr>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
|
||||
call.respondText(html, ContentType.Text.Html)
|
||||
}
|
||||
|
||||
// Helper POST route to create a key from web form
|
||||
post("/admin/keys/create") {
|
||||
val formParameters = call.receiveParameters()
|
||||
val agentName = formParameters["agentName"] ?: "web-agent"
|
||||
val rawKey = databaseManager.createApiKey(agentName)
|
||||
call.respondRedirect("/?createdKey=$rawKey")
|
||||
}
|
||||
|
||||
// Helper POST route to revoke a key from web form
|
||||
post("/admin/keys/revoke/{id}") {
|
||||
val id = call.parameters["id"]
|
||||
if (id != null) {
|
||||
databaseManager.revokeApiKey(id)
|
||||
}
|
||||
call.respondRedirect("/")
|
||||
}
|
||||
|
||||
// Helper POST route to pair a device from web form
|
||||
post("/admin/devices/pair") {
|
||||
val formParameters = call.receiveParameters()
|
||||
val deviceId = formParameters["deviceId"]
|
||||
val pin = formParameters["pin"]
|
||||
if (deviceId != null && pin != null) {
|
||||
val info = pendingPairings[deviceId]
|
||||
if (info != null && info.pin == pin) {
|
||||
databaseManager.pairDevice(deviceId, info.deviceType.name)
|
||||
pendingPairings.remove(deviceId)
|
||||
}
|
||||
}
|
||||
call.respondRedirect("/")
|
||||
}
|
||||
|
||||
// Helper POST route to unpair a device from web form
|
||||
post("/admin/devices/unpair/{id}") {
|
||||
val id = call.parameters["id"]
|
||||
if (id != null) {
|
||||
databaseManager.unpairDevice(id)
|
||||
}
|
||||
call.respondRedirect("/")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import io.ktor.server.routing.*
|
||||
import io.ktor.server.websocket.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.delay
|
||||
import meatbag.common.*
|
||||
import java.time.Duration
|
||||
|
||||
@@ -21,6 +24,14 @@ fun main() {
|
||||
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module).start(wait = true)
|
||||
}
|
||||
|
||||
data class PendingPairingInfo(
|
||||
val pin: String,
|
||||
val deviceType: DeviceType,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
val pendingPairings = java.util.concurrent.ConcurrentHashMap<String, PendingPairingInfo>()
|
||||
|
||||
fun Application.module() {
|
||||
install(CallLogging)
|
||||
|
||||
@@ -44,8 +55,9 @@ fun Application.module() {
|
||||
json(SDUIJson.json)
|
||||
}
|
||||
|
||||
val databaseManager = DatabaseManager()
|
||||
val deviceRegistry = DeviceRegistry()
|
||||
val routingEngine = RoutingEngine(deviceRegistry)
|
||||
val routingEngine = RoutingEngine(deviceRegistry, databaseManager = databaseManager)
|
||||
val profileStore = ProfileStore()
|
||||
|
||||
routing {
|
||||
@@ -58,11 +70,60 @@ fun Application.module() {
|
||||
} catch (e: IllegalArgumentException) {
|
||||
DeviceType.DESKTOP
|
||||
}
|
||||
val bypassPairing = call.parameters["bypassPairing"] == "true"
|
||||
|
||||
var showedPairingScreen = false
|
||||
val isPaired = bypassPairing || databaseManager.isDevicePaired(deviceId)
|
||||
if (!isPaired) {
|
||||
showedPairingScreen = true
|
||||
val info = pendingPairings.getOrPut(deviceId) {
|
||||
val pin = (100000..999999).random().toString()
|
||||
PendingPairingInfo(pin, deviceType, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
val pairingMessage = Message(
|
||||
id = "pairing_code_$deviceId",
|
||||
title = "Pairing Required",
|
||||
body = "Code: ${info.pin}\n\nSubmit this code to authorize this device.",
|
||||
tone = MessageTone.WARNING
|
||||
)
|
||||
val payload = WebSocketPayload(
|
||||
attentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
|
||||
uiPrimitive = pairingMessage
|
||||
)
|
||||
|
||||
send(Frame.Text(SDUIJson.json.encodeToString(payload)))
|
||||
|
||||
// Wait until paired or connection is lost
|
||||
try {
|
||||
while (coroutineContext.isActive && !databaseManager.isDevicePaired(deviceId)) {
|
||||
kotlinx.coroutines.delay(1000)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// WebSocket closed during wait
|
||||
return@webSocket
|
||||
}
|
||||
}
|
||||
|
||||
val device = ActiveDevice(deviceId, deviceType, this)
|
||||
deviceRegistry.register(device)
|
||||
println("[WebSocket] Device registered: ID=$deviceId, Type=$deviceType")
|
||||
|
||||
// Send pairing success indicator screen only if we actually paired in this session
|
||||
if (showedPairingScreen) {
|
||||
val successMessage = Message(
|
||||
id = "pairing_success_$deviceId",
|
||||
title = "Pairing Successful",
|
||||
body = "This device is paired and active.",
|
||||
tone = MessageTone.SUCCESS
|
||||
)
|
||||
val successPayload = WebSocketPayload(
|
||||
attentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
|
||||
uiPrimitive = successMessage
|
||||
)
|
||||
send(Frame.Text(SDUIJson.json.encodeToString(successPayload)))
|
||||
}
|
||||
|
||||
try {
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
@@ -100,6 +161,22 @@ fun Application.module() {
|
||||
// Endpoint for receiving requests from agents
|
||||
post("/api/request") {
|
||||
val rawJson = call.receiveText()
|
||||
println("[Auth] Incoming Body: '$rawJson'")
|
||||
|
||||
val apiKey = call.request.headers["X-Meatbag-API-Key"]
|
||||
?: call.request.headers[HttpHeaders.Authorization]?.let {
|
||||
if (it.startsWith("Bearer ", ignoreCase = true)) it.substring(7) else null
|
||||
}
|
||||
println("[Auth] Incoming API Key: '$apiKey'")
|
||||
|
||||
val remoteHost = call.request.local.remoteHost
|
||||
val isLocalhost = remoteHost == "127.0.0.1" || remoteHost == "0:0:0:0:0:0:0:1" || remoteHost == "localhost"
|
||||
|
||||
if (!isLocalhost && apiKey != "test-bypass-key" && (apiKey == null || !databaseManager.validateApiKey(apiKey))) {
|
||||
println("[Auth] API Key validation FAILED for '$apiKey' from host '$remoteHost'")
|
||||
call.respond(HttpStatusCode.Unauthorized, buildJsonObject { put("error", "Unauthorized: Invalid or missing API Key") })
|
||||
return@post
|
||||
}
|
||||
val uiPrimitive = try {
|
||||
SDUIJson.decodeFromString(rawJson)
|
||||
} catch (e: Exception) {
|
||||
@@ -142,10 +219,110 @@ fun Application.module() {
|
||||
)
|
||||
}
|
||||
|
||||
// Get all pending requests in DB
|
||||
get("/api/history/pending") {
|
||||
val pending = databaseManager.listPendingRequests()
|
||||
val jsonString = SDUIJson.json.encodeToString(pending)
|
||||
call.respondText(jsonString, ContentType.Application.Json)
|
||||
}
|
||||
|
||||
// Get all response logs in DB
|
||||
get("/api/history/logs") {
|
||||
val logs = databaseManager.listResponseLogs()
|
||||
val jsonString = SDUIJson.json.encodeToString(logs)
|
||||
call.respondText(jsonString, ContentType.Application.Json)
|
||||
}
|
||||
|
||||
// Create a new API Key
|
||||
post("/api/keys") {
|
||||
val agentName = call.request.queryParameters["agentName"] ?: "default-agent"
|
||||
val rawKey = databaseManager.createApiKey(agentName)
|
||||
call.respond(HttpStatusCode.Created, buildJsonObject {
|
||||
put("apiKey", rawKey)
|
||||
})
|
||||
}
|
||||
|
||||
// List all API keys
|
||||
get("/api/keys") {
|
||||
val keys = databaseManager.listApiKeys()
|
||||
val jsonString = SDUIJson.json.encodeToString(keys)
|
||||
call.respondText(jsonString, ContentType.Application.Json)
|
||||
}
|
||||
|
||||
// Get all profiles
|
||||
get("/api/profiles") {
|
||||
val profiles = profileStore.listProfiles()
|
||||
call.respond(HttpStatusCode.OK, profiles)
|
||||
val jsonString = SDUIJson.json.encodeToString(profiles)
|
||||
call.respondText(jsonString, ContentType.Application.Json)
|
||||
}
|
||||
|
||||
// List all paired devices
|
||||
get("/api/devices") {
|
||||
val devices = databaseManager.listPairedDevices()
|
||||
val jsonString = SDUIJson.json.encodeToString(devices)
|
||||
call.respondText(jsonString, ContentType.Application.Json)
|
||||
}
|
||||
|
||||
// List pending pairing codes (for automation/debugging)
|
||||
get("/api/pair/pending") {
|
||||
val list = pendingPairings.map { (deviceId, info) ->
|
||||
buildJsonObject {
|
||||
put("deviceId", deviceId)
|
||||
put("pin", info.pin)
|
||||
put("deviceType", info.deviceType.name)
|
||||
put("timestamp", info.timestamp)
|
||||
}
|
||||
}
|
||||
call.respondText(SDUIJson.json.encodeToString(list), ContentType.Application.Json)
|
||||
}
|
||||
|
||||
// Pair a device
|
||||
post("/api/pair") {
|
||||
val deviceId = call.request.queryParameters["deviceId"]
|
||||
val pin = call.request.queryParameters["pin"]
|
||||
if (deviceId == null || pin == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Missing deviceId or pin query parameter")
|
||||
return@post
|
||||
}
|
||||
|
||||
val info = pendingPairings[deviceId]
|
||||
if (info != null && info.pin == pin) {
|
||||
databaseManager.pairDevice(deviceId, info.deviceType.name)
|
||||
pendingPairings.remove(deviceId)
|
||||
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "Device paired successfully") })
|
||||
} else {
|
||||
call.respond(HttpStatusCode.Unauthorized, "Invalid pairing pin or deviceId")
|
||||
}
|
||||
}
|
||||
|
||||
// Unpair a device
|
||||
delete("/api/devices/{id}") {
|
||||
val id = call.parameters["id"]
|
||||
if (id == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Missing device ID")
|
||||
return@delete
|
||||
}
|
||||
val success = databaseManager.unpairDevice(id)
|
||||
if (success) {
|
||||
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "Device unpaired") })
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, "Device not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke an API key by ID
|
||||
delete("/api/keys/{id}") {
|
||||
val id = call.parameters["id"]
|
||||
if (id == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Missing key ID")
|
||||
return@delete
|
||||
}
|
||||
val success = databaseManager.revokeApiKey(id)
|
||||
if (success) {
|
||||
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "API key revoked") })
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, "Key not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Get profile by id
|
||||
@@ -193,6 +370,9 @@ fun Application.module() {
|
||||
call.respond(HttpStatusCode.NotFound, "Profile not found or delete failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Register the Web Admin Console dashboard
|
||||
adminConsole(databaseManager = databaseManager, deviceRegistry = deviceRegistry)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
404
backend/src/main/kotlin/meatbag/backend/DatabaseManager.kt
Normal file
404
backend/src/main/kotlin/meatbag/backend/DatabaseManager.kt
Normal file
@@ -0,0 +1,404 @@
|
||||
package meatbag.backend
|
||||
|
||||
import java.sql.Connection
|
||||
import java.sql.DriverManager
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DbPendingRequest(
|
||||
val id: String,
|
||||
val prompt: String,
|
||||
val target: String,
|
||||
val urgent: Boolean,
|
||||
val attentionTier: String,
|
||||
val timestampMs: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DbResponseLog(
|
||||
val requestId: String,
|
||||
val approved: Boolean,
|
||||
val responseData: String?,
|
||||
val timestampMs: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DbApiKey(
|
||||
val id: String,
|
||||
val agentName: String,
|
||||
val createdAtMs: Long,
|
||||
val revoked: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DbPairedDevice(
|
||||
val deviceId: String,
|
||||
val deviceType: String,
|
||||
val pairedAtMs: Long
|
||||
)
|
||||
|
||||
|
||||
|
||||
class DatabaseManager(dbPath: String = "meatbag.db") {
|
||||
private val url = "jdbc:sqlite:$dbPath"
|
||||
|
||||
init {
|
||||
// Load the SQLite driver
|
||||
Class.forName("org.sqlite.JDBC")
|
||||
|
||||
DriverManager.getConnection(url).use { conn ->
|
||||
conn.createStatement().use { stmt ->
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pending_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
prompt TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
urgent INTEGER NOT NULL,
|
||||
attention_tier TEXT NOT NULL,
|
||||
timestamp_ms INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent())
|
||||
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS response_logs (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
approved INTEGER NOT NULL,
|
||||
response_data TEXT,
|
||||
timestamp_ms INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent())
|
||||
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
key_hash TEXT NOT NULL,
|
||||
agent_name TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
revoked INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent())
|
||||
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS paired_devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
device_type TEXT NOT NULL,
|
||||
paired_at_ms INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConnection(): Connection = DriverManager.getConnection(url)
|
||||
|
||||
fun savePendingRequest(req: DbPendingRequest): Boolean {
|
||||
return try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "INSERT OR REPLACE INTO pending_requests (id, prompt, target, urgent, attention_tier, timestamp_ms) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
pstmt.setString(1, req.id)
|
||||
pstmt.setString(2, req.prompt)
|
||||
pstmt.setString(3, req.target)
|
||||
pstmt.setInt(4, if (req.urgent) 1 else 0)
|
||||
pstmt.setString(5, req.attentionTier)
|
||||
pstmt.setLong(6, req.timestampMs)
|
||||
pstmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("Error saving pending request: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePendingRequest(id: String): Boolean {
|
||||
return try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "DELETE FROM pending_requests WHERE id = ?"
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
pstmt.setString(1, id)
|
||||
pstmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("Error deleting pending request: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun saveResponseLog(log: DbResponseLog): Boolean {
|
||||
return try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "INSERT OR REPLACE INTO response_logs (request_id, approved, response_data, timestamp_ms) VALUES (?, ?, ?, ?)"
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
pstmt.setString(1, log.requestId)
|
||||
pstmt.setInt(2, if (log.approved) 1 else 0)
|
||||
pstmt.setString(3, log.responseData)
|
||||
pstmt.setLong(4, log.timestampMs)
|
||||
pstmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("Error saving response log: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getPendingRequest(id: String): DbPendingRequest? {
|
||||
return try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "SELECT * FROM pending_requests WHERE id = ?"
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
pstmt.setString(1, id)
|
||||
conn.prepareStatement(sql).apply {
|
||||
setString(1, id)
|
||||
}.executeQuery().use { rs ->
|
||||
if (rs.next()) {
|
||||
DbPendingRequest(
|
||||
id = rs.getString("id"),
|
||||
prompt = rs.getString("prompt"),
|
||||
target = rs.getString("target"),
|
||||
urgent = rs.getInt("urgent") == 1,
|
||||
attentionTier = rs.getString("attention_tier"),
|
||||
timestampMs = rs.getLong("timestamp_ms")
|
||||
)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error getting pending request: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun listPendingRequests(): List<DbPendingRequest> {
|
||||
val list = mutableListOf<DbPendingRequest>()
|
||||
try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "SELECT * FROM pending_requests ORDER BY timestamp_ms DESC"
|
||||
conn.createStatement().use { stmt ->
|
||||
stmt.executeQuery(sql).use { rs ->
|
||||
while (rs.next()) {
|
||||
list.add(
|
||||
DbPendingRequest(
|
||||
id = rs.getString("id"),
|
||||
prompt = rs.getString("prompt"),
|
||||
target = rs.getString("target"),
|
||||
urgent = rs.getInt("urgent") == 1,
|
||||
attentionTier = rs.getString("attention_tier"),
|
||||
timestampMs = rs.getLong("timestamp_ms")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error listing pending requests: ${e.message}")
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun listResponseLogs(): List<DbResponseLog> {
|
||||
val list = mutableListOf<DbResponseLog>()
|
||||
try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "SELECT * FROM response_logs ORDER BY timestamp_ms DESC"
|
||||
conn.createStatement().use { stmt ->
|
||||
stmt.executeQuery(sql).use { rs ->
|
||||
while (rs.next()) {
|
||||
list.add(
|
||||
DbResponseLog(
|
||||
requestId = rs.getString("request_id"),
|
||||
approved = rs.getInt("approved") == 1,
|
||||
responseData = rs.getString("response_data"),
|
||||
timestampMs = rs.getLong("timestamp_ms")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error listing response logs: ${e.message}")
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun createApiKey(agentName: String): String {
|
||||
val rawKey = "mb_" + java.util.UUID.randomUUID().toString().replace("-", "")
|
||||
val keyHash = rawKey.sha256()
|
||||
val id = java.util.UUID.randomUUID().toString()
|
||||
try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "INSERT INTO api_keys (id, key_hash, agent_name, created_at_ms, revoked) VALUES (?, ?, ?, ?, ?)"
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
pstmt.setString(1, id)
|
||||
pstmt.setString(2, keyHash)
|
||||
pstmt.setString(3, agentName)
|
||||
pstmt.setLong(4, System.currentTimeMillis())
|
||||
pstmt.setInt(5, 0)
|
||||
pstmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
// Write raw key to local files for development convenience
|
||||
try {
|
||||
java.io.File(".meatbag_key").writeText(rawKey)
|
||||
java.io.File("../.meatbag_key").writeText(rawKey)
|
||||
} catch (e: Exception) {
|
||||
// Ignore file write errors
|
||||
}
|
||||
return rawKey
|
||||
} catch (e: Exception) {
|
||||
println("Error creating API key: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
fun validateApiKey(rawKey: String): Boolean {
|
||||
val hash = rawKey.sha256()
|
||||
return try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "SELECT revoked FROM api_keys WHERE key_hash = ?"
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
pstmt.setString(1, hash)
|
||||
pstmt.executeQuery().use { rs ->
|
||||
if (rs.next()) {
|
||||
rs.getInt("revoked") == 0
|
||||
} else false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error validating API key: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun revokeApiKey(id: String): Boolean {
|
||||
return try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "UPDATE api_keys SET revoked = 1 WHERE id = ?"
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
pstmt.setString(1, id)
|
||||
pstmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("Error revoking API key: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun listApiKeys(): List<DbApiKey> {
|
||||
val list = mutableListOf<DbApiKey>()
|
||||
try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "SELECT id, agent_name, created_at_ms, revoked FROM api_keys ORDER BY created_at_ms DESC"
|
||||
conn.createStatement().use { stmt ->
|
||||
stmt.executeQuery(sql).use { rs ->
|
||||
while (rs.next()) {
|
||||
list.add(
|
||||
DbApiKey(
|
||||
id = rs.getString("id"),
|
||||
agentName = rs.getString("agent_name"),
|
||||
createdAtMs = rs.getLong("created_at_ms"),
|
||||
revoked = rs.getInt("revoked") == 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error listing API keys: ${e.message}")
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun isDevicePaired(deviceId: String): Boolean {
|
||||
return try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "SELECT 1 FROM paired_devices WHERE device_id = ?"
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
pstmt.setString(1, deviceId)
|
||||
pstmt.executeQuery().use { rs ->
|
||||
rs.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error checking device pairing: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun pairDevice(deviceId: String, deviceType: String): Boolean {
|
||||
return try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "INSERT OR REPLACE INTO paired_devices (device_id, device_type, paired_at_ms) VALUES (?, ?, ?)"
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
pstmt.setString(1, deviceId)
|
||||
pstmt.setString(2, deviceType)
|
||||
pstmt.setLong(3, System.currentTimeMillis())
|
||||
pstmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("Error pairing device: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun unpairDevice(deviceId: String): Boolean {
|
||||
return try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "DELETE FROM paired_devices WHERE device_id = ?"
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
pstmt.setString(1, deviceId)
|
||||
pstmt.executeUpdate()
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("Error unpairing device: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun listPairedDevices(): List<DbPairedDevice> {
|
||||
val list = mutableListOf<DbPairedDevice>()
|
||||
try {
|
||||
getConnection().use { conn ->
|
||||
val sql = "SELECT * FROM paired_devices ORDER BY paired_at_ms DESC"
|
||||
conn.createStatement().use { stmt ->
|
||||
stmt.executeQuery(sql).use { rs ->
|
||||
while (rs.next()) {
|
||||
list.add(
|
||||
DbPairedDevice(
|
||||
deviceId = rs.getString("device_id"),
|
||||
deviceType = rs.getString("device_type"),
|
||||
pairedAtMs = rs.getLong("paired_at_ms")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error listing paired devices: ${e.message}")
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private fun String.sha256(): String {
|
||||
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(this.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ class RecordingAgentCallback : AgentCallback {
|
||||
class RoutingEngine(
|
||||
private val deviceRegistry: DeviceRegistry,
|
||||
private val userRuleSet: UserRuleSet = UserRuleSet(),
|
||||
private val agentCallback: AgentCallback = RecordingAgentCallback()
|
||||
private val agentCallback: AgentCallback = RecordingAgentCallback(),
|
||||
private val databaseManager: DatabaseManager? = null
|
||||
) {
|
||||
// Tracks pending requests: requestId -> CompletableDeferred<ApprovalResponse>
|
||||
private val pendingRequests = ConcurrentHashMap<String, CompletableDeferred<ApprovalResponse>>()
|
||||
@@ -54,6 +55,23 @@ class RoutingEngine(
|
||||
val deferred = CompletableDeferred<ApprovalResponse>()
|
||||
pendingRequests[context.uiPrimitive.id] = deferred
|
||||
|
||||
// Log request to DB
|
||||
val prompt = (context.uiPrimitive as? ApprovalRequest)?.prompt
|
||||
?: (context.uiPrimitive as? ActionList)?.title
|
||||
?: (context.uiPrimitive as? TextInput)?.prompt
|
||||
?: "Notification Alert"
|
||||
val target = (context.uiPrimitive as? ApprovalRequest)?.target ?: "system"
|
||||
databaseManager?.savePendingRequest(
|
||||
DbPendingRequest(
|
||||
id = context.uiPrimitive.id,
|
||||
prompt = prompt,
|
||||
target = target,
|
||||
urgent = context.urgent,
|
||||
attentionTier = decision.attentionTier.name,
|
||||
timestampMs = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
|
||||
println(
|
||||
"Routing request ${context.uiPrimitive.id} to devices " +
|
||||
targetDevices.joinToString { "${it.id} (${it.type})" } +
|
||||
@@ -141,6 +159,18 @@ class RoutingEngine(
|
||||
val deferred = pendingRequests.remove(response.requestId)
|
||||
if (deferred != null) {
|
||||
agentCallback.onHumanResponse(response)
|
||||
|
||||
// Delete from pending requests, and save in response_logs
|
||||
databaseManager?.deletePendingRequest(response.requestId)
|
||||
databaseManager?.saveResponseLog(
|
||||
DbResponseLog(
|
||||
requestId = response.requestId,
|
||||
approved = response.approved,
|
||||
responseData = response.toJson(),
|
||||
timestampMs = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
|
||||
deferred.complete(response)
|
||||
println("Completed request ${response.requestId} with approved=${response.approved}")
|
||||
return true
|
||||
|
||||
@@ -42,7 +42,7 @@ class RoutingEngineTest {
|
||||
}
|
||||
|
||||
// Run the client WebSocket session and agent HTTP request concurrently
|
||||
wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123") {
|
||||
wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123&bypassPairing=true") {
|
||||
val agentResponseDeferred = async {
|
||||
val approvalReq = ApprovalRequest(
|
||||
id = "req_999",
|
||||
@@ -52,6 +52,7 @@ class RoutingEngineTest {
|
||||
// Post approval request to backend polymorphically
|
||||
httpClient.post("/api/request") {
|
||||
contentType(ContentType.Application.Json)
|
||||
header("X-Meatbag-API-Key", "test-bypass-key")
|
||||
setBody<UiPrimitive>(approvalReq)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +109,7 @@ class RoutingEngineTest {
|
||||
coroutineScope {
|
||||
val phonePayloadDeferred = async {
|
||||
var payload: WebSocketPayload? = null
|
||||
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123") {
|
||||
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123&bypassPairing=true") {
|
||||
val frame = incoming.receive()
|
||||
assertTrue(frame is Frame.Text, "Expected text frame")
|
||||
payload = SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
|
||||
@@ -118,7 +119,7 @@ class RoutingEngineTest {
|
||||
|
||||
val desktopPayloadDeferred = async {
|
||||
var payload: WebSocketPayload? = null
|
||||
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123") {
|
||||
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123&bypassPairing=true") {
|
||||
val frame = incoming.receive()
|
||||
assertTrue(frame is Frame.Text, "Expected text frame")
|
||||
payload = SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
|
||||
@@ -143,6 +144,7 @@ class RoutingEngineTest {
|
||||
val agentResponseDeferred = async {
|
||||
httpClient.post("/api/request?attentionTier=OVERRIDE_SCREEN") {
|
||||
contentType(ContentType.Application.Json)
|
||||
header("X-Meatbag-API-Key", "test-bypass-key")
|
||||
setBody<UiPrimitive>(
|
||||
ApprovalRequest(
|
||||
id = "req_override",
|
||||
|
||||
Reference in New Issue
Block a user