mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-28 13:01:10 +00:00
feat(gateway): session dashboard domain — board RPCs, ticketed widgets, dashboard tool, per-agent persistence (#110960)
* feat(gateway): add session dashboard domain * test(gateway): cover board event guards * fix(gateway): authorize board widgets with view tickets * feat(boards): persist dashboards in agent databases * test(boards): use tracked SQLite temp directories * test(gateway): isolate board deletion lifecycle coverage * fix(boards): harden widget view ticket bindings * fix(boards): lazily ensure additive board schema * fix(gateway): complete dashboard integration metadata * fix(boards): satisfy generated integration checks * fix(boards): align persistence with repository guards * fix(boards): retain notice test reset * fix(protocol): generate dashboard union models * fix(boards): harden dashboard persistence and serving * fix(boards): satisfy dashboard CI guardrails
This commit is contained in:
committed by
GitHub
parent
ed546bdcf5
commit
2f00a417ed
@@ -231,6 +231,11 @@ enum class GatewayMethod(
|
||||
McpAppListResourceTemplates("mcp.app.listResourceTemplates"),
|
||||
McpAppReadResource("mcp.app.readResource"),
|
||||
McpAppCallTool("mcp.app.callTool"),
|
||||
BoardGet("board.get"),
|
||||
BoardUpdate("board.update"),
|
||||
BoardWidgetPut("board.widget.put"),
|
||||
BoardWidgetGrant("board.widget.grant"),
|
||||
BoardEvent("board.event"),
|
||||
AuditList("audit.list"),
|
||||
AuditActivityList("audit.activity.list"),
|
||||
TasksList("tasks.list"),
|
||||
|
||||
@@ -417,6 +417,7 @@ internal val nativeStringResourceIds: Map<String, Int> =
|
||||
"Current session" to R.string.native_a32789d2b53dc704,
|
||||
"D" to R.string.native_3f39d5c348e5b79d,
|
||||
"DIARY" to R.string.native_6cb1e6a563098eba,
|
||||
"Dashboard" to R.string.native_67b696468610b879,
|
||||
"Default" to R.string.native_21b111cbfe6e8fca,
|
||||
"Default Agent" to R.string.native_0349070711a2323d,
|
||||
"Default agent" to R.string.native_94da52ecd6c5c3b7,
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تفاصيل الجدول"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"إمكانات الهاتف"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"غير متاح"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لصق الرمز المميز"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لا يوجد موفرون"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ميكروفون Bluetooth"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zeitplandetails"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefonfunktionen"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nicht verfügbar"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Token einfügen"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Keine Anbieter"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth-Mikrofon"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detalles de la programación"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Capacidades del teléfono"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No disponible"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pegar token"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No hay proveedores"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Micrófono Bluetooth"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جزئیات زمانبندی"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"قابلیتهای تلفن"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در دسترس نیست"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"چسباندن توکن"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ارائهدهندهای وجود ندارد"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"میکروفون Bluetooth"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Détails de la planification"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fonctionnalités du téléphone"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Non disponible"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Coller le jeton"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aucun fournisseur"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Microphone Bluetooth"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"शेड्यूल का विवरण"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"फ़ोन की क्षमताएँ"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"उपलब्ध नहीं"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"टोकन पेस्ट करें"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"कोई प्रदाता नहीं"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth माइक्रोफ़ोन"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detail Jadwal"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kemampuan Ponsel"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tidak tersedia"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tempel token"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tidak ada penyedia"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mikrofon Bluetooth"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dettagli pianificazione"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Funzionalità del telefono"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Non disponibile"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Incolla token"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nessun provider"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Microfono Bluetooth"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"スケジュールの詳細"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"電話の機能"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"利用できません"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"トークンを貼り付け"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"プロバイダーがありません"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetoothマイク"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"일정 상세 정보"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"휴대전화 기능"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"사용할 수 없음"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"토큰 붙여넣기"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"제공자 없음"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth 마이크"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Planningsdetails"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefoonmogelijkheden"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Niet beschikbaar"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Token plakken"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Geen providers"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth-microfoon"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Szczegóły harmonogramu"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Funkcje telefonu"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Niedostępne"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wklej token"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Brak dostawców"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mikrofon Bluetooth"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Detalhes do agendamento"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Recursos do telefone"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Não disponível"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Colar token"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nenhum provedor"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Microfone Bluetooth"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Сведения о расписании"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Возможности телефона"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Недоступно"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вставить токен"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Нет провайдеров"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth-микрофон"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Schemadetaljer"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefonfunktioner"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inte tillgänglig"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Klistra in token"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Inga leverantörer"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth-mikrofon"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"รายละเอียดกำหนดการ"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ความสามารถของโทรศัพท์"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่พร้อมใช้งาน"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"วางโทเค็น"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่มีผู้ให้บริการ"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไมโครโฟน Bluetooth"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zamanlama Ayrıntısı"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Telefon Özellikleri"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kullanılamıyor"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Token\'ı yapıştır"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sağlayıcı yok"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth mikrofonu"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відомості про розклад"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Можливості телефона"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Недоступно"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вставити токен"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Немає постачальників"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth-мікрофон"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chi tiết lịch trình"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Khả năng của điện thoại"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Không khả dụng"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dán token"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Không có nhà cung cấp"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Micrô Bluetooth"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"计划详情"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手机功能"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"不可用"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"粘贴令牌"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"无提供商"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"蓝牙麦克风"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"排程詳細資料"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"手機功能"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"無法使用"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"貼上 token"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"沒有提供者"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth 麥克風"</string>
|
||||
|
||||
@@ -613,6 +613,7 @@
|
||||
<string name="native_674eb55d9c353900" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Schedule Detail"</string>
|
||||
<string name="native_67528858c36397f7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Phone Capabilities"</string>
|
||||
<string name="native_67a926f7008e2b0e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Not available"</string>
|
||||
<string name="native_67b696468610b879" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dashboard"</string>
|
||||
<string name="native_67fa416afa64d304" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Paste token"</string>
|
||||
<string name="native_6827238fea72c88b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No providers"</string>
|
||||
<string name="native_68a0cdfcc76d6ebd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bluetooth microphone"</string>
|
||||
|
||||
@@ -274,6 +274,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"emoji": "📋",
|
||||
"title": "Dashboard",
|
||||
"detailKeys": [
|
||||
"action",
|
||||
"tabId",
|
||||
"name",
|
||||
"title"
|
||||
]
|
||||
},
|
||||
"nodes": {
|
||||
"emoji": "📱",
|
||||
"title": "Nodes",
|
||||
|
||||
@@ -186,6 +186,534 @@ public enum QuestionStatus: String, Codable, Sendable {
|
||||
case expired = "expired"
|
||||
}
|
||||
|
||||
public struct BoardTab: Codable, Sendable {
|
||||
public let tabid: String
|
||||
public let title: String
|
||||
public let position: Int
|
||||
public let chatdock: AnyCodable
|
||||
|
||||
public init(
|
||||
tabid: String,
|
||||
title: String,
|
||||
position: Int,
|
||||
chatdock: AnyCodable)
|
||||
{
|
||||
self.tabid = tabid
|
||||
self.title = title
|
||||
self.position = position
|
||||
self.chatdock = chatdock
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case tabid = "tabId"
|
||||
case title
|
||||
case position
|
||||
case chatdock = "chatDock"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidget: Codable, Sendable {
|
||||
public let name: String
|
||||
public let tabid: String
|
||||
public let title: String?
|
||||
public let contentkind: AnyCodable
|
||||
public let sizew: Int
|
||||
public let sizeh: Int
|
||||
public let position: Int
|
||||
public let grantstate: AnyCodable
|
||||
public let revision: Int
|
||||
public let declaredsummary: [String]?
|
||||
public let frameurl: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
tabid: String,
|
||||
title: String? = nil,
|
||||
contentkind: AnyCodable,
|
||||
sizew: Int,
|
||||
sizeh: Int,
|
||||
position: Int,
|
||||
grantstate: AnyCodable,
|
||||
revision: Int,
|
||||
declaredsummary: [String]? = nil,
|
||||
frameurl: String? = nil)
|
||||
{
|
||||
self.name = name
|
||||
self.tabid = tabid
|
||||
self.title = title
|
||||
self.contentkind = contentkind
|
||||
self.sizew = sizew
|
||||
self.sizeh = sizeh
|
||||
self.position = position
|
||||
self.grantstate = grantstate
|
||||
self.revision = revision
|
||||
self.declaredsummary = declaredsummary
|
||||
self.frameurl = frameurl
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case tabid = "tabId"
|
||||
case title
|
||||
case contentkind = "contentKind"
|
||||
case sizew = "sizeW"
|
||||
case sizeh = "sizeH"
|
||||
case position
|
||||
case grantstate = "grantState"
|
||||
case revision
|
||||
case declaredsummary = "declaredSummary"
|
||||
case frameurl = "frameUrl"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardSnapshot: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let revision: Int
|
||||
public let tabs: [BoardTab]
|
||||
public let widgets: [BoardWidget]
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
revision: Int,
|
||||
tabs: [BoardTab],
|
||||
widgets: [BoardWidget])
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.revision = revision
|
||||
self.tabs = tabs
|
||||
self.widgets = widgets
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case revision
|
||||
case tabs
|
||||
case widgets
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardTabCreateOp: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let tabid: String
|
||||
public let title: String
|
||||
public let chatdock: AnyCodable?
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
tabid: String,
|
||||
title: String,
|
||||
chatdock: AnyCodable? = nil)
|
||||
{
|
||||
self.kind = kind
|
||||
self.tabid = tabid
|
||||
self.title = title
|
||||
self.chatdock = chatdock
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case tabid = "tabId"
|
||||
case title
|
||||
case chatdock = "chatDock"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardTabUpdateOp: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let tabid: String
|
||||
public let title: String?
|
||||
public let chatdock: AnyCodable?
|
||||
public let position: Int?
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
tabid: String,
|
||||
title: String? = nil,
|
||||
chatdock: AnyCodable? = nil,
|
||||
position: Int? = nil)
|
||||
{
|
||||
self.kind = kind
|
||||
self.tabid = tabid
|
||||
self.title = title
|
||||
self.chatdock = chatdock
|
||||
self.position = position
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case tabid = "tabId"
|
||||
case title
|
||||
case chatdock = "chatDock"
|
||||
case position
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardTabDeleteOp: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let tabid: String
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
tabid: String)
|
||||
{
|
||||
self.kind = kind
|
||||
self.tabid = tabid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case tabid = "tabId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardTabsReorderOp: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let tabids: [String]
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
tabids: [String])
|
||||
{
|
||||
self.kind = kind
|
||||
self.tabids = tabids
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case tabids = "tabIds"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetMoveOp: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let name: String
|
||||
public let tabid: String?
|
||||
public let position: Int?
|
||||
public let after: String?
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
name: String,
|
||||
tabid: String? = nil,
|
||||
position: Int? = nil,
|
||||
after: String? = nil)
|
||||
{
|
||||
self.kind = kind
|
||||
self.name = name
|
||||
self.tabid = tabid
|
||||
self.position = position
|
||||
self.after = after
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case name
|
||||
case tabid = "tabId"
|
||||
case position
|
||||
case after
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetResizeOp: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let name: String
|
||||
public let sizew: Int
|
||||
public let sizeh: Int
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
name: String,
|
||||
sizew: Int,
|
||||
sizeh: Int)
|
||||
{
|
||||
self.kind = kind
|
||||
self.name = name
|
||||
self.sizew = sizew
|
||||
self.sizeh = sizeh
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case name
|
||||
case sizew = "sizeW"
|
||||
case sizeh = "sizeH"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetRemoveOp: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let name: String
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
name: String)
|
||||
{
|
||||
self.kind = kind
|
||||
self.name = name
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case name
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardMcpAppDescriptor: Codable, Sendable {
|
||||
public let servername: String
|
||||
public let toolname: String
|
||||
public let uiresourceuri: String
|
||||
public let originsessionkey: String
|
||||
public let toolcallid: String
|
||||
|
||||
public init(
|
||||
servername: String,
|
||||
toolname: String,
|
||||
uiresourceuri: String,
|
||||
originsessionkey: String,
|
||||
toolcallid: String)
|
||||
{
|
||||
self.servername = servername
|
||||
self.toolname = toolname
|
||||
self.uiresourceuri = uiresourceuri
|
||||
self.originsessionkey = originsessionkey
|
||||
self.toolcallid = toolcallid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case servername = "serverName"
|
||||
case toolname = "toolName"
|
||||
case uiresourceuri = "uiResourceUri"
|
||||
case originsessionkey = "originSessionKey"
|
||||
case toolcallid = "toolCallId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetHtmlContent: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let html: String
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
html: String)
|
||||
{
|
||||
self.kind = kind
|
||||
self.html = html
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case html
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetMcpAppContent: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let descriptor: BoardMcpAppDescriptor
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
descriptor: BoardMcpAppDescriptor)
|
||||
{
|
||||
self.kind = kind
|
||||
self.descriptor = descriptor
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case descriptor
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardGetParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
|
||||
public init(
|
||||
sessionkey: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardUpdateParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let ops: [BoardOp]
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
ops: [BoardOp])
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.ops = ops
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case ops
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetPutParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let name: String
|
||||
public let title: String?
|
||||
public let content: BoardWidgetContent
|
||||
public let placement: [String: AnyCodable]?
|
||||
public let declared: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
name: String,
|
||||
title: String? = nil,
|
||||
content: BoardWidgetContent,
|
||||
placement: [String: AnyCodable]? = nil,
|
||||
declared: [String: AnyCodable]? = nil)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.name = name
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.placement = placement
|
||||
self.declared = declared
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case name
|
||||
case title
|
||||
case content
|
||||
case placement
|
||||
case declared
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetGrantParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let name: String
|
||||
public let decision: AnyCodable
|
||||
public let revision: Int
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
name: String,
|
||||
decision: AnyCodable,
|
||||
revision: Int)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.name = name
|
||||
self.decision = decision
|
||||
self.revision = revision
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case name
|
||||
case decision
|
||||
case revision
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardEventParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let widget: String
|
||||
public let payload: AnyCodable
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
widget: String,
|
||||
payload: AnyCodable)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.widget = widget
|
||||
self.payload = payload
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case widget
|
||||
case payload
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardChangedEvent: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let revision: Int
|
||||
public let widget: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
revision: Int,
|
||||
widget: String? = nil)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.revision = revision
|
||||
self.widget = widget
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case revision
|
||||
case widget
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardFocusTabCommand: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let tabid: String
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
tabid: String)
|
||||
{
|
||||
self.kind = kind
|
||||
self.tabid = tabid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case tabid = "tabId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardSetChatDockCommand: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let dock: AnyCodable
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
dock: AnyCodable)
|
||||
{
|
||||
self.kind = kind
|
||||
self.dock = dock
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case dock
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardCommandEvent: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let command: BoardCommand
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
command: BoardCommand)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.command = command
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case command
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnectParams: Codable, Sendable {
|
||||
public let minprotocol: Int
|
||||
public let maxprotocol: Int
|
||||
@@ -14591,6 +15119,114 @@ public struct ShutdownEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum BoardOp: Codable, Sendable {
|
||||
case tabCreate(BoardTabCreateOp)
|
||||
case tabUpdate(BoardTabUpdateOp)
|
||||
case tabDelete(BoardTabDeleteOp)
|
||||
case tabsReorder(BoardTabsReorderOp)
|
||||
case widgetMove(BoardWidgetMoveOp)
|
||||
case widgetResize(BoardWidgetResizeOp)
|
||||
case widgetRemove(BoardWidgetRemoveOp)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case discriminator = "kind"
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let discriminator = try container.decode(String.self, forKey: .discriminator)
|
||||
switch discriminator {
|
||||
case "tab_create": self = try .tabCreate(BoardTabCreateOp(from: decoder))
|
||||
case "tab_update": self = try .tabUpdate(BoardTabUpdateOp(from: decoder))
|
||||
case "tab_delete": self = try .tabDelete(BoardTabDeleteOp(from: decoder))
|
||||
case "tabs_reorder": self = try .tabsReorder(BoardTabsReorderOp(from: decoder))
|
||||
case "widget_move": self = try .widgetMove(BoardWidgetMoveOp(from: decoder))
|
||||
case "widget_resize": self = try .widgetResize(BoardWidgetResizeOp(from: decoder))
|
||||
case "widget_remove": self = try .widgetRemove(BoardWidgetRemoveOp(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .discriminator,
|
||||
in: container,
|
||||
debugDescription: "Unknown BoardOp discriminator value"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
switch self {
|
||||
case .tabCreate(let value): try value.encode(to: encoder)
|
||||
case .tabUpdate(let value): try value.encode(to: encoder)
|
||||
case .tabDelete(let value): try value.encode(to: encoder)
|
||||
case .tabsReorder(let value): try value.encode(to: encoder)
|
||||
case .widgetMove(let value): try value.encode(to: encoder)
|
||||
case .widgetResize(let value): try value.encode(to: encoder)
|
||||
case .widgetRemove(let value): try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum BoardWidgetContent: Codable, Sendable {
|
||||
case html(BoardWidgetHtmlContent)
|
||||
case mcpApp(BoardWidgetMcpAppContent)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case discriminator = "kind"
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let discriminator = try container.decode(String.self, forKey: .discriminator)
|
||||
switch discriminator {
|
||||
case "html": self = try .html(BoardWidgetHtmlContent(from: decoder))
|
||||
case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppContent(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .discriminator,
|
||||
in: container,
|
||||
debugDescription: "Unknown BoardWidgetContent discriminator value"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
switch self {
|
||||
case .html(let value): try value.encode(to: encoder)
|
||||
case .mcpApp(let value): try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum BoardCommand: Codable, Sendable {
|
||||
case focusTab(BoardFocusTabCommand)
|
||||
case setChatDock(BoardSetChatDockCommand)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case discriminator = "kind"
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let discriminator = try container.decode(String.self, forKey: .discriminator)
|
||||
switch discriminator {
|
||||
case "focus_tab": self = try .focusTab(BoardFocusTabCommand(from: decoder))
|
||||
case "set_chat_dock": self = try .setChatDock(BoardSetChatDockCommand(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .discriminator,
|
||||
in: container,
|
||||
debugDescription: "Unknown BoardCommand discriminator value"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
switch self {
|
||||
case .focusTab(let value): try value.encode(to: encoder)
|
||||
case .setChatDock(let value): try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewaySuspendPrepareResult: Codable, Sendable {
|
||||
case busy(GatewaySuspendPrepareBusyResult)
|
||||
case ready(GatewaySuspendPrepareReadyResult)
|
||||
|
||||
@@ -344,6 +344,12 @@ const config = {
|
||||
// reporting enabled. Suppress them only in this application-production scan.
|
||||
ignoreIssues: {
|
||||
"scripts/**": ["exports", "nsExports", "types", "nsTypes", "enumMembers", "namespaceMembers"],
|
||||
// The full-tree companion config makes tests entrypoints; these contracts
|
||||
// are intentionally test-only in the production graph.
|
||||
"src/boards/board-layout.ts": ["types"],
|
||||
"src/boards/board-notices.ts": ["exports"],
|
||||
"src/boards/board-store.ts": ["exports"],
|
||||
"src/gateway/board-view-ticket.ts": ["exports"],
|
||||
},
|
||||
workspaces: {
|
||||
".": {
|
||||
|
||||
@@ -15,9 +15,15 @@ export * from "./schema/worker-inference.js";
|
||||
export * from "./schema/skill-history.js";
|
||||
export * from "./schema/ui-command.js";
|
||||
export type { GatewayErrorDetails, MissingScopeErrorDetails } from "./schema/error-codes.js";
|
||||
export * from "./schema/board.js";
|
||||
export * from "./migration-api.js";
|
||||
export type * from "./public-session-catalog.js";
|
||||
import {
|
||||
BoardEventParamsSchema,
|
||||
BoardGetParamsSchema,
|
||||
BoardUpdateParamsSchema,
|
||||
BoardWidgetGrantParamsSchema,
|
||||
BoardWidgetPutParamsSchema,
|
||||
AgentEventSchema,
|
||||
AuditActivityAgentRunV1Schema,
|
||||
AuditActivityEventV1Schema,
|
||||
@@ -631,6 +637,11 @@ export const validateAgentWaitParams = lazyCompile(AgentWaitParamsSchema);
|
||||
export const validateWakeParams = lazyCompile(WakeParamsSchema);
|
||||
export const validateAgentsListParams = lazyCompile(AgentsListParamsSchema);
|
||||
export const validateWorktreesListParams = lazyCompile(WorktreesListParamsSchema);
|
||||
export const validateBoardGetParams = lazyCompile(BoardGetParamsSchema);
|
||||
export const validateBoardUpdateParams = lazyCompile(BoardUpdateParamsSchema);
|
||||
export const validateBoardWidgetPutParams = lazyCompile(BoardWidgetPutParamsSchema);
|
||||
export const validateBoardWidgetGrantParams = lazyCompile(BoardWidgetGrantParamsSchema);
|
||||
export const validateBoardEventParams = lazyCompile(BoardEventParamsSchema);
|
||||
export const validateWorktreesCreateParams = lazyCompile(WorktreesCreateParamsSchema);
|
||||
export const validateWorktreesRemoveParams = lazyCompile(WorktreesRemoveParamsSchema);
|
||||
export const validateWorktreesRestoreParams = lazyCompile(WorktreesRestoreParamsSchema);
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from "./schema/artifacts.js";
|
||||
export * from "./schema/approvals.js";
|
||||
export * from "./schema/audit-activity.js";
|
||||
export * from "./schema/audit.js";
|
||||
export * from "./schema/board.js";
|
||||
export * from "./schema/channels.js";
|
||||
export * from "./schema/talk-marks.js";
|
||||
export * from "./schema/commands.js";
|
||||
|
||||
60
packages/gateway-protocol/src/schema/board.test.ts
Normal file
60
packages/gateway-protocol/src/schema/board.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Value } from "typebox/value";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BoardSnapshotSchema, BoardWidgetGrantParamsSchema } from "./board.js";
|
||||
|
||||
describe("BoardSnapshotSchema", () => {
|
||||
it("accepts optional HTML widget view metadata", () => {
|
||||
const snapshot = {
|
||||
sessionKey: "agent:main:main",
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
|
||||
widgets: [
|
||||
{
|
||||
name: "status",
|
||||
tabId: "main",
|
||||
contentKind: "html",
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
declaredSummary: ["Network access: https://example.com"],
|
||||
frameUrl: "/__openclaw__/board/agent%3Amain%3Amain/status/index.html?bt=ticket",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(Value.Check(BoardSnapshotSchema, snapshot)).toBe(true);
|
||||
expect(
|
||||
Value.Check(BoardSnapshotSchema, {
|
||||
...snapshot,
|
||||
widgets: [{ ...snapshot.widgets[0], frameUrl: 42 }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(BoardSnapshotSchema, {
|
||||
...snapshot,
|
||||
widgets: [{ ...snapshot.widgets[0], declaredSummary: [42] }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BoardWidgetGrantParamsSchema", () => {
|
||||
it("requires the widget revision being approved", () => {
|
||||
expect(
|
||||
Value.Check(BoardWidgetGrantParamsSchema, {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "status",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(BoardWidgetGrantParamsSchema, {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "status",
|
||||
decision: "granted",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
202
packages/gateway-protocol/src/schema/board.ts
Normal file
202
packages/gateway-protocol/src/schema/board.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { Static } from "typebox";
|
||||
import { Type } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
export const BoardTabIdSchema = Type.String({ pattern: "^[a-z0-9-]{1,40}$" });
|
||||
export const BoardWidgetNameSchema = Type.String({
|
||||
pattern: "^[a-z0-9][a-z0-9._-]{0,63}$",
|
||||
});
|
||||
export const BoardChatDockSchema = Type.Union([
|
||||
Type.Literal("left"),
|
||||
Type.Literal("right"),
|
||||
Type.Literal("bottom"),
|
||||
Type.Literal("hidden"),
|
||||
]);
|
||||
export const BoardSizeSchema = Type.Union([
|
||||
Type.Literal("sm"),
|
||||
Type.Literal("md"),
|
||||
Type.Literal("lg"),
|
||||
Type.Literal("xl"),
|
||||
Type.Literal("full"),
|
||||
]);
|
||||
|
||||
export const BoardTabSchema = closedObject({
|
||||
tabId: BoardTabIdSchema,
|
||||
title: Type.String({ minLength: 1, maxLength: 80 }),
|
||||
position: Type.Integer({ minimum: 0 }),
|
||||
chatDock: BoardChatDockSchema,
|
||||
});
|
||||
export type BoardTab = Static<typeof BoardTabSchema>;
|
||||
|
||||
export const BoardWidgetSchema = closedObject({
|
||||
name: BoardWidgetNameSchema,
|
||||
tabId: BoardTabIdSchema,
|
||||
title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
|
||||
contentKind: Type.Union([Type.Literal("html"), Type.Literal("mcp-app")]),
|
||||
sizeW: Type.Integer({ minimum: 1, maximum: 12 }),
|
||||
sizeH: Type.Integer({ minimum: 1, maximum: 20 }),
|
||||
position: Type.Integer({ minimum: 0 }),
|
||||
grantState: Type.Union([
|
||||
Type.Literal("none"),
|
||||
Type.Literal("pending"),
|
||||
Type.Literal("granted"),
|
||||
Type.Literal("rejected"),
|
||||
]),
|
||||
revision: Type.Integer({ minimum: 1 }),
|
||||
declaredSummary: Type.Optional(Type.Array(Type.String())),
|
||||
frameUrl: Type.Optional(Type.String()),
|
||||
});
|
||||
export type BoardWidget = Static<typeof BoardWidgetSchema>;
|
||||
|
||||
export const BoardSnapshotSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
revision: Type.Integer({ minimum: 0 }),
|
||||
tabs: Type.Array(BoardTabSchema),
|
||||
widgets: Type.Array(BoardWidgetSchema),
|
||||
});
|
||||
export type BoardSnapshot = Static<typeof BoardSnapshotSchema>;
|
||||
|
||||
export const BoardTabCreateOpSchema = closedObject({
|
||||
kind: Type.Literal("tab_create"),
|
||||
tabId: BoardTabIdSchema,
|
||||
title: Type.String({ minLength: 1, maxLength: 80 }),
|
||||
chatDock: Type.Optional(BoardChatDockSchema),
|
||||
});
|
||||
export const BoardTabUpdateOpSchema = closedObject({
|
||||
kind: Type.Literal("tab_update"),
|
||||
tabId: BoardTabIdSchema,
|
||||
title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
|
||||
chatDock: Type.Optional(BoardChatDockSchema),
|
||||
position: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
});
|
||||
export const BoardTabDeleteOpSchema = closedObject({
|
||||
kind: Type.Literal("tab_delete"),
|
||||
tabId: BoardTabIdSchema,
|
||||
});
|
||||
export const BoardTabsReorderOpSchema = closedObject({
|
||||
kind: Type.Literal("tabs_reorder"),
|
||||
tabIds: Type.Array(BoardTabIdSchema),
|
||||
});
|
||||
export const BoardWidgetMoveOpSchema = closedObject({
|
||||
kind: Type.Literal("widget_move"),
|
||||
name: BoardWidgetNameSchema,
|
||||
tabId: Type.Optional(BoardTabIdSchema),
|
||||
position: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
after: Type.Optional(BoardWidgetNameSchema),
|
||||
});
|
||||
export const BoardWidgetResizeOpSchema = closedObject({
|
||||
kind: Type.Literal("widget_resize"),
|
||||
name: BoardWidgetNameSchema,
|
||||
sizeW: Type.Integer(),
|
||||
sizeH: Type.Integer(),
|
||||
});
|
||||
export const BoardWidgetRemoveOpSchema = closedObject({
|
||||
kind: Type.Literal("widget_remove"),
|
||||
name: BoardWidgetNameSchema,
|
||||
});
|
||||
export const BoardOpSchema = Type.Union([
|
||||
BoardTabCreateOpSchema,
|
||||
BoardTabUpdateOpSchema,
|
||||
BoardTabDeleteOpSchema,
|
||||
BoardTabsReorderOpSchema,
|
||||
BoardWidgetMoveOpSchema,
|
||||
BoardWidgetResizeOpSchema,
|
||||
BoardWidgetRemoveOpSchema,
|
||||
]);
|
||||
export type BoardOp = Static<typeof BoardOpSchema>;
|
||||
|
||||
export const BoardGetParamsSchema = closedObject({ sessionKey: NonEmptyString });
|
||||
export type BoardGetParams = Static<typeof BoardGetParamsSchema>;
|
||||
|
||||
export const BoardUpdateParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
ops: Type.Array(BoardOpSchema),
|
||||
});
|
||||
export type BoardUpdateParams = Static<typeof BoardUpdateParamsSchema>;
|
||||
|
||||
export const BoardMcpAppDescriptorSchema = closedObject({
|
||||
serverName: NonEmptyString,
|
||||
toolName: NonEmptyString,
|
||||
uiResourceUri: NonEmptyString,
|
||||
originSessionKey: NonEmptyString,
|
||||
toolCallId: NonEmptyString,
|
||||
});
|
||||
export type BoardMcpAppDescriptor = Static<typeof BoardMcpAppDescriptorSchema>;
|
||||
|
||||
export const BoardWidgetHtmlContentSchema = closedObject({
|
||||
kind: Type.Literal("html"),
|
||||
html: Type.String({ maxLength: 262_144 }),
|
||||
});
|
||||
export const BoardWidgetMcpAppContentSchema = closedObject({
|
||||
kind: Type.Literal("mcp-app"),
|
||||
descriptor: BoardMcpAppDescriptorSchema,
|
||||
});
|
||||
export const BoardWidgetContentSchema = Type.Union([
|
||||
BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppContentSchema,
|
||||
]);
|
||||
export type BoardWidgetContent = Static<typeof BoardWidgetContentSchema>;
|
||||
|
||||
export const BoardWidgetPutParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
name: BoardWidgetNameSchema,
|
||||
title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
|
||||
content: BoardWidgetContentSchema,
|
||||
placement: Type.Optional(
|
||||
closedObject({
|
||||
tabId: Type.Optional(BoardTabIdSchema),
|
||||
size: Type.Optional(BoardSizeSchema),
|
||||
after: Type.Optional(BoardWidgetNameSchema),
|
||||
}),
|
||||
),
|
||||
declared: Type.Optional(
|
||||
closedObject({
|
||||
netOrigins: Type.Optional(Type.Array(Type.String())),
|
||||
tools: Type.Optional(Type.Array(Type.String())),
|
||||
}),
|
||||
),
|
||||
});
|
||||
export type BoardWidgetPutParams = Static<typeof BoardWidgetPutParamsSchema>;
|
||||
|
||||
export const BoardWidgetGrantParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
name: BoardWidgetNameSchema,
|
||||
decision: Type.Union([Type.Literal("granted"), Type.Literal("rejected")]),
|
||||
revision: Type.Integer({ minimum: 1 }),
|
||||
});
|
||||
export type BoardWidgetGrantParams = Static<typeof BoardWidgetGrantParamsSchema>;
|
||||
|
||||
export const BoardEventParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
widget: BoardWidgetNameSchema,
|
||||
payload: Type.Unknown(),
|
||||
});
|
||||
export type BoardEventParams = Static<typeof BoardEventParamsSchema>;
|
||||
|
||||
export const BoardChangedEventSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
revision: Type.Integer({ minimum: 0 }),
|
||||
widget: Type.Optional(BoardWidgetNameSchema),
|
||||
});
|
||||
export type BoardChangedEvent = Static<typeof BoardChangedEventSchema>;
|
||||
|
||||
export const BoardFocusTabCommandSchema = closedObject({
|
||||
kind: Type.Literal("focus_tab"),
|
||||
tabId: BoardTabIdSchema,
|
||||
});
|
||||
export const BoardSetChatDockCommandSchema = closedObject({
|
||||
kind: Type.Literal("set_chat_dock"),
|
||||
dock: BoardChatDockSchema,
|
||||
});
|
||||
export const BoardCommandSchema = Type.Union([
|
||||
BoardFocusTabCommandSchema,
|
||||
BoardSetChatDockCommandSchema,
|
||||
]);
|
||||
export type BoardCommand = Static<typeof BoardCommandSchema>;
|
||||
|
||||
export const BoardCommandEventSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
command: BoardCommandSchema,
|
||||
});
|
||||
export type BoardCommandEvent = Static<typeof BoardCommandEventSchema>;
|
||||
@@ -151,6 +151,33 @@ import {
|
||||
AuditActivityToolActionV1Schema,
|
||||
} from "./audit-activity.js";
|
||||
import { AuditEventSchema, AuditListParamsSchema, AuditListResultSchema } from "./audit.js";
|
||||
import {
|
||||
BoardChangedEventSchema,
|
||||
BoardCommandEventSchema,
|
||||
BoardCommandSchema,
|
||||
BoardEventParamsSchema,
|
||||
BoardFocusTabCommandSchema,
|
||||
BoardGetParamsSchema,
|
||||
BoardMcpAppDescriptorSchema,
|
||||
BoardOpSchema,
|
||||
BoardSetChatDockCommandSchema,
|
||||
BoardSnapshotSchema,
|
||||
BoardTabCreateOpSchema,
|
||||
BoardTabDeleteOpSchema,
|
||||
BoardTabSchema,
|
||||
BoardTabsReorderOpSchema,
|
||||
BoardTabUpdateOpSchema,
|
||||
BoardUpdateParamsSchema,
|
||||
BoardWidgetContentSchema,
|
||||
BoardWidgetGrantParamsSchema,
|
||||
BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppContentSchema,
|
||||
BoardWidgetMoveOpSchema,
|
||||
BoardWidgetPutParamsSchema,
|
||||
BoardWidgetRemoveOpSchema,
|
||||
BoardWidgetResizeOpSchema,
|
||||
BoardWidgetSchema,
|
||||
} from "./board.js";
|
||||
import {
|
||||
ChannelsStartParamsSchema,
|
||||
ChannelsStopParamsSchema,
|
||||
@@ -540,6 +567,31 @@ import {
|
||||
|
||||
/** Public schema registry keyed by stable protocol schema name. */
|
||||
export const ProtocolSchemas = {
|
||||
BoardTab: BoardTabSchema,
|
||||
BoardWidget: BoardWidgetSchema,
|
||||
BoardSnapshot: BoardSnapshotSchema,
|
||||
BoardTabCreateOp: BoardTabCreateOpSchema,
|
||||
BoardTabUpdateOp: BoardTabUpdateOpSchema,
|
||||
BoardTabDeleteOp: BoardTabDeleteOpSchema,
|
||||
BoardTabsReorderOp: BoardTabsReorderOpSchema,
|
||||
BoardWidgetMoveOp: BoardWidgetMoveOpSchema,
|
||||
BoardWidgetResizeOp: BoardWidgetResizeOpSchema,
|
||||
BoardWidgetRemoveOp: BoardWidgetRemoveOpSchema,
|
||||
BoardOp: BoardOpSchema,
|
||||
BoardMcpAppDescriptor: BoardMcpAppDescriptorSchema,
|
||||
BoardWidgetHtmlContent: BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppContent: BoardWidgetMcpAppContentSchema,
|
||||
BoardWidgetContent: BoardWidgetContentSchema,
|
||||
BoardGetParams: BoardGetParamsSchema,
|
||||
BoardUpdateParams: BoardUpdateParamsSchema,
|
||||
BoardWidgetPutParams: BoardWidgetPutParamsSchema,
|
||||
BoardWidgetGrantParams: BoardWidgetGrantParamsSchema,
|
||||
BoardEventParams: BoardEventParamsSchema,
|
||||
BoardChangedEvent: BoardChangedEventSchema,
|
||||
BoardFocusTabCommand: BoardFocusTabCommandSchema,
|
||||
BoardSetChatDockCommand: BoardSetChatDockCommandSchema,
|
||||
BoardCommand: BoardCommandSchema,
|
||||
BoardCommandEvent: BoardCommandEventSchema,
|
||||
AuthProbeStatus: AuthProbeStatusSchema,
|
||||
// Handshake, transport frames, state snapshots, and shared error envelopes.
|
||||
ConnectParams: ConnectParamsSchema,
|
||||
|
||||
@@ -24,6 +24,7 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [
|
||||
{ name: "conversations_send", family: "openclaw" },
|
||||
{ name: "conversations_turn", family: "openclaw" },
|
||||
{ name: "cron", family: "openclaw" },
|
||||
{ name: "dashboard", family: "openclaw" },
|
||||
{ name: "gateway", family: "openclaw" },
|
||||
{ name: "get_goal", family: "openclaw" },
|
||||
{ name: "heartbeat_respond", family: "openclaw" },
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
createConversationsTurnTool,
|
||||
} from "./tools/conversation-tools.js";
|
||||
import { createCronTool, type CronCreatorToolAllowlistEntry } from "./tools/cron-tool.js";
|
||||
import { createDashboardTool } from "./tools/dashboard-tool.js";
|
||||
import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js";
|
||||
import { createGatewayToolCallerWrapper } from "./tools/gateway-caller-context.js";
|
||||
import { createGatewayTool } from "./tools/gateway-tool.js";
|
||||
@@ -472,6 +473,9 @@ export function createOpenClawTools(
|
||||
});
|
||||
const includeTranscriptsTool = resolveTranscriptsConfig(resolvedConfig?.transcripts).enabled;
|
||||
const tools: AnyAgentTool[] = [
|
||||
createDashboardTool({
|
||||
agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey,
|
||||
}),
|
||||
...(embedded
|
||||
? []
|
||||
: [
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("tool-catalog", () => {
|
||||
"spawn_task",
|
||||
"dismiss_task",
|
||||
"screen",
|
||||
"dashboard",
|
||||
"terminal",
|
||||
"cron",
|
||||
"get_goal",
|
||||
|
||||
@@ -280,6 +280,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "dashboard",
|
||||
label: "dashboard",
|
||||
description: "Read and arrange the session dashboard",
|
||||
sectionId: "ui",
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
label: "terminal",
|
||||
|
||||
@@ -196,6 +196,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
emoji: "📋",
|
||||
title: "Dashboard",
|
||||
detailKeys: ["action", "tabId", "name", "title"],
|
||||
},
|
||||
nodes: {
|
||||
emoji: "📱",
|
||||
title: "Nodes",
|
||||
|
||||
140
src/agents/tools/dashboard-tool.test.ts
Normal file
140
src/agents/tools/dashboard-tool.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Value } from "typebox/value";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { BoardCommand, BoardSnapshot } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { createDashboardTool } from "./dashboard-tool.js";
|
||||
import type { InProcessGatewayCaller } from "./in-process-gateway.js";
|
||||
|
||||
const snapshot: BoardSnapshot = {
|
||||
sessionKey: "agent:main:main",
|
||||
revision: 3,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
|
||||
widgets: [],
|
||||
};
|
||||
|
||||
function recorder() {
|
||||
const calls: Array<[string, Record<string, unknown>]> = [];
|
||||
const commands: Array<{ sessionKey: string; command: BoardCommand }> = [];
|
||||
const callGateway: InProcessGatewayCaller = async <T>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
calls.push([method, params]);
|
||||
return snapshot as T;
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
commands,
|
||||
callGateway,
|
||||
emitCommand: (command: { sessionKey: string; command: BoardCommand }) => {
|
||||
commands.push(command);
|
||||
return 2;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("dashboard tool", () => {
|
||||
it("declares every action, no client capability guard, and stable-name/size guidance", () => {
|
||||
const tool = createDashboardTool();
|
||||
expect(tool.requiredClientCaps).toBeUndefined();
|
||||
expect(tool.description).toContain("stable names");
|
||||
expect(tool.description).toContain("sm=3x3");
|
||||
expect(tool.parameters).toMatchObject({
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: {
|
||||
enum: [
|
||||
"read",
|
||||
"tab_create",
|
||||
"tab_update",
|
||||
"tab_delete",
|
||||
"tabs_reorder",
|
||||
"widget_move",
|
||||
"widget_resize",
|
||||
"widget_remove",
|
||||
"focus_tab",
|
||||
"set_chat_dock",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(Value.Check(tool.parameters, { action: "widget_move", name: "status" })).toBe(true);
|
||||
expect(Value.Check(tool.parameters, { action: "unknown" })).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a compact text plus JSON snapshot", async () => {
|
||||
const harness = recorder();
|
||||
const tool = createDashboardTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
callGateway: harness.callGateway,
|
||||
});
|
||||
const result = await tool.execute("read", { action: "read" });
|
||||
expect(harness.calls).toEqual([["board.get", { sessionKey: "agent:main:main" }]]);
|
||||
expect(result.details).toEqual(snapshot);
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining('"revision":3'),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects protocol-invalid focus tab ids before broadcasting", async () => {
|
||||
const harness = recorder();
|
||||
const tool = createDashboardTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
emitCommand: harness.emitCommand,
|
||||
});
|
||||
await expect(
|
||||
tool.execute("focus", { action: "focus_tab", tabId: "Invalid Tab" }),
|
||||
).rejects.toThrow("lowercase slug");
|
||||
expect(harness.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"tab_create",
|
||||
{ tabId: "notes", title: "Notes", chatDock: "bottom" },
|
||||
{ kind: "tab_create", tabId: "notes", title: "Notes", chatDock: "bottom" },
|
||||
],
|
||||
[
|
||||
"tab_update",
|
||||
{ tabId: "notes", title: "New", position: 0 },
|
||||
{ kind: "tab_update", tabId: "notes", title: "New", position: 0 },
|
||||
],
|
||||
["tab_delete", { tabId: "notes" }, { kind: "tab_delete", tabId: "notes" }],
|
||||
["tabs_reorder", { tabIds: ["two", "one"] }, { kind: "tabs_reorder", tabIds: ["two", "one"] }],
|
||||
[
|
||||
"widget_move",
|
||||
{ name: "status", tabId: "notes", after: "clock" },
|
||||
{ kind: "widget_move", name: "status", tabId: "notes", after: "clock" },
|
||||
],
|
||||
[
|
||||
"widget_resize",
|
||||
{ name: "status", sizeW: 8, sizeH: 6 },
|
||||
{ kind: "widget_resize", name: "status", sizeW: 8, sizeH: 6 },
|
||||
],
|
||||
["widget_remove", { name: "status" }, { kind: "widget_remove", name: "status" }],
|
||||
])("maps %s to one board.update op", async (action, args, op) => {
|
||||
const harness = recorder();
|
||||
const tool = createDashboardTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
callGateway: harness.callGateway,
|
||||
});
|
||||
await tool.execute("mutate", { action, ...args });
|
||||
expect(harness.calls).toEqual([["board.update", { sessionKey: "agent:main:main", ops: [op] }]]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["focus_tab", { tabId: "notes" }, { kind: "focus_tab", tabId: "notes" }],
|
||||
["set_chat_dock", { dock: "left" }, { kind: "set_chat_dock", dock: "left" }],
|
||||
])("emits board.command for %s", async (action, args, command) => {
|
||||
const harness = recorder();
|
||||
const tool = createDashboardTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
callGateway: harness.callGateway,
|
||||
emitCommand: harness.emitCommand,
|
||||
});
|
||||
const result = await tool.execute("command", { action, ...args });
|
||||
expect(harness.calls).toEqual([]);
|
||||
expect(harness.commands).toEqual([{ sessionKey: "agent:main:main", command }]);
|
||||
expect(result.details).toEqual({ ok: true, delivered: 2 });
|
||||
});
|
||||
});
|
||||
257
src/agents/tools/dashboard-tool.ts
Normal file
257
src/agents/tools/dashboard-tool.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { Type } from "typebox";
|
||||
import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js";
|
||||
import type {
|
||||
BoardCommand,
|
||||
BoardOp,
|
||||
BoardSnapshot,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import {
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
textResult,
|
||||
ToolInputError,
|
||||
} from "./common.js";
|
||||
import {
|
||||
callInProcessGatewayTool,
|
||||
getInProcessGatewayToolContext,
|
||||
type InProcessGatewayCaller,
|
||||
} from "./in-process-gateway.js";
|
||||
|
||||
const DASHBOARD_ACTIONS = [
|
||||
"read",
|
||||
"tab_create",
|
||||
"tab_update",
|
||||
"tab_delete",
|
||||
"tabs_reorder",
|
||||
"widget_move",
|
||||
"widget_resize",
|
||||
"widget_remove",
|
||||
"focus_tab",
|
||||
"set_chat_dock",
|
||||
] as const;
|
||||
const BOARD_TAB_ID_PATTERN = "^[a-z0-9-]{1,40}$";
|
||||
const BOARD_TAB_ID_REGEX = /^[a-z0-9-]{1,40}$/;
|
||||
const BOARD_WIDGET_NAME_PATTERN = "^[a-z0-9][a-z0-9._-]{0,63}$";
|
||||
|
||||
const DashboardToolSchema = Type.Object(
|
||||
{
|
||||
action: Type.String({ enum: [...DASHBOARD_ACTIONS], description: "Dashboard action" }),
|
||||
tabId: Type.Optional(
|
||||
Type.String({ pattern: BOARD_TAB_ID_PATTERN, description: "Stable tab slug" }),
|
||||
),
|
||||
title: Type.Optional(Type.String({ minLength: 1, maxLength: 80, description: "Tab title" })),
|
||||
chatDock: Type.Optional(
|
||||
Type.String({ enum: ["left", "right", "bottom", "hidden"], description: "Chat dock" }),
|
||||
),
|
||||
dock: Type.Optional(
|
||||
Type.String({ enum: ["left", "right", "bottom", "hidden"], description: "Chat dock" }),
|
||||
),
|
||||
position: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based position" })),
|
||||
tabIds: Type.Optional(
|
||||
Type.Array(Type.String({ pattern: BOARD_TAB_ID_PATTERN }), {
|
||||
description: "Complete tab order",
|
||||
}),
|
||||
),
|
||||
name: Type.Optional(
|
||||
Type.String({ pattern: BOARD_WIDGET_NAME_PATTERN, description: "Stable widget name" }),
|
||||
),
|
||||
after: Type.Optional(
|
||||
Type.String({
|
||||
pattern: BOARD_WIDGET_NAME_PATTERN,
|
||||
description: "Place after stable widget name",
|
||||
}),
|
||||
),
|
||||
sizeW: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
|
||||
sizeH: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
type DashboardCommandEmitter = (params: { sessionKey: string; command: BoardCommand }) => number;
|
||||
|
||||
type DashboardGatewayContext = {
|
||||
getClientConnIds?: (
|
||||
predicate: (client: { connect: { client: { id: string } } }) => boolean,
|
||||
) => Set<string>;
|
||||
broadcastToConnIds: (event: "board.command", payload: unknown, connIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
type DashboardToolOptions = {
|
||||
agentSessionKey?: string;
|
||||
callGateway?: InProcessGatewayCaller;
|
||||
emitCommand?: DashboardCommandEmitter;
|
||||
};
|
||||
|
||||
function requireSessionKey(value: string | undefined): string {
|
||||
const sessionKey = value?.trim();
|
||||
if (!sessionKey) {
|
||||
throw new ToolInputError("agent session required");
|
||||
}
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
function readDock(
|
||||
params: Record<string, unknown>,
|
||||
key: "chatDock" | "dock",
|
||||
): "left" | "right" | "bottom" | "hidden" | undefined {
|
||||
const value = readStringParam(params, key);
|
||||
if (
|
||||
value === undefined ||
|
||||
value === "left" ||
|
||||
value === "right" ||
|
||||
value === "bottom" ||
|
||||
value === "hidden"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
throw new ToolInputError(`${key} must be left, right, bottom, or hidden`);
|
||||
}
|
||||
|
||||
function requireInteger(params: Record<string, unknown>, key: string): number {
|
||||
const value = readNumberParam(params, key, { required: true, integer: true, strict: true });
|
||||
if (value === undefined) {
|
||||
throw new ToolInputError(`${key} required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readTabId(params: Record<string, unknown>): string {
|
||||
const tabId = readStringParam(params, "tabId", { required: true });
|
||||
if (!BOARD_TAB_ID_REGEX.test(tabId)) {
|
||||
throw new ToolInputError("tabId must be a lowercase slug up to 40 characters");
|
||||
}
|
||||
return tabId;
|
||||
}
|
||||
|
||||
function opForAction(action: string, params: Record<string, unknown>): BoardOp {
|
||||
const name = () => readStringParam(params, "name", { required: true });
|
||||
switch (action) {
|
||||
case "tab_create":
|
||||
return {
|
||||
kind: "tab_create",
|
||||
tabId: readTabId(params),
|
||||
title: readStringParam(params, "title", { required: true }),
|
||||
...(readDock(params, "chatDock") ? { chatDock: readDock(params, "chatDock") } : {}),
|
||||
};
|
||||
case "tab_update": {
|
||||
const title = readStringParam(params, "title");
|
||||
const chatDock = readDock(params, "chatDock");
|
||||
const position = readNumberParam(params, "position", { integer: true, strict: true });
|
||||
if (title === undefined && chatDock === undefined && position === undefined) {
|
||||
throw new ToolInputError("tab_update requires title, chatDock, or position");
|
||||
}
|
||||
return {
|
||||
kind: "tab_update",
|
||||
tabId: readTabId(params),
|
||||
...(title !== undefined ? { title } : {}),
|
||||
...(chatDock !== undefined ? { chatDock } : {}),
|
||||
...(position !== undefined ? { position } : {}),
|
||||
};
|
||||
}
|
||||
case "tab_delete":
|
||||
return { kind: "tab_delete", tabId: readTabId(params) };
|
||||
case "tabs_reorder":
|
||||
return {
|
||||
kind: "tabs_reorder",
|
||||
tabIds: readStringArrayParam(params, "tabIds", { required: true }),
|
||||
};
|
||||
case "widget_move": {
|
||||
const targetTabId = readStringParam(params, "tabId");
|
||||
const position = readNumberParam(params, "position", { integer: true, strict: true });
|
||||
const after = readStringParam(params, "after");
|
||||
if (position !== undefined && after !== undefined) {
|
||||
throw new ToolInputError("widget_move accepts either position or after, not both");
|
||||
}
|
||||
return {
|
||||
kind: "widget_move",
|
||||
name: name(),
|
||||
...(targetTabId !== undefined ? { tabId: targetTabId } : {}),
|
||||
...(position !== undefined ? { position } : {}),
|
||||
...(after !== undefined ? { after } : {}),
|
||||
};
|
||||
}
|
||||
case "widget_resize":
|
||||
return {
|
||||
kind: "widget_resize",
|
||||
name: name(),
|
||||
sizeW: requireInteger(params, "sizeW"),
|
||||
sizeH: requireInteger(params, "sizeH"),
|
||||
};
|
||||
case "widget_remove":
|
||||
return { kind: "widget_remove", name: name() };
|
||||
default:
|
||||
throw new ToolInputError(`Unknown dashboard action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
function emitBoardCommand(params: { sessionKey: string; command: BoardCommand }): number {
|
||||
const context = getInProcessGatewayToolContext() as DashboardGatewayContext | undefined;
|
||||
if (!context) {
|
||||
throw new ToolInputError("dashboard command unavailable outside gateway runtime");
|
||||
}
|
||||
const connIds =
|
||||
context.getClientConnIds?.(
|
||||
(client) => client.connect.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI,
|
||||
) ?? new Set<string>();
|
||||
context.broadcastToConnIds("board.command", params, connIds);
|
||||
return connIds.size;
|
||||
}
|
||||
|
||||
function snapshotResult(snapshot: BoardSnapshot) {
|
||||
return textResult(
|
||||
`Dashboard revision ${snapshot.revision}: ${snapshot.tabs.length} tabs, ${snapshot.widgets.length} widgets\n${JSON.stringify(snapshot)}`,
|
||||
snapshot,
|
||||
);
|
||||
}
|
||||
|
||||
export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTool {
|
||||
const gatewayCall = opts.callGateway ?? callInProcessGatewayTool;
|
||||
const emitCommand = opts.emitCommand ?? emitBoardCommand;
|
||||
return {
|
||||
label: "Dashboard",
|
||||
name: "dashboard",
|
||||
description:
|
||||
"Read and arrange this session dashboard. Widgets use stable names. Sizes: sm=3x3, md=6x4, lg=8x6, xl=12x8, full=12x8 single-widget emphasis.",
|
||||
parameters: DashboardToolSchema,
|
||||
execute: async (_toolCallId, rawArgs) => {
|
||||
const params = rawArgs as Record<string, unknown>;
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const sessionKey = requireSessionKey(opts.agentSessionKey);
|
||||
if (action === "read") {
|
||||
return snapshotResult(await gatewayCall<BoardSnapshot>("board.get", { sessionKey }));
|
||||
}
|
||||
if (action === "focus_tab") {
|
||||
const delivered = emitCommand({
|
||||
sessionKey,
|
||||
command: {
|
||||
kind: "focus_tab",
|
||||
tabId: readTabId(params),
|
||||
},
|
||||
});
|
||||
return textResult(`Dashboard command sent to ${delivered} client(s)`, {
|
||||
ok: true,
|
||||
delivered,
|
||||
});
|
||||
}
|
||||
if (action === "set_chat_dock") {
|
||||
const dock = readDock(params, "dock");
|
||||
if (!dock) {
|
||||
throw new ToolInputError("dock required");
|
||||
}
|
||||
const delivered = emitCommand({ sessionKey, command: { kind: "set_chat_dock", dock } });
|
||||
return textResult(`Dashboard command sent to ${delivered} client(s)`, {
|
||||
ok: true,
|
||||
delivered,
|
||||
});
|
||||
}
|
||||
return snapshotResult(
|
||||
await gatewayCall<BoardSnapshot>("board.update", {
|
||||
sessionKey,
|
||||
ops: [opForAction(action, params)],
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
121
src/boards/board-layout.test.ts
Normal file
121
src/boards/board-layout.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { BoardLayout } from "./board-layout.js";
|
||||
import { applyBoardOps, BoardValidationError, normalizeBoardLayout } from "./board-layout.js";
|
||||
|
||||
function layout(): BoardLayout {
|
||||
return {
|
||||
tabs: [
|
||||
{ tabId: "one", title: "One", position: 4, chatDock: "right" },
|
||||
{ tabId: "two", title: "Two", position: 8, chatDock: "bottom" },
|
||||
],
|
||||
widgets: [
|
||||
{
|
||||
name: "alpha",
|
||||
tabId: "one",
|
||||
contentKind: "html",
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 3,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
},
|
||||
{
|
||||
name: "beta",
|
||||
tabId: "one",
|
||||
contentKind: "html",
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 9,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
},
|
||||
{
|
||||
name: "gamma",
|
||||
tabId: "two",
|
||||
contentKind: "html",
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 2,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function widgetOrder(state: BoardLayout, tabId: string): string[] {
|
||||
return state.widgets
|
||||
.filter((widget) => widget.tabId === tabId)
|
||||
.toSorted((a, b) => a.position - b.position)
|
||||
.map((widget) => widget.name);
|
||||
}
|
||||
|
||||
describe("board layout", () => {
|
||||
it("normalizes tab and per-tab widget positions without holes", () => {
|
||||
const normalized = normalizeBoardLayout(layout());
|
||||
expect(normalized.tabs.map((tab) => tab.position)).toEqual([0, 1]);
|
||||
expect(
|
||||
normalized.widgets
|
||||
.filter((widget) => widget.tabId === "one")
|
||||
.map((widget) => widget.position),
|
||||
).toEqual([0, 1]);
|
||||
expect(normalized.widgets.find((widget) => widget.name === "gamma")?.position).toBe(0);
|
||||
});
|
||||
|
||||
it("reorders widgets by position and after anchors, including cross-tab moves", () => {
|
||||
const reordered = applyBoardOps(layout(), [
|
||||
{ kind: "widget_move", name: "beta", position: 0 },
|
||||
{ kind: "widget_move", name: "gamma", tabId: "one", after: "beta" },
|
||||
]);
|
||||
expect(widgetOrder(reordered, "one")).toEqual(["beta", "gamma", "alpha"]);
|
||||
expect(widgetOrder(reordered, "two")).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects missing and self after anchors", () => {
|
||||
expect(() =>
|
||||
applyBoardOps(layout(), [{ kind: "widget_move", name: "alpha", after: "missing" }]),
|
||||
).toThrow(BoardValidationError);
|
||||
expect(() =>
|
||||
applyBoardOps(layout(), [{ kind: "widget_move", name: "alpha", after: "alpha" }]),
|
||||
).toThrow("after itself");
|
||||
});
|
||||
|
||||
it("clamps widget sizes to the grid bounds", () => {
|
||||
const resized = applyBoardOps(layout(), [
|
||||
{ kind: "widget_resize", name: "alpha", sizeW: 99, sizeH: -5 },
|
||||
]);
|
||||
const widget = resized.widgets.find((candidate) => candidate.name === "alpha");
|
||||
expect(widget).toMatchObject({ sizeW: 12, sizeH: 1 });
|
||||
});
|
||||
|
||||
it("moves deleted-tab widgets to the first remaining tab", () => {
|
||||
const next = applyBoardOps(layout(), [{ kind: "tab_delete", tabId: "one" }]);
|
||||
expect(next.tabs.map((tab) => tab.tabId)).toEqual(["two"]);
|
||||
expect(widgetOrder(next, "two")).toEqual(["gamma", "alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("allows deleting the last empty tab but rejects deleting it with widgets", () => {
|
||||
expect(
|
||||
applyBoardOps(
|
||||
{ tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }], widgets: [] },
|
||||
[{ kind: "tab_delete", tabId: "main" }],
|
||||
).tabs,
|
||||
).toEqual([]);
|
||||
expect(() =>
|
||||
applyBoardOps({ ...layout(), tabs: [layout().tabs[0]!], widgets: [layout().widgets[0]!] }, [
|
||||
{ kind: "tab_delete", tabId: "one" },
|
||||
]),
|
||||
).toThrow("last board tab");
|
||||
});
|
||||
|
||||
it("requires tabs_reorder to be an exact permutation", () => {
|
||||
expect(
|
||||
applyBoardOps(layout(), [{ kind: "tabs_reorder", tabIds: ["two", "one"] }]).tabs.map(
|
||||
(tab) => tab.tabId,
|
||||
),
|
||||
).toEqual(["two", "one"]);
|
||||
expect(() =>
|
||||
applyBoardOps(layout(), [{ kind: "tabs_reorder", tabIds: ["one", "one"] }]),
|
||||
).toThrow("every tab exactly once");
|
||||
});
|
||||
});
|
||||
285
src/boards/board-layout.ts
Normal file
285
src/boards/board-layout.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type {
|
||||
BoardOp,
|
||||
BoardSnapshot,
|
||||
BoardTab,
|
||||
BoardWidget,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
export const BOARD_SIZE_PRESETS = {
|
||||
sm: { sizeW: 3, sizeH: 3 },
|
||||
md: { sizeW: 6, sizeH: 4 },
|
||||
lg: { sizeW: 8, sizeH: 6 },
|
||||
xl: { sizeW: 12, sizeH: 8 },
|
||||
full: { sizeW: 12, sizeH: 8 },
|
||||
} as const;
|
||||
|
||||
export type BoardSize = keyof typeof BOARD_SIZE_PRESETS;
|
||||
export type BoardLayout = Pick<BoardSnapshot, "tabs" | "widgets">;
|
||||
type BoardValidationErrorCode = "conflict" | "invalid_operation" | "not_found";
|
||||
|
||||
export class BoardValidationError extends Error {
|
||||
readonly code: BoardValidationErrorCode;
|
||||
|
||||
constructor(code: BoardValidationErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "BoardValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneTab(tab: BoardTab): BoardTab {
|
||||
return {
|
||||
tabId: tab.tabId,
|
||||
title: tab.title,
|
||||
position: tab.position,
|
||||
chatDock: tab.chatDock,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneWidget(widget: BoardWidget): BoardWidget {
|
||||
return {
|
||||
name: widget.name,
|
||||
tabId: widget.tabId,
|
||||
...(widget.title !== undefined ? { title: widget.title } : {}),
|
||||
contentKind: widget.contentKind,
|
||||
sizeW: widget.sizeW,
|
||||
sizeH: widget.sizeH,
|
||||
position: widget.position,
|
||||
grantState: widget.grantState,
|
||||
revision: widget.revision,
|
||||
...(widget.declaredSummary !== undefined
|
||||
? { declaredSummary: [...widget.declaredSummary] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneLayout(layout: BoardLayout): BoardLayout {
|
||||
return {
|
||||
tabs: layout.tabs.map(cloneTab),
|
||||
widgets: layout.widgets.map(cloneWidget),
|
||||
};
|
||||
}
|
||||
|
||||
function clampInteger(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
|
||||
function comparePosition(a: { position: number }, b: { position: number }): number {
|
||||
return a.position - b.position;
|
||||
}
|
||||
|
||||
export function normalizeBoardLayout(layout: BoardLayout): BoardLayout {
|
||||
const tabs = layout.tabs.toSorted(comparePosition).map((tab, position) => {
|
||||
const next = cloneTab(tab);
|
||||
next.position = position;
|
||||
return next;
|
||||
});
|
||||
const tabPosition = new Map(tabs.map((tab) => [tab.tabId, tab.position]));
|
||||
const widgets = layout.widgets
|
||||
.toSorted((a, b) => {
|
||||
const tabDelta =
|
||||
(tabPosition.get(a.tabId) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(tabPosition.get(b.tabId) ?? Number.MAX_SAFE_INTEGER);
|
||||
return tabDelta || a.position - b.position;
|
||||
})
|
||||
.map(cloneWidget);
|
||||
const nextPosition = new Map<string, number>();
|
||||
for (const widget of widgets) {
|
||||
const position = nextPosition.get(widget.tabId) ?? 0;
|
||||
widget.position = position;
|
||||
nextPosition.set(widget.tabId, position + 1);
|
||||
}
|
||||
return { tabs, widgets };
|
||||
}
|
||||
|
||||
function requireTab(layout: BoardLayout, tabId: string): BoardTab {
|
||||
const tab = layout.tabs.find((candidate) => candidate.tabId === tabId);
|
||||
if (!tab) {
|
||||
throw new BoardValidationError("not_found", `board tab not found: ${tabId}`);
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
function requireWidget(layout: BoardLayout, name: string): BoardWidget {
|
||||
const widget = layout.widgets.find((candidate) => candidate.name === name);
|
||||
if (!widget) {
|
||||
throw new BoardValidationError("not_found", `board widget not found: ${name}`);
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
|
||||
function moveTab(layout: BoardLayout, tab: BoardTab, position: number): void {
|
||||
const ordered = layout.tabs.toSorted(comparePosition).filter((candidate) => candidate !== tab);
|
||||
ordered.splice(clampInteger(position, 0, ordered.length), 0, tab);
|
||||
ordered.forEach((candidate, index) => {
|
||||
candidate.position = index;
|
||||
});
|
||||
layout.tabs = ordered;
|
||||
}
|
||||
|
||||
function moveWidget(
|
||||
layout: BoardLayout,
|
||||
widget: BoardWidget,
|
||||
targetTabId: string,
|
||||
position?: number,
|
||||
after?: string,
|
||||
): void {
|
||||
requireTab(layout, targetTabId);
|
||||
if (position !== undefined && after !== undefined) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
"widget_move accepts either position or after, not both",
|
||||
);
|
||||
}
|
||||
const targetWidgets = layout.widgets
|
||||
.filter((candidate) => candidate.tabId === targetTabId && candidate !== widget)
|
||||
.toSorted(comparePosition);
|
||||
let targetPosition = targetWidgets.length;
|
||||
if (after !== undefined) {
|
||||
if (after === widget.name) {
|
||||
throw new BoardValidationError("invalid_operation", "widget cannot be placed after itself");
|
||||
}
|
||||
const anchorIndex = targetWidgets.findIndex((candidate) => candidate.name === after);
|
||||
if (anchorIndex < 0) {
|
||||
throw new BoardValidationError(
|
||||
"not_found",
|
||||
`board widget anchor not found on tab ${targetTabId}: ${after}`,
|
||||
);
|
||||
}
|
||||
targetPosition = anchorIndex + 1;
|
||||
} else if (position !== undefined) {
|
||||
targetPosition = clampInteger(position, 0, targetWidgets.length);
|
||||
}
|
||||
widget.tabId = targetTabId;
|
||||
targetWidgets.splice(targetPosition, 0, widget);
|
||||
targetWidgets.forEach((candidate, index) => {
|
||||
candidate.position = index;
|
||||
});
|
||||
const otherWidgets = layout.widgets.filter(
|
||||
(candidate) => candidate !== widget && candidate.tabId !== targetTabId,
|
||||
);
|
||||
layout.widgets = [...otherWidgets, ...targetWidgets];
|
||||
}
|
||||
|
||||
function applyBoardOp(layout: BoardLayout, op: BoardOp): void {
|
||||
switch (op.kind) {
|
||||
case "tab_create": {
|
||||
if (layout.tabs.some((tab) => tab.tabId === op.tabId)) {
|
||||
throw new BoardValidationError("conflict", `board tab already exists: ${op.tabId}`);
|
||||
}
|
||||
layout.tabs.push({
|
||||
tabId: op.tabId,
|
||||
title: op.title,
|
||||
position: layout.tabs.length,
|
||||
chatDock: op.chatDock ?? "right",
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "tab_update": {
|
||||
const tab = requireTab(layout, op.tabId);
|
||||
if (op.title === undefined && op.chatDock === undefined && op.position === undefined) {
|
||||
throw new BoardValidationError("invalid_operation", "tab_update has no changes");
|
||||
}
|
||||
if (op.title !== undefined) {
|
||||
tab.title = op.title;
|
||||
}
|
||||
if (op.chatDock !== undefined) {
|
||||
tab.chatDock = op.chatDock;
|
||||
}
|
||||
if (op.position !== undefined) {
|
||||
moveTab(layout, tab, op.position);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "tab_delete": {
|
||||
const tab = requireTab(layout, op.tabId);
|
||||
const remainingTabs = layout.tabs
|
||||
.filter((candidate) => candidate !== tab)
|
||||
.toSorted(comparePosition);
|
||||
const tabWidgets = layout.widgets
|
||||
.filter((widget) => widget.tabId === tab.tabId)
|
||||
.toSorted(comparePosition);
|
||||
if (remainingTabs.length === 0 && tabWidgets.length > 0) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
"cannot delete the last board tab while it contains widgets",
|
||||
);
|
||||
}
|
||||
layout.tabs = remainingTabs;
|
||||
if (tabWidgets.length > 0) {
|
||||
const fallback = remainingTabs[0]!;
|
||||
for (const widget of tabWidgets) {
|
||||
widget.tabId = fallback.tabId;
|
||||
widget.position = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "tabs_reorder": {
|
||||
if (
|
||||
op.tabIds.length !== layout.tabs.length ||
|
||||
new Set(op.tabIds).size !== op.tabIds.length ||
|
||||
op.tabIds.some((tabId) => !layout.tabs.some((tab) => tab.tabId === tabId))
|
||||
) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
"tabs_reorder must contain every tab exactly once",
|
||||
);
|
||||
}
|
||||
const byId = new Map(layout.tabs.map((tab) => [tab.tabId, tab]));
|
||||
layout.tabs = op.tabIds.map((tabId, position) => {
|
||||
const tab = byId.get(tabId)!;
|
||||
tab.position = position;
|
||||
return tab;
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "widget_move": {
|
||||
const widget = requireWidget(layout, op.name);
|
||||
moveWidget(layout, widget, op.tabId ?? widget.tabId, op.position, op.after);
|
||||
return;
|
||||
}
|
||||
case "widget_resize": {
|
||||
const widget = requireWidget(layout, op.name);
|
||||
widget.sizeW = clampInteger(op.sizeW, 1, 12);
|
||||
widget.sizeH = clampInteger(op.sizeH, 1, 20);
|
||||
return;
|
||||
}
|
||||
case "widget_remove": {
|
||||
requireWidget(layout, op.name);
|
||||
layout.widgets = layout.widgets.filter((widget) => widget.name !== op.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyBoardOps(layout: BoardLayout, ops: readonly BoardOp[]): BoardLayout {
|
||||
const next = cloneLayout(layout);
|
||||
for (const op of ops) {
|
||||
applyBoardOp(next, op);
|
||||
const normalized = normalizeBoardLayout(next);
|
||||
next.tabs = normalized.tabs;
|
||||
next.widgets = normalized.widgets;
|
||||
}
|
||||
return normalizeBoardLayout(next);
|
||||
}
|
||||
|
||||
export function insertBoardWidget(
|
||||
layout: BoardLayout,
|
||||
widget: BoardWidget,
|
||||
placement: { tabId: string; after?: string; move?: boolean },
|
||||
): BoardLayout {
|
||||
const next = cloneLayout(layout);
|
||||
const existing = next.widgets.find((candidate) => candidate.name === widget.name);
|
||||
if (existing) {
|
||||
const position = existing.position;
|
||||
Object.assign(existing, widget, { tabId: placement.tabId, position });
|
||||
if (placement.move) {
|
||||
moveWidget(next, existing, placement.tabId, undefined, placement.after);
|
||||
}
|
||||
} else {
|
||||
next.widgets.push({ ...widget, tabId: placement.tabId });
|
||||
const inserted = requireWidget(next, widget.name);
|
||||
moveWidget(next, inserted, placement.tabId, undefined, placement.after);
|
||||
}
|
||||
return normalizeBoardLayout(next);
|
||||
}
|
||||
64
src/boards/board-notices.ts
Normal file
64
src/boards/board-notices.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
|
||||
const BOARD_EVENT_MAX_BYTES = 8 * 1024;
|
||||
const BOARD_NOTICE_MAX_CHARS = 500;
|
||||
const BOARD_EVENT_DEDUPE_MS = 5_000;
|
||||
|
||||
const recentNotices = new Map<string, { summary: string; at: number }>();
|
||||
|
||||
export class BoardEventPayloadError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BoardEventPayloadError";
|
||||
}
|
||||
}
|
||||
|
||||
function serializePayload(payload: unknown): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(payload);
|
||||
return serialized === undefined ? String(payload) : serialized;
|
||||
} catch {
|
||||
throw new BoardEventPayloadError("board event payload must be JSON serializable");
|
||||
}
|
||||
}
|
||||
|
||||
function formatNotice(widget: string, summary: string): string {
|
||||
const prefix = "[dashboard] ";
|
||||
const suffix = ` on widget ${widget}`;
|
||||
const available = BOARD_NOTICE_MAX_CHARS - prefix.length - suffix.length;
|
||||
const clipped =
|
||||
summary.length <= available ? summary : `${summary.slice(0, Math.max(0, available - 1))}…`;
|
||||
return `${prefix}${clipped}${suffix}`;
|
||||
}
|
||||
|
||||
export function appendBoardEventNotice(params: {
|
||||
sessionKey: string;
|
||||
widget: string;
|
||||
payload: unknown;
|
||||
now?: number;
|
||||
}): boolean {
|
||||
const summary = serializePayload(params.payload);
|
||||
if (Buffer.byteLength(summary, "utf8") > BOARD_EVENT_MAX_BYTES) {
|
||||
throw new BoardEventPayloadError(`board event payload exceeds ${BOARD_EVENT_MAX_BYTES} bytes`);
|
||||
}
|
||||
const now = params.now ?? Date.now();
|
||||
const key = `${params.sessionKey}\0${params.widget}`;
|
||||
const recent = recentNotices.get(key);
|
||||
if (recent?.summary === summary && now - recent.at < BOARD_EVENT_DEDUPE_MS) {
|
||||
return false;
|
||||
}
|
||||
recentNotices.set(key, { summary, at: now });
|
||||
for (const [candidate, notice] of recentNotices) {
|
||||
if (now - notice.at >= BOARD_EVENT_DEDUPE_MS) {
|
||||
recentNotices.delete(candidate);
|
||||
}
|
||||
}
|
||||
return enqueueSystemEvent(formatNotice(params.widget, summary), {
|
||||
sessionKey: params.sessionKey,
|
||||
contextKey: `dashboard:${params.widget}:${now}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function resetBoardEventNoticeStateForTest(): void {
|
||||
recentNotices.clear();
|
||||
}
|
||||
508
src/boards/board-store.parity.test.ts
Normal file
508
src/boards/board-store.parity.test.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { replaceSessionEntrySync } from "../config/sessions/session-accessor.entry.js";
|
||||
import { deleteSessionEntryLifecycle } from "../config/sessions/session-accessor.js";
|
||||
import { requireNodeSqlite } from "../infra/node-sqlite.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
openOpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { InMemoryBoardStore, type BoardStore } from "./board-store.js";
|
||||
import { SqliteBoardStore } from "./sqlite-board-store.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function seedSession(env: NodeJS.ProcessEnv, agentId: string, sessionKey: string): string {
|
||||
const database = openOpenClawAgentDatabase({ agentId, env });
|
||||
const sessionId = `session-${agentId}-${sessionKey.replaceAll(":", "-")}`;
|
||||
replaceSessionEntrySync(
|
||||
{ agentId, sessionKey, storePath: database.path },
|
||||
{ sessionId, updatedAt: Date.now() },
|
||||
);
|
||||
return database.path;
|
||||
}
|
||||
|
||||
function createSqliteStore(): BoardStore {
|
||||
const stateDir = tempDirs.make("openclaw-board-parity-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
seedSession(env, "main", "agent:main:board");
|
||||
return new SqliteBoardStore({
|
||||
resolveSession: (sessionKey) => ({
|
||||
agentId: sessionKey.split(":")[1] ?? "main",
|
||||
sessionKey,
|
||||
}),
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["memory", () => new InMemoryBoardStore()],
|
||||
["sqlite", createSqliteStore],
|
||||
] as const)("BoardStore parity: %s", (_kind, createStore) => {
|
||||
it("persists revisions, layout, bytes, and declared summaries", () => {
|
||||
const store = createStore();
|
||||
const first = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "weather",
|
||||
content: { kind: "html", html: "<p>one</p>" },
|
||||
declared: {
|
||||
netOrigins: ["https://weather.example"],
|
||||
tools: ["weather.refresh"],
|
||||
},
|
||||
});
|
||||
expect(first).toMatchObject({
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", position: 0 }],
|
||||
widgets: [
|
||||
{
|
||||
name: "weather",
|
||||
revision: 1,
|
||||
grantState: "pending",
|
||||
declaredSummary: [
|
||||
"Network access: https://weather.example",
|
||||
"Tool access: weather.refresh",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(store.readWidgetHtml("agent:main:board", "weather")).toMatchObject({
|
||||
html: "<p>one</p>",
|
||||
revision: 1,
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
});
|
||||
|
||||
const resized = store.applyOps("agent:main:board", [
|
||||
{ kind: "widget_resize", name: "weather", sizeW: 8, sizeH: 6 },
|
||||
]);
|
||||
expect(resized).toMatchObject({
|
||||
revision: 2,
|
||||
widgets: [{ sizeW: 8, sizeH: 6, revision: 1 }],
|
||||
});
|
||||
expect(store.grant("agent:main:board", "weather", "granted", 1)).toMatchObject({
|
||||
revision: 3,
|
||||
widgets: [{ grantState: "granted" }],
|
||||
});
|
||||
|
||||
const updated = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "weather",
|
||||
content: { kind: "html", html: "<p>two</p>" },
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
revision: 4,
|
||||
widgets: [{ revision: 2, grantState: "granted", sizeW: 8, sizeH: 6 }],
|
||||
});
|
||||
expect(updated.widgets[0]).not.toHaveProperty("declaredSummary");
|
||||
});
|
||||
|
||||
it("keeps content-kind semantics and normalized ordering", () => {
|
||||
const store = createStore();
|
||||
store.applyOps("agent:main:board", [
|
||||
{ kind: "tab_create", tabId: "main", title: "Main" },
|
||||
{ kind: "tab_create", tabId: "notes", title: "Notes" },
|
||||
]);
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "first",
|
||||
content: { kind: "html", html: "first" },
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "app",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "agent:main:origin",
|
||||
toolCallId: "call",
|
||||
},
|
||||
},
|
||||
placement: { tabId: "notes" },
|
||||
});
|
||||
expect(store.getSnapshot("agent:main:board").widgets).toEqual([
|
||||
expect.objectContaining({ name: "first", tabId: "main", position: 0 }),
|
||||
expect.objectContaining({ name: "app", tabId: "notes", position: 0 }),
|
||||
]);
|
||||
expect(store.readWidgetHtml("agent:main:board", "app")).toEqual({
|
||||
descriptor: {
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "agent:main:origin",
|
||||
toolCallId: "call",
|
||||
},
|
||||
revision: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves grants only for equal or narrower declarations", () => {
|
||||
const store = createStore();
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "scoped",
|
||||
content: { kind: "html", html: "one" },
|
||||
declared: {
|
||||
netOrigins: ["https://one.example", "https://two.example"],
|
||||
tools: ["weather.read", "weather.refresh"],
|
||||
},
|
||||
});
|
||||
store.grant("agent:main:board", "scoped", "granted", 1);
|
||||
|
||||
const equal = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "scoped",
|
||||
content: { kind: "html", html: "two" },
|
||||
declared: {
|
||||
netOrigins: ["https://one.example", "https://two.example"],
|
||||
tools: ["weather.read", "weather.refresh"],
|
||||
},
|
||||
});
|
||||
expect(equal.widgets[0]).toMatchObject({ revision: 2, grantState: "granted" });
|
||||
expect(store.readWidgetHtml("agent:main:board", "scoped")).toMatchObject({
|
||||
html: "two",
|
||||
grantState: "granted",
|
||||
});
|
||||
|
||||
const narrower = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "scoped",
|
||||
content: { kind: "html", html: "three" },
|
||||
declared: {
|
||||
netOrigins: ["https://one.example"],
|
||||
tools: ["weather.read"],
|
||||
},
|
||||
});
|
||||
expect(narrower.widgets[0]).toMatchObject({ revision: 3, grantState: "granted" });
|
||||
|
||||
const wider = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "scoped",
|
||||
content: { kind: "html", html: "four" },
|
||||
declared: {
|
||||
netOrigins: ["https://one.example", "https://three.example"],
|
||||
tools: ["weather.read"],
|
||||
},
|
||||
});
|
||||
expect(wider.widgets[0]).toMatchObject({ revision: 4, grantState: "pending" });
|
||||
});
|
||||
|
||||
it("rejects stale grant revisions before accepting the current one", () => {
|
||||
const store = createStore();
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "scoped",
|
||||
content: { kind: "html", html: "one" },
|
||||
declared: { tools: ["weather.read"] },
|
||||
});
|
||||
expect(() => store.grant("agent:main:board", "scoped", "granted", 2)).toThrow(
|
||||
"revision changed",
|
||||
);
|
||||
expect(store.grant("agent:main:board", "scoped", "granted", 1).widgets[0]).toMatchObject({
|
||||
revision: 1,
|
||||
grantState: "granted",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops an empty board after its last tab is deleted", () => {
|
||||
const store = createStore();
|
||||
store.applyOps("agent:main:board", [{ kind: "tab_create", tabId: "main", title: "Main" }]);
|
||||
expect(
|
||||
store.applyOps("agent:main:board", [{ kind: "tab_delete", tabId: "main" }]),
|
||||
).toMatchObject({ revision: 2, tabs: [], widgets: [] });
|
||||
expect(store.getSnapshot("agent:main:board").revision).toBe(0);
|
||||
expect(store.listSessionsWithBoards()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqliteBoardStore persistence", () => {
|
||||
it("lazily creates board tables for an existing v13 database", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-lazy-schema-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionKey = "agent:main:board";
|
||||
seedSession(env, "main", sessionKey);
|
||||
const opened = openOpenClawAgentDatabase({ agentId: "main", env });
|
||||
const databasePath = opened.path;
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const existingV13 = new DatabaseSync(databasePath);
|
||||
existingV13.exec(`
|
||||
DROP TABLE board_widgets;
|
||||
DROP TABLE board_tabs;
|
||||
PRAGMA user_version = 13;
|
||||
UPDATE schema_meta SET schema_version = 13 WHERE meta_key = 'primary';
|
||||
`);
|
||||
existingV13.close();
|
||||
|
||||
const reopened = openOpenClawAgentDatabase({ agentId: "main", env });
|
||||
expect(
|
||||
reopened.db
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'board_tabs'")
|
||||
.get(),
|
||||
).toBeUndefined();
|
||||
|
||||
const store = new SqliteBoardStore({
|
||||
resolveSession: () => ({ agentId: "main", sessionKey }),
|
||||
env,
|
||||
});
|
||||
expect(() =>
|
||||
store.putWidget({
|
||||
sessionKey,
|
||||
name: "broken",
|
||||
content: { kind: "html", html: "broken" },
|
||||
placement: { tabId: "missing" },
|
||||
}),
|
||||
).toThrow("board tab not found");
|
||||
store.putWidget({
|
||||
sessionKey,
|
||||
name: "status",
|
||||
content: { kind: "html", html: "ok" },
|
||||
});
|
||||
expect(
|
||||
reopened.db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name IN ('board_tabs', 'board_widgets') ORDER BY name",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([{ name: "board_tabs" }, { name: "board_widgets" }]);
|
||||
expect(
|
||||
reopened.db
|
||||
.prepare("SELECT strict FROM pragma_table_list WHERE name = 'board_widgets'")
|
||||
.get(),
|
||||
).toEqual({ strict: 1 });
|
||||
expect(
|
||||
reopened.db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_schema WHERE type = 'index' AND name = 'idx_agent_board_widgets_tab_position'",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ name: "idx_agent_board_widgets_tab_position" });
|
||||
});
|
||||
|
||||
it("does not create an unregistered agent database during widget byte lookup", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-no-create-");
|
||||
const store = new SqliteBoardStore({
|
||||
resolveSession: () => ({
|
||||
agentId: "attacker-selected",
|
||||
sessionKey: "agent:attacker-selected:main",
|
||||
}),
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
});
|
||||
|
||||
expect(store.getSnapshot("agent:attacker-selected:main")).toEqual({
|
||||
sessionKey: "agent:attacker-selected:main",
|
||||
revision: 0,
|
||||
tabs: [],
|
||||
widgets: [],
|
||||
});
|
||||
expect(store.readWidgetHtml("agent:attacker-selected:main", "missing")).toBeUndefined();
|
||||
expect(() =>
|
||||
store.putWidget({
|
||||
sessionKey: "agent:attacker-selected:main",
|
||||
name: "missing",
|
||||
content: { kind: "html", html: "no" },
|
||||
}),
|
||||
).toThrow("board session not found");
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(stateDir, "agents", "attacker-selected", "agent", "openclaw-agent.sqlite"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(existsSync(path.join(stateDir, "agents", "attacker-selected"))).toBe(false);
|
||||
});
|
||||
|
||||
it("canonicalizes aliases before reading and writing board rows", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-alias-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const canonicalSessionKey = "agent:main:main";
|
||||
seedSession(env, "main", canonicalSessionKey);
|
||||
const store = new SqliteBoardStore({
|
||||
resolveSession: (sessionKey) => ({
|
||||
agentId: "main",
|
||||
sessionKey: sessionKey === "main" ? canonicalSessionKey : sessionKey,
|
||||
}),
|
||||
env,
|
||||
});
|
||||
|
||||
store.putWidget({
|
||||
sessionKey: "main",
|
||||
name: "status",
|
||||
content: { kind: "html", html: "one" },
|
||||
});
|
||||
expect(store.getSnapshot(canonicalSessionKey)).toMatchObject({
|
||||
sessionKey: canonicalSessionKey,
|
||||
widgets: [{ name: "status", revision: 1 }],
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey: canonicalSessionKey,
|
||||
name: "status",
|
||||
content: { kind: "html", html: "two" },
|
||||
});
|
||||
expect(store.getSnapshot("main")).toMatchObject({
|
||||
sessionKey: canonicalSessionKey,
|
||||
widgets: [{ name: "status", revision: 2 }],
|
||||
});
|
||||
expect(store.listSessionsWithBoards()).toEqual([canonicalSessionKey]);
|
||||
});
|
||||
|
||||
it("reads widget bytes only from the canonical per-agent database", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-canonical-bytes-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const agentId = "worker-1";
|
||||
const sessionKey = "agent:worker-1:board";
|
||||
seedSession(env, agentId, sessionKey);
|
||||
const store = new SqliteBoardStore({
|
||||
resolveSession: () => ({ agentId, sessionKey }),
|
||||
env,
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey,
|
||||
name: "status",
|
||||
content: { kind: "html", html: "canonical" },
|
||||
});
|
||||
|
||||
const relocated = openOpenClawAgentDatabase({
|
||||
agentId,
|
||||
env,
|
||||
path: path.join(stateDir, "000-relocated.sqlite"),
|
||||
});
|
||||
replaceSessionEntrySync(
|
||||
{ agentId, sessionKey, storePath: relocated.path },
|
||||
{ sessionId: "relocated-session", updatedAt: Date.now() },
|
||||
);
|
||||
relocated.db
|
||||
.prepare(
|
||||
"INSERT INTO board_tabs (session_key, tab_id, title, position, chat_dock, created_by, revision) VALUES (?, 'main', 'Main', 0, 'right', 'agent', 1)",
|
||||
)
|
||||
.run(sessionKey);
|
||||
relocated.db
|
||||
.prepare(
|
||||
"INSERT INTO board_widgets (session_key, name, tab_id, content_kind, html, sha256, view_generation, revision, size_w, size_h, position, manifest, grant_state, created_by, created_at, updated_at) VALUES (?, 'status', 'main', 'html', ?, ?, ?, 1, 6, 4, 0, '{}', 'none', 'agent', 1, 1)",
|
||||
)
|
||||
.run(sessionKey, Buffer.from("relocated"), "a".repeat(64), "b".repeat(32));
|
||||
|
||||
expect(store.readWidgetHtml(sessionKey, "status")).toMatchObject({ html: "canonical" });
|
||||
});
|
||||
|
||||
it("purges board rows through the shared session deletion lifecycle", async () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-shared-delete-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionKey = "agent:main:cleanup";
|
||||
const databasePath = seedSession(env, "main", sessionKey);
|
||||
const store = new SqliteBoardStore({
|
||||
resolveSession: () => ({ agentId: "main", sessionKey }),
|
||||
env,
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey,
|
||||
name: "status",
|
||||
content: { kind: "html", html: "ok" },
|
||||
});
|
||||
|
||||
const result = await deleteSessionEntryLifecycle({
|
||||
agentId: "main",
|
||||
archiveTranscript: false,
|
||||
storePath: databasePath,
|
||||
target: { canonicalKey: sessionKey, storeKeys: [sessionKey] },
|
||||
});
|
||||
|
||||
expect(result.deleted).toBe(true);
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main", env });
|
||||
expect(database.db.prepare("SELECT count(*) AS count FROM board_widgets").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
expect(database.db.prepare("SELECT count(*) AS count FROM board_tabs").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("rebinds a preserved grant to the updated widget digest", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-granted-digest-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionKey = "agent:main:grant-digest";
|
||||
seedSession(env, "main", sessionKey);
|
||||
const store = new SqliteBoardStore({
|
||||
resolveSession: () => ({ agentId: "main", sessionKey }),
|
||||
env,
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey,
|
||||
name: "status",
|
||||
content: { kind: "html", html: "one" },
|
||||
declared: { tools: ["status.read", "status.refresh"] },
|
||||
});
|
||||
store.grant(sessionKey, "status", "granted", 1);
|
||||
store.putWidget({
|
||||
sessionKey,
|
||||
name: "status",
|
||||
content: { kind: "html", html: "two" },
|
||||
declared: { tools: ["status.read"] },
|
||||
});
|
||||
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main", env });
|
||||
expect(
|
||||
database.db
|
||||
.prepare(
|
||||
"SELECT grant_state AS grantState, granted_sha AS grantedSha, sha256 FROM board_widgets WHERE session_key = ? AND name = 'status'",
|
||||
)
|
||||
.get(sessionKey),
|
||||
).toEqual({
|
||||
grantState: "granted",
|
||||
grantedSha: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
});
|
||||
const row = database.db
|
||||
.prepare(
|
||||
"SELECT granted_sha AS grantedSha, sha256 FROM board_widgets WHERE session_key = ? AND name = 'status'",
|
||||
)
|
||||
.get(sessionKey) as { grantedSha: string; sha256: string };
|
||||
expect(row.grantedSha).toBe(row.sha256);
|
||||
});
|
||||
|
||||
it("reopens durable boards and isolates owning agent databases", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-durable-");
|
||||
const options = {
|
||||
resolveSession: (sessionKey: string) => ({
|
||||
agentId: sessionKey.split(":")[1] ?? "main",
|
||||
sessionKey,
|
||||
}),
|
||||
env: { OPENCLAW_STATE_DIR: stateDir },
|
||||
};
|
||||
seedSession(options.env, "alpha", "agent:alpha:board");
|
||||
seedSession(options.env, "beta", "agent:beta:board");
|
||||
const store = new SqliteBoardStore(options);
|
||||
store.putWidget({
|
||||
sessionKey: "agent:alpha:board",
|
||||
name: "alpha",
|
||||
content: { kind: "html", html: "alpha" },
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey: "agent:beta:board",
|
||||
name: "beta",
|
||||
content: { kind: "html", html: "beta" },
|
||||
});
|
||||
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
const reopened = new SqliteBoardStore(options);
|
||||
expect(reopened.getSnapshot("agent:alpha:board").widgets).toEqual([
|
||||
expect.objectContaining({ name: "alpha", revision: 1 }),
|
||||
]);
|
||||
expect(reopened.getSnapshot("agent:beta:board").widgets).toEqual([
|
||||
expect.objectContaining({ name: "beta", revision: 1 }),
|
||||
]);
|
||||
expect(reopened.listSessionsWithBoards()).toEqual(["agent:alpha:board", "agent:beta:board"]);
|
||||
});
|
||||
});
|
||||
168
src/boards/board-store.test.ts
Normal file
168
src/boards/board-store.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BoardValidationError } from "./board-layout.js";
|
||||
import { InMemoryBoardStore } from "./board-store.js";
|
||||
|
||||
function putHtml(store: InMemoryBoardStore, sessionKey: string, name: string, html = "<p>one</p>") {
|
||||
return store.putWidget({ sessionKey, name, content: { kind: "html", html } });
|
||||
}
|
||||
|
||||
describe("in-memory board store", () => {
|
||||
it("creates the implicit main tab and bumps board and widget revisions", () => {
|
||||
const store = new InMemoryBoardStore();
|
||||
const first = putHtml(store, "agent:main:main", "status");
|
||||
const second = putHtml(store, "agent:main:main", "status", "<p>two</p>");
|
||||
expect(first).toMatchObject({
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0 }],
|
||||
widgets: [{ name: "status", revision: 1 }],
|
||||
});
|
||||
expect(second.revision).toBe(2);
|
||||
expect(second.widgets[0]!.revision).toBe(2);
|
||||
});
|
||||
|
||||
it("returns immutable snapshots and lists only existing boards", () => {
|
||||
const store = new InMemoryBoardStore();
|
||||
putHtml(store, "session-b", "b");
|
||||
putHtml(store, "session-a", "a");
|
||||
const snapshot = store.getSnapshot("session-a");
|
||||
snapshot.tabs[0]!.title = "Changed";
|
||||
expect(store.getSnapshot("session-a").tabs[0]!.title).toBe("Main");
|
||||
expect(store.listSessionsWithBoards()).toEqual(["session-a", "session-b"]);
|
||||
expect(store.getSnapshot("missing")).toEqual({
|
||||
sessionKey: "missing",
|
||||
revision: 0,
|
||||
tabs: [],
|
||||
widgets: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("stores HTML bytes with digest and keeps MCP descriptors non-HTML", () => {
|
||||
const store = new InMemoryBoardStore();
|
||||
putHtml(store, "session", "html", "<main>ok</main>");
|
||||
store.putWidget({
|
||||
sessionKey: "session",
|
||||
name: "app",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "origin",
|
||||
toolCallId: "call",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(store.readWidgetHtml("session", "html")).toMatchObject({
|
||||
html: "<main>ok</main>",
|
||||
revision: 1,
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
expect(store.readWidgetHtml("session", "app")).toEqual({
|
||||
descriptor: {
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "origin",
|
||||
toolCallId: "call",
|
||||
},
|
||||
revision: 1,
|
||||
});
|
||||
expect(store.readWidgetHtml("session", "unknown")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("transitions declared widgets through pending grants", () => {
|
||||
const store = new InMemoryBoardStore();
|
||||
const pending = store.putWidget({
|
||||
sessionKey: "session",
|
||||
name: "networked",
|
||||
content: { kind: "html", html: "<p>ok</p>" },
|
||||
declared: { netOrigins: ["https://example.com"] },
|
||||
});
|
||||
expect(pending.widgets[0]!.grantState).toBe("pending");
|
||||
expect(store.grant("session", "networked", "granted", 1).widgets[0]!.grantState).toBe(
|
||||
"granted",
|
||||
);
|
||||
expect(() => store.grant("session", "networked", "rejected", 1)).toThrow("not pending");
|
||||
});
|
||||
|
||||
it("survives reset/new boundaries", () => {
|
||||
const store = new InMemoryBoardStore();
|
||||
putHtml(store, "session", "status");
|
||||
// Session reset has no BoardStore call; the stable session key remains authoritative.
|
||||
expect(store.getSnapshot("session").widgets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects stale grant revisions and accepts the current revision", () => {
|
||||
const store = new InMemoryBoardStore();
|
||||
store.putWidget({
|
||||
sessionKey: "session",
|
||||
name: "networked",
|
||||
content: { kind: "html", html: "ok" },
|
||||
declared: { tools: ["weather.refresh"] },
|
||||
});
|
||||
try {
|
||||
store.grant("session", "networked", "granted", 2);
|
||||
throw new Error("expected stale grant to fail");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(BoardValidationError);
|
||||
expect(error).toMatchObject({ code: "conflict" });
|
||||
expect((error as Error).message).toContain("revision changed");
|
||||
}
|
||||
expect(store.grant("session", "networked", "granted", 1).widgets[0]).toMatchObject({
|
||||
grantState: "granted",
|
||||
revision: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces the board widget count and UTF-8 HTML byte limits", () => {
|
||||
const store = new InMemoryBoardStore();
|
||||
for (let index = 0; index < 48; index += 1) {
|
||||
putHtml(store, "session", `widget-${index}`, "ok");
|
||||
}
|
||||
try {
|
||||
putHtml(store, "session", "widget-48", "ok");
|
||||
throw new Error("expected widget cap to fail");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(BoardValidationError);
|
||||
expect(error).toMatchObject({ code: "invalid_operation" });
|
||||
expect((error as Error).message).toContain("more than 48 widgets");
|
||||
}
|
||||
expect(() =>
|
||||
putHtml(new InMemoryBoardStore(), "session", "large", "é".repeat(131_073)),
|
||||
).toThrow("262144 UTF-8 bytes");
|
||||
});
|
||||
|
||||
it("bumps once per applyOps transaction and removes widget bytes", () => {
|
||||
const store = new InMemoryBoardStore();
|
||||
putHtml(store, "session", "status");
|
||||
const snapshot = store.applyOps("session", [
|
||||
{ kind: "widget_resize", name: "status", sizeW: 3, sizeH: 3 },
|
||||
{ kind: "widget_remove", name: "status" },
|
||||
]);
|
||||
expect(snapshot.revision).toBe(2);
|
||||
expect(snapshot.widgets).toEqual([]);
|
||||
expect(store.readWidgetHtml("session", "status")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves position on content updates and honors explicit after placement", () => {
|
||||
const store = new InMemoryBoardStore();
|
||||
putHtml(store, "session", "first");
|
||||
putHtml(store, "session", "second");
|
||||
putHtml(store, "session", "third");
|
||||
|
||||
expect(
|
||||
putHtml(store, "session", "first", "<p>updated</p>").widgets.map((widget) => widget.name),
|
||||
).toEqual(["first", "second", "third"]);
|
||||
expect(
|
||||
store
|
||||
.putWidget({
|
||||
sessionKey: "session",
|
||||
name: "first",
|
||||
content: { kind: "html", html: "<p>moved</p>" },
|
||||
placement: { after: "third" },
|
||||
})
|
||||
.widgets.map((widget) => widget.name),
|
||||
).toEqual(["second", "third", "first"]);
|
||||
});
|
||||
});
|
||||
279
src/boards/board-store.ts
Normal file
279
src/boards/board-store.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type {
|
||||
BoardMcpAppDescriptor,
|
||||
BoardOp,
|
||||
BoardSnapshot,
|
||||
BoardWidgetContent,
|
||||
BoardWidgetPutParams,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
applyBoardOps,
|
||||
BOARD_SIZE_PRESETS,
|
||||
BoardValidationError,
|
||||
insertBoardWidget,
|
||||
normalizeBoardLayout,
|
||||
type BoardSize,
|
||||
} from "./board-layout.js";
|
||||
|
||||
type BoardWidgetHtmlDocument = {
|
||||
html: string;
|
||||
revision: number;
|
||||
sha256: string;
|
||||
viewGeneration: string;
|
||||
grantState: "none" | "pending" | "granted" | "rejected";
|
||||
};
|
||||
type BoardWidgetMcpAppDocument = {
|
||||
descriptor: BoardMcpAppDescriptor;
|
||||
revision: number;
|
||||
};
|
||||
export type BoardWidgetDocument = BoardWidgetHtmlDocument | BoardWidgetMcpAppDocument;
|
||||
|
||||
export interface BoardStore {
|
||||
getSnapshot(sessionKey: string): BoardSnapshot;
|
||||
applyOps(sessionKey: string, ops: readonly BoardOp[]): BoardSnapshot;
|
||||
putWidget(params: BoardWidgetPutParams): BoardSnapshot;
|
||||
grant(
|
||||
sessionKey: string,
|
||||
name: string,
|
||||
decision: "granted" | "rejected",
|
||||
revision: number,
|
||||
): BoardSnapshot;
|
||||
readWidgetHtml(sessionKey: string, name: string): BoardWidgetDocument | undefined;
|
||||
listSessionsWithBoards(): string[];
|
||||
}
|
||||
|
||||
type StoredBoard = {
|
||||
snapshot: BoardSnapshot;
|
||||
documents: Map<string, BoardWidgetDocument>;
|
||||
};
|
||||
|
||||
const BOARD_MAX_WIDGETS = 48;
|
||||
const BOARD_MAX_WIDGET_HTML_BYTES = 256 * 1024;
|
||||
|
||||
function emptyBoardSnapshot(sessionKey: string): BoardSnapshot {
|
||||
return { sessionKey, revision: 0, tabs: [], widgets: [] };
|
||||
}
|
||||
|
||||
export function cloneBoardSnapshot(snapshot: BoardSnapshot): BoardSnapshot {
|
||||
return {
|
||||
sessionKey: snapshot.sessionKey,
|
||||
revision: snapshot.revision,
|
||||
tabs: snapshot.tabs.map((tab) => ({ ...tab })),
|
||||
widgets: snapshot.widgets.map((widget) => ({
|
||||
...widget,
|
||||
...(widget.declaredSummary !== undefined
|
||||
? { declaredSummary: [...widget.declaredSummary] }
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createBoardWidgetDocument(
|
||||
content: BoardWidgetContent,
|
||||
revision: number,
|
||||
grantState: BoardWidgetHtmlDocument["grantState"],
|
||||
): BoardWidgetDocument {
|
||||
if (content.kind === "html") {
|
||||
return {
|
||||
html: content.html,
|
||||
revision,
|
||||
sha256: createHash("sha256").update(content.html).digest("hex"),
|
||||
viewGeneration: randomBytes(16).toString("hex"),
|
||||
grantState,
|
||||
};
|
||||
}
|
||||
return { descriptor: { ...content.descriptor }, revision };
|
||||
}
|
||||
|
||||
export function createBoardDeclaredSummary(
|
||||
declared: BoardWidgetPutParams["declared"],
|
||||
): string[] | undefined {
|
||||
const lines = [
|
||||
...(declared?.netOrigins ?? []).map((origin) => `Network access: ${origin}`),
|
||||
...(declared?.tools ?? []).map((tool) => `Tool access: ${tool}`),
|
||||
];
|
||||
return lines.length > 0 ? lines : undefined;
|
||||
}
|
||||
|
||||
export function createBoardWidgetPutSnapshot(
|
||||
prior: BoardSnapshot,
|
||||
params: BoardWidgetPutParams,
|
||||
): BoardSnapshot {
|
||||
if (
|
||||
params.content.kind === "html" &&
|
||||
Buffer.byteLength(params.content.html, "utf8") > BOARD_MAX_WIDGET_HTML_BYTES
|
||||
) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
`board widget HTML exceeds ${BOARD_MAX_WIDGET_HTML_BYTES} UTF-8 bytes`,
|
||||
);
|
||||
}
|
||||
let layout = normalizeBoardLayout(prior);
|
||||
if (layout.tabs.length === 0) {
|
||||
layout.tabs.push({ tabId: "main", title: "Main", position: 0, chatDock: "right" });
|
||||
}
|
||||
const existing = layout.widgets.find((widget) => widget.name === params.name);
|
||||
if (!existing && layout.widgets.length >= BOARD_MAX_WIDGETS) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
`board cannot contain more than ${BOARD_MAX_WIDGETS} widgets`,
|
||||
);
|
||||
}
|
||||
const tabId = params.placement?.tabId ?? existing?.tabId ?? layout.tabs[0]!.tabId;
|
||||
if (!layout.tabs.some((tab) => tab.tabId === tabId)) {
|
||||
throw new BoardValidationError("not_found", `board tab not found: ${tabId}`);
|
||||
}
|
||||
const size = BOARD_SIZE_PRESETS[(params.placement?.size ?? "md") as BoardSize];
|
||||
const widgetRevision = (existing?.revision ?? 0) + 1;
|
||||
const declaredSummary = createBoardDeclaredSummary(params.declared);
|
||||
// A grant follows new bytes only when every declared capability was already approved.
|
||||
// Any widening must return to pending before the widget can be served.
|
||||
const preservesGrant =
|
||||
existing?.grantState === "granted" &&
|
||||
(declaredSummary ?? []).every((entry) => existing.declaredSummary?.includes(entry));
|
||||
layout = insertBoardWidget(
|
||||
layout,
|
||||
{
|
||||
name: params.name,
|
||||
tabId,
|
||||
...(params.title !== undefined
|
||||
? { title: params.title }
|
||||
: existing?.title !== undefined
|
||||
? { title: existing.title }
|
||||
: {}),
|
||||
contentKind: params.content.kind,
|
||||
sizeW: params.placement?.size ? size.sizeW : (existing?.sizeW ?? size.sizeW),
|
||||
sizeH: params.placement?.size ? size.sizeH : (existing?.sizeH ?? size.sizeH),
|
||||
position: existing?.position ?? layout.widgets.length,
|
||||
grantState: preservesGrant ? "granted" : declaredSummary ? "pending" : "none",
|
||||
revision: widgetRevision,
|
||||
...(declaredSummary ? { declaredSummary } : {}),
|
||||
},
|
||||
{
|
||||
tabId,
|
||||
...(params.placement?.after ? { after: params.placement.after } : {}),
|
||||
move: params.placement?.tabId !== undefined || params.placement?.after !== undefined,
|
||||
},
|
||||
);
|
||||
if (!declaredSummary) {
|
||||
delete layout.widgets.find((widget) => widget.name === params.name)!.declaredSummary;
|
||||
}
|
||||
return {
|
||||
sessionKey: params.sessionKey,
|
||||
revision: prior.revision + 1,
|
||||
...layout,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBoardGrantSnapshot(
|
||||
current: BoardSnapshot,
|
||||
name: string,
|
||||
decision: "granted" | "rejected",
|
||||
revision: number,
|
||||
): BoardSnapshot {
|
||||
const widget = current.widgets.find((candidate) => candidate.name === name);
|
||||
if (!widget) {
|
||||
throw new BoardValidationError("not_found", `board widget not found: ${name}`);
|
||||
}
|
||||
if (widget.revision !== revision) {
|
||||
throw new BoardValidationError(
|
||||
"conflict",
|
||||
`board widget revision changed: ${name} is revision ${widget.revision}, not ${revision}`,
|
||||
);
|
||||
}
|
||||
if (widget.grantState !== "pending") {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
`board widget grant is not pending: ${name}`,
|
||||
);
|
||||
}
|
||||
const snapshot = cloneBoardSnapshot(current);
|
||||
snapshot.widgets.find((candidate) => candidate.name === name)!.grantState = decision;
|
||||
snapshot.revision += 1;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export class InMemoryBoardStore implements BoardStore {
|
||||
private readonly boards = new Map<string, StoredBoard>();
|
||||
|
||||
getSnapshot(sessionKey: string): BoardSnapshot {
|
||||
return cloneBoardSnapshot(
|
||||
this.boards.get(sessionKey)?.snapshot ?? emptyBoardSnapshot(sessionKey),
|
||||
);
|
||||
}
|
||||
|
||||
applyOps(sessionKey: string, ops: readonly BoardOp[]): BoardSnapshot {
|
||||
const current = this.boards.get(sessionKey);
|
||||
const snapshot = current?.snapshot ?? emptyBoardSnapshot(sessionKey);
|
||||
if (ops.length === 0) {
|
||||
return cloneBoardSnapshot(snapshot);
|
||||
}
|
||||
const layout = applyBoardOps(snapshot, ops);
|
||||
const next: BoardSnapshot = {
|
||||
sessionKey,
|
||||
revision: snapshot.revision + 1,
|
||||
...layout,
|
||||
};
|
||||
const removedNames = new Set(next.widgets.map((widget) => widget.name));
|
||||
const documents = new Map(
|
||||
[...(current?.documents ?? [])].filter(([name]) => removedNames.has(name)),
|
||||
);
|
||||
if (next.tabs.length === 0 && next.widgets.length === 0) {
|
||||
this.boards.delete(sessionKey);
|
||||
} else {
|
||||
this.boards.set(sessionKey, { snapshot: next, documents });
|
||||
}
|
||||
return cloneBoardSnapshot(next);
|
||||
}
|
||||
|
||||
putWidget(params: BoardWidgetPutParams): BoardSnapshot {
|
||||
const current = this.boards.get(params.sessionKey);
|
||||
const prior = current?.snapshot ?? emptyBoardSnapshot(params.sessionKey);
|
||||
const snapshot = createBoardWidgetPutSnapshot(prior, params);
|
||||
const documents = new Map(current?.documents ?? []);
|
||||
const widgetRevision = snapshot.widgets.find((widget) => widget.name === params.name)!.revision;
|
||||
const widget = snapshot.widgets.find((candidate) => candidate.name === params.name)!;
|
||||
documents.set(
|
||||
params.name,
|
||||
createBoardWidgetDocument(params.content, widgetRevision, widget.grantState),
|
||||
);
|
||||
this.boards.set(params.sessionKey, { snapshot, documents });
|
||||
return cloneBoardSnapshot(snapshot);
|
||||
}
|
||||
|
||||
grant(
|
||||
sessionKey: string,
|
||||
name: string,
|
||||
decision: "granted" | "rejected",
|
||||
revision: number,
|
||||
): BoardSnapshot {
|
||||
const current = this.boards.get(sessionKey);
|
||||
if (!current) {
|
||||
throw new BoardValidationError("not_found", `board widget not found: ${name}`);
|
||||
}
|
||||
const snapshot = createBoardGrantSnapshot(current.snapshot, name, decision, revision);
|
||||
const document = current.documents.get(name);
|
||||
if (document && "html" in document) {
|
||||
document.grantState = decision;
|
||||
}
|
||||
this.boards.set(sessionKey, { snapshot, documents: current.documents });
|
||||
return cloneBoardSnapshot(snapshot);
|
||||
}
|
||||
|
||||
readWidgetHtml(sessionKey: string, name: string): BoardWidgetDocument | undefined {
|
||||
const document = this.boards.get(sessionKey)?.documents.get(name);
|
||||
if (!document) {
|
||||
return undefined;
|
||||
}
|
||||
return "html" in document
|
||||
? { ...document }
|
||||
: { descriptor: { ...document.descriptor }, revision: document.revision };
|
||||
}
|
||||
|
||||
listSessionsWithBoards(): string[] {
|
||||
return [...this.boards]
|
||||
.filter(([, board]) => board.snapshot.tabs.length > 0 || board.snapshot.widgets.length > 0)
|
||||
.map(([sessionKey]) => sessionKey)
|
||||
.toSorted();
|
||||
}
|
||||
}
|
||||
602
src/boards/sqlite-board-store.ts
Normal file
602
src/boards/sqlite-board-store.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { Selectable } from "kysely";
|
||||
import type {
|
||||
BoardMcpAppDescriptor,
|
||||
BoardOp,
|
||||
BoardSnapshot,
|
||||
BoardTab,
|
||||
BoardWidget,
|
||||
BoardWidgetPutParams,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import {
|
||||
runSqliteDeferredTransactionSync,
|
||||
runSqliteImmediateTransactionSync,
|
||||
} from "../infra/sqlite-transaction.js";
|
||||
import { OPENCLAW_AGENT_BOARD_SCHEMA_SQL } from "../state/openclaw-agent-board-schema.js";
|
||||
import { withOpenClawAgentDatabaseReadOnly } from "../state/openclaw-agent-db-readonly.js";
|
||||
import type {
|
||||
BoardTabs as BoardTabRow,
|
||||
BoardWidgets as BoardWidgetRow,
|
||||
DB as OpenClawAgentKyselyDatabase,
|
||||
} from "../state/openclaw-agent-db.generated.js";
|
||||
import {
|
||||
listOpenClawRegisteredAgentDatabases,
|
||||
openOpenClawAgentDatabase,
|
||||
resolveOpenClawAgentSqlitePath,
|
||||
runOpenClawAgentWriteTransaction,
|
||||
type OpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import { applyBoardOps, BoardValidationError, normalizeBoardLayout } from "./board-layout.js";
|
||||
import {
|
||||
cloneBoardSnapshot,
|
||||
createBoardDeclaredSummary,
|
||||
createBoardGrantSnapshot,
|
||||
createBoardWidgetPutSnapshot,
|
||||
type BoardStore,
|
||||
type BoardWidgetDocument,
|
||||
} from "./board-store.js";
|
||||
|
||||
type BoardDatabase = Pick<
|
||||
OpenClawAgentKyselyDatabase,
|
||||
"board_tabs" | "board_widgets" | "session_entries"
|
||||
>;
|
||||
type BoardDatabaseHandle = Pick<OpenClawAgentDatabase, "db" | "path">;
|
||||
type SelectedBoardTabRow = Selectable<BoardTabRow>;
|
||||
type SelectedBoardWidgetRow = Selectable<BoardWidgetRow>;
|
||||
|
||||
type StoredBoard = {
|
||||
snapshot: BoardSnapshot;
|
||||
tabRows: SelectedBoardTabRow[];
|
||||
widgetRows: SelectedBoardWidgetRow[];
|
||||
};
|
||||
|
||||
const ensuredBoardDatabases = new WeakSet<DatabaseSync>();
|
||||
|
||||
function ensureBoardSchema(database: OpenClawAgentDatabase): void {
|
||||
if (ensuredBoardDatabases.has(database.db)) {
|
||||
return;
|
||||
}
|
||||
if (database.db.isTransaction) {
|
||||
throw new Error("board schema must be ensured before the write transaction starts");
|
||||
}
|
||||
runSqliteImmediateTransactionSync(
|
||||
database.db,
|
||||
() => database.db.exec(OPENCLAW_AGENT_BOARD_SCHEMA_SQL), // sqlite-allow-raw: one-time DDL bootstrap before Kysely access.
|
||||
{
|
||||
databaseLabel: database.path,
|
||||
operationLabel: "board.ensure-schema",
|
||||
},
|
||||
);
|
||||
// Additive-surface rule: fold this into the next natural schema bump, then delete this lazy ensure.
|
||||
ensuredBoardDatabases.add(database.db);
|
||||
}
|
||||
|
||||
type SqliteBoardStoreOptions = {
|
||||
resolveSession: (sessionKey: string) => {
|
||||
agentId: string;
|
||||
path?: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
function parseManifest(value: string): BoardWidgetPutParams["declared"] {
|
||||
const parsed = JSON.parse(value) as { netOrigins?: unknown; tools?: unknown };
|
||||
const netOrigins = Array.isArray(parsed.netOrigins)
|
||||
? parsed.netOrigins.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
const tools = Array.isArray(parsed.tools)
|
||||
? parsed.tools.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
return {
|
||||
...(netOrigins?.length ? { netOrigins } : {}),
|
||||
...(tools?.length ? { tools } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDescriptor(value: string): BoardMcpAppDescriptor {
|
||||
return JSON.parse(value) as BoardMcpAppDescriptor;
|
||||
}
|
||||
|
||||
function rowToTab(row: SelectedBoardTabRow): BoardTab {
|
||||
return {
|
||||
tabId: row.tab_id,
|
||||
title: row.title,
|
||||
position: row.position,
|
||||
chatDock: row.chat_dock as BoardTab["chatDock"],
|
||||
};
|
||||
}
|
||||
|
||||
function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
|
||||
const declaredSummary = createBoardDeclaredSummary(parseManifest(row.manifest));
|
||||
return {
|
||||
name: row.name,
|
||||
tabId: row.tab_id,
|
||||
...(row.title !== null ? { title: row.title } : {}),
|
||||
contentKind: row.content_kind as BoardWidget["contentKind"],
|
||||
sizeW: row.size_w,
|
||||
sizeH: row.size_h,
|
||||
position: row.position,
|
||||
grantState: row.grant_state as BoardWidget["grantState"],
|
||||
revision: row.revision,
|
||||
...(declaredSummary ? { declaredSummary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readStoredBoard(database: BoardDatabaseHandle, sessionKey: string): StoredBoard {
|
||||
// Write callers already hold an IMMEDIATE transaction; the shared helper nests
|
||||
// this consistent read as a savepoint instead of issuing a second BEGIN.
|
||||
return runSqliteDeferredTransactionSync(
|
||||
database.db,
|
||||
() => {
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
|
||||
const tabRows = executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.selectFrom("board_tabs")
|
||||
.selectAll()
|
||||
.where("session_key", "=", sessionKey)
|
||||
.orderBy("position", "asc")
|
||||
.orderBy("tab_id", "asc"),
|
||||
).rows as SelectedBoardTabRow[];
|
||||
const widgetRows = executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.selectFrom("board_widgets")
|
||||
.selectAll()
|
||||
.where("session_key", "=", sessionKey)
|
||||
.orderBy("tab_id", "asc")
|
||||
.orderBy("position", "asc")
|
||||
.orderBy("name", "asc"),
|
||||
).rows as SelectedBoardWidgetRow[];
|
||||
const layout = normalizeBoardLayout({
|
||||
tabs: tabRows.map(rowToTab),
|
||||
widgets: widgetRows.map(rowToWidget),
|
||||
});
|
||||
return {
|
||||
snapshot: {
|
||||
sessionKey,
|
||||
// Board existence is row-defined; deleting the last empty tab removes
|
||||
// the board, so a later read starts again at the empty revision.
|
||||
revision: tabRows.reduce((revision, row) => Math.max(revision, row.revision), 0),
|
||||
...layout,
|
||||
},
|
||||
tabRows,
|
||||
widgetRows,
|
||||
};
|
||||
},
|
||||
{ databaseLabel: database.path, operationLabel: "board.read" },
|
||||
);
|
||||
}
|
||||
|
||||
function upsertTabs(
|
||||
database: BoardDatabaseHandle,
|
||||
previous: StoredBoard,
|
||||
next: BoardSnapshot,
|
||||
): void {
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
|
||||
const createdBy = new Map(previous.tabRows.map((row) => [row.tab_id, row.created_by]));
|
||||
for (const tab of next.tabs) {
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.insertInto("board_tabs")
|
||||
.values({
|
||||
session_key: next.sessionKey,
|
||||
tab_id: tab.tabId,
|
||||
title: tab.title,
|
||||
position: tab.position,
|
||||
chat_dock: tab.chatDock,
|
||||
created_by: createdBy.get(tab.tabId) ?? "agent",
|
||||
revision: next.revision,
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.columns(["session_key", "tab_id"]).doUpdateSet({
|
||||
title: tab.title,
|
||||
position: tab.position,
|
||||
chat_dock: tab.chatDock,
|
||||
revision: next.revision,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function updateWidgetLayouts(
|
||||
database: BoardDatabaseHandle,
|
||||
snapshot: BoardSnapshot,
|
||||
updatedAt: number,
|
||||
): void {
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
|
||||
for (const widget of snapshot.widgets) {
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.updateTable("board_widgets")
|
||||
.set({
|
||||
tab_id: widget.tabId,
|
||||
title: widget.title ?? null,
|
||||
size_w: widget.sizeW,
|
||||
size_h: widget.sizeH,
|
||||
position: widget.position,
|
||||
updated_at: updatedAt,
|
||||
})
|
||||
.where("session_key", "=", snapshot.sessionKey)
|
||||
.where("name", "=", widget.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteRemovedWidgets(
|
||||
database: BoardDatabaseHandle,
|
||||
previous: StoredBoard,
|
||||
next: BoardSnapshot,
|
||||
): void {
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
|
||||
const widgetNames = new Set(next.widgets.map((widget) => widget.name));
|
||||
for (const row of previous.widgetRows) {
|
||||
if (!widgetNames.has(row.name)) {
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.deleteFrom("board_widgets")
|
||||
.where("session_key", "=", next.sessionKey)
|
||||
.where("name", "=", row.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deleteRemovedTabs(
|
||||
database: BoardDatabaseHandle,
|
||||
previous: StoredBoard,
|
||||
next: BoardSnapshot,
|
||||
): void {
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
|
||||
const tabIds = new Set(next.tabs.map((tab) => tab.tabId));
|
||||
for (const row of previous.tabRows) {
|
||||
if (!tabIds.has(row.tab_id)) {
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.deleteFrom("board_tabs")
|
||||
.where("session_key", "=", next.sessionKey)
|
||||
.where("tab_id", "=", row.tab_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function contentFields(
|
||||
params: BoardWidgetPutParams,
|
||||
revision: number,
|
||||
grantState: BoardWidget["grantState"],
|
||||
viewGeneration: string,
|
||||
now: number,
|
||||
) {
|
||||
const manifest = JSON.stringify(params.declared ?? {});
|
||||
if (params.content.kind === "html") {
|
||||
const sha256 = createHash("sha256").update(params.content.html).digest("hex");
|
||||
return {
|
||||
content_kind: "html",
|
||||
html: Buffer.from(params.content.html, "utf8"),
|
||||
descriptor_json: null,
|
||||
sha256,
|
||||
view_generation: viewGeneration,
|
||||
revision,
|
||||
manifest,
|
||||
grant_state: grantState,
|
||||
granted_sha: grantState === "granted" ? sha256 : null,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
const descriptorJson = JSON.stringify(params.content.descriptor);
|
||||
const sha256 = createHash("sha256").update(descriptorJson).digest("hex");
|
||||
return {
|
||||
content_kind: "mcp-app",
|
||||
html: null,
|
||||
descriptor_json: descriptorJson,
|
||||
sha256,
|
||||
view_generation: null,
|
||||
revision,
|
||||
manifest,
|
||||
grant_state: grantState,
|
||||
granted_sha: grantState === "granted" ? sha256 : null,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
function hasSession(database: BoardDatabaseHandle, sessionKey: string): boolean {
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
|
||||
return Boolean(
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.selectFrom("session_entries")
|
||||
.select("session_key")
|
||||
.where("session_key", "=", sessionKey)
|
||||
.limit(1),
|
||||
).rows[0],
|
||||
);
|
||||
}
|
||||
|
||||
function emptyBoardSnapshot(sessionKey: string): BoardSnapshot {
|
||||
return { sessionKey, revision: 0, tabs: [], widgets: [] };
|
||||
}
|
||||
|
||||
export class SqliteBoardStore implements BoardStore {
|
||||
constructor(private readonly options: SqliteBoardStoreOptions) {}
|
||||
|
||||
private resolve(sessionKey: string): { agentId: string; path?: string; sessionKey: string } {
|
||||
return this.options.resolveSession(sessionKey);
|
||||
}
|
||||
|
||||
private requireExistingSession(resolved: {
|
||||
agentId: string;
|
||||
path?: string;
|
||||
sessionKey: string;
|
||||
}): void {
|
||||
const result = withOpenClawAgentDatabaseReadOnly(
|
||||
(database) => hasSession(database, resolved.sessionKey),
|
||||
{
|
||||
agentId: resolved.agentId,
|
||||
...(resolved.path ? { path: resolved.path } : {}),
|
||||
env: this.options.env,
|
||||
},
|
||||
);
|
||||
if (!result.found || !result.value) {
|
||||
throw new BoardValidationError(
|
||||
"not_found",
|
||||
`board session not found: ${resolved.sessionKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private prepareWrite(sessionKey: string): {
|
||||
database: OpenClawAgentDatabase;
|
||||
resolved: { agentId: string; path?: string; sessionKey: string };
|
||||
} {
|
||||
const resolved = this.resolve(sessionKey);
|
||||
this.requireExistingSession(resolved);
|
||||
const database = openOpenClawAgentDatabase({
|
||||
agentId: resolved.agentId,
|
||||
...(resolved.path ? { path: resolved.path } : {}),
|
||||
env: this.options.env,
|
||||
});
|
||||
ensureBoardSchema(database);
|
||||
return { database, resolved };
|
||||
}
|
||||
|
||||
getSnapshot(sessionKey: string): BoardSnapshot {
|
||||
const resolved = this.resolve(sessionKey);
|
||||
const result = withOpenClawAgentDatabaseReadOnly(
|
||||
(database) =>
|
||||
hasSession(database, resolved.sessionKey)
|
||||
? readStoredBoard(database, resolved.sessionKey).snapshot
|
||||
: undefined,
|
||||
{
|
||||
agentId: resolved.agentId,
|
||||
...(resolved.path ? { path: resolved.path } : {}),
|
||||
env: this.options.env,
|
||||
},
|
||||
);
|
||||
return cloneBoardSnapshot(
|
||||
result.found && result.value ? result.value : emptyBoardSnapshot(resolved.sessionKey),
|
||||
);
|
||||
}
|
||||
|
||||
applyOps(sessionKey: string, ops: readonly BoardOp[]): BoardSnapshot {
|
||||
if (ops.length === 0) {
|
||||
return this.getSnapshot(sessionKey);
|
||||
}
|
||||
const { database, resolved } = this.prepareWrite(sessionKey);
|
||||
return runOpenClawAgentWriteTransaction(
|
||||
(transactionDatabase) => {
|
||||
if (!hasSession(transactionDatabase, resolved.sessionKey)) {
|
||||
throw new BoardValidationError(
|
||||
"not_found",
|
||||
`board session not found: ${resolved.sessionKey}`,
|
||||
);
|
||||
}
|
||||
const previous = readStoredBoard(transactionDatabase, resolved.sessionKey);
|
||||
const layout = applyBoardOps(previous.snapshot, ops);
|
||||
const next: BoardSnapshot = {
|
||||
sessionKey: resolved.sessionKey,
|
||||
revision: previous.snapshot.revision + 1,
|
||||
...layout,
|
||||
};
|
||||
const now = Date.now();
|
||||
upsertTabs(transactionDatabase, previous, next);
|
||||
deleteRemovedWidgets(transactionDatabase, previous, next);
|
||||
updateWidgetLayouts(transactionDatabase, next, now);
|
||||
deleteRemovedTabs(transactionDatabase, previous, next);
|
||||
return cloneBoardSnapshot(next);
|
||||
},
|
||||
{ agentId: resolved.agentId, path: database.path, env: this.options.env },
|
||||
{ operationLabel: "board.apply-ops" },
|
||||
);
|
||||
}
|
||||
|
||||
putWidget(params: BoardWidgetPutParams): BoardSnapshot {
|
||||
const { database, resolved } = this.prepareWrite(params.sessionKey);
|
||||
const canonicalParams = { ...params, sessionKey: resolved.sessionKey };
|
||||
const viewGeneration = randomBytes(16).toString("hex");
|
||||
return runOpenClawAgentWriteTransaction(
|
||||
(transactionDatabase) => {
|
||||
if (!hasSession(transactionDatabase, resolved.sessionKey)) {
|
||||
throw new BoardValidationError(
|
||||
"not_found",
|
||||
`board session not found: ${resolved.sessionKey}`,
|
||||
);
|
||||
}
|
||||
const previous = readStoredBoard(transactionDatabase, resolved.sessionKey);
|
||||
const next = createBoardWidgetPutSnapshot(previous.snapshot, canonicalParams);
|
||||
const widget = next.widgets.find((candidate) => candidate.name === canonicalParams.name)!;
|
||||
const existing = previous.widgetRows.find((row) => row.name === canonicalParams.name);
|
||||
const now = Date.now();
|
||||
upsertTabs(transactionDatabase, previous, next);
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(transactionDatabase.db);
|
||||
const fields = contentFields(
|
||||
canonicalParams,
|
||||
widget.revision,
|
||||
widget.grantState,
|
||||
viewGeneration,
|
||||
now,
|
||||
);
|
||||
executeSqliteQuerySync(
|
||||
transactionDatabase.db,
|
||||
db
|
||||
.insertInto("board_widgets")
|
||||
.values({
|
||||
session_key: resolved.sessionKey,
|
||||
name: canonicalParams.name,
|
||||
tab_id: widget.tabId,
|
||||
title: widget.title ?? null,
|
||||
size_w: widget.sizeW,
|
||||
size_h: widget.sizeH,
|
||||
position: widget.position,
|
||||
created_by: existing?.created_by ?? "agent",
|
||||
created_at: existing?.created_at ?? now,
|
||||
...fields,
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.columns(["session_key", "name"]).doUpdateSet({
|
||||
tab_id: widget.tabId,
|
||||
title: widget.title ?? null,
|
||||
size_w: widget.sizeW,
|
||||
size_h: widget.sizeH,
|
||||
position: widget.position,
|
||||
...fields,
|
||||
}),
|
||||
),
|
||||
);
|
||||
updateWidgetLayouts(transactionDatabase, next, now);
|
||||
return cloneBoardSnapshot(next);
|
||||
},
|
||||
{ agentId: resolved.agentId, path: database.path, env: this.options.env },
|
||||
{ operationLabel: "board.put-widget" },
|
||||
);
|
||||
}
|
||||
|
||||
grant(
|
||||
sessionKey: string,
|
||||
name: string,
|
||||
decision: "granted" | "rejected",
|
||||
revision: number,
|
||||
): BoardSnapshot {
|
||||
const { database, resolved } = this.prepareWrite(sessionKey);
|
||||
return runOpenClawAgentWriteTransaction(
|
||||
(transactionDatabase) => {
|
||||
if (!hasSession(transactionDatabase, resolved.sessionKey)) {
|
||||
throw new BoardValidationError(
|
||||
"not_found",
|
||||
`board session not found: ${resolved.sessionKey}`,
|
||||
);
|
||||
}
|
||||
const previous = readStoredBoard(transactionDatabase, resolved.sessionKey);
|
||||
const next = createBoardGrantSnapshot(previous.snapshot, name, decision, revision);
|
||||
upsertTabs(transactionDatabase, previous, next);
|
||||
const row = previous.widgetRows.find((candidate) => candidate.name === name)!;
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(transactionDatabase.db);
|
||||
executeSqliteQuerySync(
|
||||
transactionDatabase.db,
|
||||
db
|
||||
.updateTable("board_widgets")
|
||||
.set({
|
||||
grant_state: decision,
|
||||
granted_sha: decision === "granted" ? row.sha256 : null,
|
||||
updated_at: Date.now(),
|
||||
})
|
||||
.where("session_key", "=", resolved.sessionKey)
|
||||
.where("name", "=", name),
|
||||
);
|
||||
return cloneBoardSnapshot(next);
|
||||
},
|
||||
{ agentId: resolved.agentId, path: database.path, env: this.options.env },
|
||||
{ operationLabel: "board.grant-widget" },
|
||||
);
|
||||
}
|
||||
|
||||
readWidgetHtml(sessionKey: string, name: string): BoardWidgetDocument | undefined {
|
||||
const resolved = this.resolve(sessionKey);
|
||||
const result = withOpenClawAgentDatabaseReadOnly(
|
||||
(database) => {
|
||||
if (!hasSession(database, resolved.sessionKey)) {
|
||||
return undefined;
|
||||
}
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
|
||||
const row = executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.selectFrom("board_widgets")
|
||||
.select([
|
||||
"content_kind",
|
||||
"html",
|
||||
"descriptor_json",
|
||||
"revision",
|
||||
"sha256",
|
||||
"view_generation",
|
||||
"grant_state",
|
||||
])
|
||||
.where("session_key", "=", resolved.sessionKey)
|
||||
.where("name", "=", name)
|
||||
.limit(1),
|
||||
).rows[0];
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
if (row.content_kind === "html" && row.html !== null && row.view_generation !== null) {
|
||||
return {
|
||||
html: Buffer.from(row.html).toString("utf8"),
|
||||
revision: row.revision,
|
||||
sha256: row.sha256,
|
||||
viewGeneration: row.view_generation,
|
||||
grantState: row.grant_state as BoardWidget["grantState"],
|
||||
};
|
||||
}
|
||||
if (row.content_kind === "mcp-app" && row.descriptor_json !== null) {
|
||||
return { descriptor: parseDescriptor(row.descriptor_json), revision: row.revision };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
{
|
||||
agentId: resolved.agentId,
|
||||
...(resolved.path ? { path: resolved.path } : {}),
|
||||
env: this.options.env,
|
||||
},
|
||||
);
|
||||
return result.found ? result.value : undefined;
|
||||
}
|
||||
|
||||
listSessionsWithBoards(): string[] {
|
||||
const sessionKeys = new Set<string>();
|
||||
const agentIds = new Set(
|
||||
listOpenClawRegisteredAgentDatabases({ env: this.options.env }).map(
|
||||
(registered) => registered.agentId,
|
||||
),
|
||||
);
|
||||
for (const agentId of agentIds) {
|
||||
const canonicalPath =
|
||||
this.resolve(`agent:${agentId}:main`).path ??
|
||||
resolveOpenClawAgentSqlitePath({ agentId, env: this.options.env });
|
||||
const result = withOpenClawAgentDatabaseReadOnly(
|
||||
(database) => {
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
|
||||
return executeSqliteQuerySync(
|
||||
database.db,
|
||||
db.selectFrom("board_tabs").select("session_key").distinct(),
|
||||
).rows;
|
||||
},
|
||||
{ agentId, path: canonicalPath, env: this.options.env },
|
||||
);
|
||||
if (result.found) {
|
||||
for (const row of result.value) {
|
||||
sessionKeys.add(row.session_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...sessionKeys].toSorted();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
|
||||
import {
|
||||
isAgentHarnessSessionKey,
|
||||
isValidAgentHarnessSessionStoreEntry,
|
||||
@@ -6,9 +7,11 @@ import {
|
||||
resolveAgentHarnessSessionStoreEntryError,
|
||||
} from "../../sessions/agent-harness-session-key.js";
|
||||
import { emitSessionIdentityMutation } from "../../sessions/session-lifecycle-events.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js";
|
||||
import {
|
||||
openOpenClawAgentDatabase,
|
||||
runOpenClawAgentWriteTransaction,
|
||||
type OpenClawAgentDatabase,
|
||||
} from "../../state/openclaw-agent-db.js";
|
||||
import { materializeSqliteSessionStateDeletePlans } from "./session-accessor.sqlite-archive.js";
|
||||
import type {
|
||||
@@ -50,6 +53,44 @@ import type { SessionEntry } from "./types.js";
|
||||
|
||||
// Single-target lifecycle owner: cleanup, reset, guarded delete, and trusted rollback.
|
||||
|
||||
type SessionBoardCleanupDatabase = Pick<
|
||||
OpenClawAgentKyselyDatabase,
|
||||
"board_tabs" | "board_widgets"
|
||||
> & {
|
||||
sqlite_schema: {
|
||||
name: string | null;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
function deleteSessionBoardRows(
|
||||
database: OpenClawAgentDatabase,
|
||||
sessionKeys: readonly string[],
|
||||
): void {
|
||||
const keys = [...new Set(sessionKeys)];
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
const db = getNodeSqliteKysely<SessionBoardCleanupDatabase>(database.db);
|
||||
const tableRows = executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.selectFrom("sqlite_schema")
|
||||
.select("name")
|
||||
.where("type", "=", "table")
|
||||
.where("name", "in", ["board_tabs", "board_widgets"]),
|
||||
).rows;
|
||||
const tables = new Set(tableRows.map((row) => row.name));
|
||||
if (!tables.has("board_tabs") || !tables.has("board_widgets")) {
|
||||
return;
|
||||
}
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
db.deleteFrom("board_widgets").where("session_key", "in", keys),
|
||||
);
|
||||
executeSqliteQuerySync(database.db, db.deleteFrom("board_tabs").where("session_key", "in", keys));
|
||||
}
|
||||
|
||||
export async function cleanupSqliteSessionLifecycleArtifacts(
|
||||
params: SessionLifecycleArtifactCleanupParams,
|
||||
): Promise<SessionLifecycleArtifactCleanupResult> {
|
||||
@@ -230,6 +271,11 @@ async function deleteSqliteSessionEntryLifecycleInternal(
|
||||
return;
|
||||
}
|
||||
deleteSqliteLifecycleTargetRows(transactionDb, params.target);
|
||||
deleteSessionBoardRows(transactionDb, [
|
||||
params.target.canonicalKey,
|
||||
...params.target.storeKeys,
|
||||
...transactionSnapshot.rows.map((row) => row.sessionKey),
|
||||
]);
|
||||
const archivedTranscripts = deleteMaterializedSqliteSessionStatePlans(
|
||||
transactionDb,
|
||||
materializedPlans,
|
||||
|
||||
259
src/gateway/board-http.test.ts
Normal file
259
src/gateway/board-http.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { InMemoryBoardStore } from "../boards/board-store.js";
|
||||
import { handleBoardHttpRequest } from "./board-http.js";
|
||||
import {
|
||||
BOARD_VIEW_TICKET_TTL_MS,
|
||||
createBoardViewTicket,
|
||||
verifyBoardViewTicket,
|
||||
} from "./board-view-ticket.js";
|
||||
|
||||
const store = new InMemoryBoardStore();
|
||||
const nowMs = 1_800_000_000_000;
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "status",
|
||||
content: { kind: "html", html: "<!doctype html><p>Status</p>" },
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "pending",
|
||||
content: { kind: "html", html: "pending" },
|
||||
declared: { tools: ["pending.read"] },
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "rejected",
|
||||
content: { kind: "html", html: "rejected" },
|
||||
declared: { tools: ["rejected.read"] },
|
||||
});
|
||||
store.grant("agent:main:main", "rejected", "rejected", 1);
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "mcp",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "origin",
|
||||
toolCallId: "call",
|
||||
},
|
||||
},
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "revisioned",
|
||||
content: { kind: "html", html: "<p>one</p>" },
|
||||
});
|
||||
server = createServer((req, res) => {
|
||||
const handled = handleBoardHttpRequest(req, res, {
|
||||
store,
|
||||
nowMs,
|
||||
});
|
||||
if (!handled) {
|
||||
res.statusCode = 404;
|
||||
res.end("unhandled");
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function ticketFor(name: string, revision = 1, issuedAtMs = nowMs): string {
|
||||
const document = store.readWidgetHtml("agent:main:main", name);
|
||||
if (!document || !("html" in document)) {
|
||||
throw new Error(`missing HTML widget: ${name}`);
|
||||
}
|
||||
return createBoardViewTicket({
|
||||
sessionKey: "agent:main:main",
|
||||
name,
|
||||
revision,
|
||||
viewGeneration: document.viewGeneration,
|
||||
nowMs: issuedAtMs,
|
||||
}).ticket;
|
||||
}
|
||||
|
||||
function request(
|
||||
name: string,
|
||||
init: { method?: string; headers?: Record<string, string>; ticket?: string } = {},
|
||||
) {
|
||||
const query = init.ticket ? `?bt=${encodeURIComponent(init.ticket)}` : "";
|
||||
return fetch(`${baseUrl}/__openclaw__/board/agent%3Amain%3Amain/${name}/index.html${query}`, {
|
||||
method: init.method,
|
||||
headers: init.headers,
|
||||
});
|
||||
}
|
||||
|
||||
describe("board widget HTTP", () => {
|
||||
it("round-trips self-contained claims covered by a two-minute HMAC ticket", () => {
|
||||
const document = store.readWidgetHtml("agent:main:main", "status");
|
||||
if (!document || !("html" in document)) {
|
||||
throw new Error("missing status widget");
|
||||
}
|
||||
const issued = createBoardViewTicket({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "status",
|
||||
revision: 1,
|
||||
viewGeneration: document.viewGeneration,
|
||||
nowMs,
|
||||
});
|
||||
expect(issued.ticket).toMatch(/^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u);
|
||||
expect(issued.ticket).not.toContain("agent:main:main");
|
||||
expect(issued.expiresAtMs).toBe(nowMs + BOARD_VIEW_TICKET_TTL_MS);
|
||||
expect(verifyBoardViewTicket(issued.ticket, { nowMs })).toEqual({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "status",
|
||||
revision: 1,
|
||||
viewGeneration: document.viewGeneration,
|
||||
expiresAtMs: issued.expiresAtMs,
|
||||
nonce: expect.stringMatching(/^[A-Za-z0-9_-]{32}$/u),
|
||||
});
|
||||
});
|
||||
|
||||
it("serves HTML bytes with a valid ticket and no gateway auth", async () => {
|
||||
const response = await request("status", { ticket: ticketFor("status") });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(response.headers.get("content-security-policy")).toBe("sandbox allow-scripts");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
await expect(response.text()).resolves.toBe("<!doctype html><p>Status</p>");
|
||||
});
|
||||
|
||||
it("does not require or inspect an operator token", async () => {
|
||||
const response = await request("status", {
|
||||
ticket: ticketFor("status"),
|
||||
headers: { Authorization: "Bearer test-token" },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects garbage and expired tickets before reading the store", async () => {
|
||||
const expired = ticketFor("status", 1, nowMs - BOARD_VIEW_TICKET_TTL_MS - 1);
|
||||
const valid = ticketFor("status");
|
||||
const readSpy = vi.spyOn(store, "readWidgetHtml");
|
||||
expect((await request("status")).status).toBe(401);
|
||||
expect((await request("status", { ticket: "garbage" })).status).toBe(401);
|
||||
expect((await request("status", { ticket: `${valid.slice(0, -1)}x` })).status).toBe(401);
|
||||
expect((await request("status", { ticket: expired })).status).toBe(401);
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
readSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("rejects a ticket after the widget revision changes", async () => {
|
||||
const stale = ticketFor("revisioned");
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "revisioned",
|
||||
content: { kind: "html", html: "<p>two</p>" },
|
||||
});
|
||||
expect((await request("revisioned", { ticket: stale })).status).toBe(401);
|
||||
const current = await request("revisioned", { ticket: ticketFor("revisioned", 2) });
|
||||
expect(current.status).toBe(200);
|
||||
await expect(current.text()).resolves.toBe("<p>two</p>");
|
||||
});
|
||||
|
||||
it("rejects a stale ticket when a widget name and revision are reused", async () => {
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "recreated",
|
||||
content: { kind: "html", html: "<p>old</p>" },
|
||||
});
|
||||
const stale = ticketFor("recreated");
|
||||
store.applyOps("agent:main:main", [{ kind: "widget_remove", name: "recreated" }]);
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "recreated",
|
||||
content: { kind: "html", html: "<p>old</p>" },
|
||||
});
|
||||
|
||||
expect((await request("recreated", { ticket: stale })).status).toBe(401);
|
||||
expect((await request("recreated", { ticket: ticketFor("recreated") })).status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a ticket with a stale view generation", async () => {
|
||||
const ticket = createBoardViewTicket({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "status",
|
||||
revision: 1,
|
||||
viewGeneration: "0".repeat(32),
|
||||
nowMs,
|
||||
}).ticket;
|
||||
expect((await request("status", { ticket })).status).toBe(401);
|
||||
});
|
||||
|
||||
it("refuses pending and rejected widgets even with valid tickets", async () => {
|
||||
expect((await request("pending", { ticket: ticketFor("pending") })).status).toBe(401);
|
||||
expect((await request("rejected", { ticket: ticketFor("rejected") })).status).toBe(401);
|
||||
});
|
||||
|
||||
it("serves an encoded slash as part of an opaque session key", async () => {
|
||||
store.putWidget({
|
||||
sessionKey: "session/with/slash",
|
||||
name: "slash-key",
|
||||
content: { kind: "html", html: "slash" },
|
||||
});
|
||||
const document = store.readWidgetHtml("session/with/slash", "slash-key");
|
||||
if (!document || !("html" in document)) {
|
||||
throw new Error("missing slash-key widget");
|
||||
}
|
||||
const ticket = createBoardViewTicket({
|
||||
sessionKey: "session/with/slash",
|
||||
name: "slash-key",
|
||||
revision: 1,
|
||||
viewGeneration: document.viewGeneration,
|
||||
nowMs,
|
||||
}).ticket;
|
||||
const response = await fetch(
|
||||
`${baseUrl}/__openclaw__/board/session%2Fwith%2Fslash/slash-key/index.html?bt=${encodeURIComponent(ticket)}`,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("slash");
|
||||
});
|
||||
|
||||
it("returns 401 when valid claims have no matching HTML document", async () => {
|
||||
const ticket = createBoardViewTicket({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "missing",
|
||||
revision: 1,
|
||||
viewGeneration: "0".repeat(32),
|
||||
nowMs,
|
||||
}).ticket;
|
||||
expect((await request("missing", { ticket })).status).toBe(401);
|
||||
const mcpTicket = createBoardViewTicket({
|
||||
sessionKey: "agent:main:main",
|
||||
name: "mcp",
|
||||
revision: 1,
|
||||
viewGeneration: "0".repeat(32),
|
||||
nowMs,
|
||||
}).ticket;
|
||||
expect((await request("mcp", { ticket: mcpTicket })).status).toBe(401);
|
||||
});
|
||||
|
||||
it("allows GET only", async () => {
|
||||
const response = await request("status", { method: "POST", ticket: ticketFor("status") });
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET");
|
||||
});
|
||||
});
|
||||
85
src/gateway/board-http.ts
Normal file
85
src/gateway/board-http.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { BoardStore } from "../boards/board-store.js";
|
||||
import { boardStore } from "./board-store.js";
|
||||
import { BOARD_HTTP_PATH_PREFIX, verifyBoardViewTicket } from "./board-view-ticket.js";
|
||||
import { sendMethodNotAllowed } from "./http-common.js";
|
||||
|
||||
const BOARD_WIDGET_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
|
||||
type BoardHttpOptions = {
|
||||
store?: BoardStore;
|
||||
nowMs?: number;
|
||||
};
|
||||
|
||||
function sendNotFound(res: ServerResponse): void {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
}
|
||||
|
||||
function sendUnauthorized(res: ServerResponse): void {
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Unauthorized");
|
||||
}
|
||||
|
||||
function parseBoardWidgetPath(pathname: string): { sessionKey: string; name: string } | undefined {
|
||||
const match = /^\/__openclaw__\/board\/([^/]+)\/([^/]+)\/index\.html$/.exec(pathname);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const sessionKey = decodeURIComponent(match[1]!);
|
||||
const name = decodeURIComponent(match[2]!);
|
||||
if (!sessionKey || !BOARD_WIDGET_NAME_PATTERN.test(name)) {
|
||||
return undefined;
|
||||
}
|
||||
return { sessionKey, name };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleBoardHttpRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
opts: BoardHttpOptions = {},
|
||||
): boolean {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const pathname = url.pathname;
|
||||
if (!pathname.startsWith(BOARD_HTTP_PATH_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
if (req.method !== "GET") {
|
||||
sendMethodNotAllowed(res, "GET");
|
||||
return true;
|
||||
}
|
||||
const path = parseBoardWidgetPath(pathname);
|
||||
if (!path) {
|
||||
sendNotFound(res);
|
||||
return true;
|
||||
}
|
||||
const ticket = url.searchParams.get("bt");
|
||||
const claims = ticket ? verifyBoardViewTicket(ticket, { nowMs: opts.nowMs }) : undefined;
|
||||
if (!claims || claims.sessionKey !== path.sessionKey || claims.name !== path.name) {
|
||||
sendUnauthorized(res);
|
||||
return true;
|
||||
}
|
||||
const document = (opts.store ?? boardStore).readWidgetHtml(path.sessionKey, path.name);
|
||||
if (
|
||||
!document ||
|
||||
!("html" in document) ||
|
||||
(document.grantState !== "none" && document.grantState !== "granted") ||
|
||||
document.revision !== claims.revision ||
|
||||
document.viewGeneration !== claims.viewGeneration
|
||||
) {
|
||||
sendUnauthorized(res);
|
||||
return true;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Content-Security-Policy", "sandbox allow-scripts");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.end(document.html);
|
||||
return true;
|
||||
}
|
||||
20
src/gateway/board-store.ts
Normal file
20
src/gateway/board-store.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { SqliteBoardStore } from "../boards/sqlite-board-store.js";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
|
||||
import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js";
|
||||
|
||||
export const boardStore = new SqliteBoardStore({
|
||||
resolveSession: (sessionKey) => {
|
||||
const cfg = getRuntimeConfig();
|
||||
const canonicalSessionKey = resolveSessionStoreKey({ cfg, sessionKey });
|
||||
const agentId = resolveSessionStoreAgentId(cfg, canonicalSessionKey);
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
const databasePath = resolveSqliteTargetFromSessionStorePath(storePath, { agentId }).path;
|
||||
return {
|
||||
agentId,
|
||||
...(databasePath ? { path: databasePath } : {}),
|
||||
sessionKey: canonicalSessionKey,
|
||||
};
|
||||
},
|
||||
});
|
||||
118
src/gateway/board-view-ticket.ts
Normal file
118
src/gateway/board-view-ticket.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { safeEqualSecret } from "../security/secret-equal.js";
|
||||
|
||||
export const BOARD_HTTP_PATH_PREFIX = "/__openclaw__/board/";
|
||||
export const BOARD_VIEW_TICKET_TTL_MS = 2 * 60_000;
|
||||
|
||||
const BOARD_VIEW_TICKET_SCOPE = "board-widget-view";
|
||||
const BOARD_VIEW_TICKET_MAX_LENGTH = 2_048;
|
||||
const ticketSecret = randomBytes(32);
|
||||
|
||||
type BoardViewTicket = {
|
||||
ticket: string;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
type BoardViewTicketClaims = {
|
||||
sessionKey: string;
|
||||
name: string;
|
||||
revision: number;
|
||||
viewGeneration: string;
|
||||
expiresAtMs: number;
|
||||
nonce: string;
|
||||
};
|
||||
|
||||
function signTicketPayload(payload: string, secret: Buffer): string {
|
||||
return createHmac("sha256", secret)
|
||||
.update(`${BOARD_VIEW_TICKET_SCOPE}\0${payload}`)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
function isValidClaims(value: unknown): value is BoardViewTicketClaims {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const claims = value as Partial<BoardViewTicketClaims>;
|
||||
return (
|
||||
typeof claims.sessionKey === "string" &&
|
||||
claims.sessionKey.length > 0 &&
|
||||
claims.sessionKey.length <= 512 &&
|
||||
typeof claims.name === "string" &&
|
||||
claims.name.length > 0 &&
|
||||
claims.name.length <= 64 &&
|
||||
Number.isSafeInteger(claims.revision) &&
|
||||
(claims.revision ?? 0) >= 1 &&
|
||||
typeof claims.viewGeneration === "string" &&
|
||||
/^[a-f0-9]{32}$/u.test(claims.viewGeneration) &&
|
||||
Number.isSafeInteger(claims.expiresAtMs) &&
|
||||
typeof claims.nonce === "string" &&
|
||||
/^[A-Za-z0-9_-]{32}$/u.test(claims.nonce)
|
||||
);
|
||||
}
|
||||
|
||||
export function createBoardViewTicket(params: {
|
||||
sessionKey: string;
|
||||
name: string;
|
||||
revision: number;
|
||||
viewGeneration: string;
|
||||
nowMs?: number;
|
||||
}): BoardViewTicket {
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const claims: BoardViewTicketClaims = {
|
||||
sessionKey: params.sessionKey,
|
||||
name: params.name,
|
||||
revision: params.revision,
|
||||
viewGeneration: params.viewGeneration,
|
||||
expiresAtMs: nowMs + BOARD_VIEW_TICKET_TTL_MS,
|
||||
nonce: randomBytes(24).toString("base64url"),
|
||||
};
|
||||
if (!Number.isSafeInteger(nowMs) || !isValidClaims(claims)) {
|
||||
throw new Error("invalid board view ticket binding");
|
||||
}
|
||||
const payload = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
|
||||
const signature = signTicketPayload(payload, ticketSecret);
|
||||
return {
|
||||
ticket: `v1.${payload}.${signature}`,
|
||||
expiresAtMs: claims.expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyBoardViewTicket(
|
||||
value: string,
|
||||
options: { nowMs?: number } = {},
|
||||
): BoardViewTicketClaims | undefined {
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
if (!Number.isSafeInteger(nowMs) || value.length > BOARD_VIEW_TICKET_MAX_LENGTH) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = value.split(".");
|
||||
if (parts.length !== 3 || parts[0] !== "v1") {
|
||||
return undefined;
|
||||
}
|
||||
const [, payload, signature] = parts;
|
||||
if (!payload || !signature) {
|
||||
return undefined;
|
||||
}
|
||||
const expectedSignature = signTicketPayload(payload, ticketSecret);
|
||||
if (!safeEqualSecret(signature, expectedSignature)) {
|
||||
return undefined;
|
||||
}
|
||||
let claims: unknown;
|
||||
try {
|
||||
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!isValidClaims(claims) || claims.expiresAtMs <= nowMs) {
|
||||
return undefined;
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
export function buildBoardWidgetFrameUrl(params: {
|
||||
sessionKey: string;
|
||||
name: string;
|
||||
ticket: string;
|
||||
}): string {
|
||||
return `${BOARD_HTTP_PATH_PREFIX}${encodeURIComponent(params.sessionKey)}/${encodeURIComponent(params.name)}/index.html?bt=${encodeURIComponent(params.ticket)}`;
|
||||
}
|
||||
@@ -115,6 +115,11 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "mcp.app.listResourceTemplates", scope: "operator.read" },
|
||||
{ name: "mcp.app.readResource", scope: "operator.read" },
|
||||
{ name: "mcp.app.callTool", scope: "operator.write" },
|
||||
{ name: "board.get", scope: "operator.read" },
|
||||
{ name: "board.update", scope: "operator.write" },
|
||||
{ name: "board.widget.put", scope: "operator.write" },
|
||||
{ name: "board.widget.grant", scope: "operator.approvals" },
|
||||
{ name: "board.event", scope: "operator.write" },
|
||||
{ name: "audit.list", scope: "operator.read" },
|
||||
{ name: "audit.activity.list", scope: "operator.read" },
|
||||
{ name: "tasks.list", scope: "operator.read" },
|
||||
|
||||
59
src/gateway/server-broadcast.board.test.ts
Normal file
59
src/gateway/server-broadcast.board.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
|
||||
type RecordingSocket = {
|
||||
bufferedAmount: number;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
events: string[];
|
||||
};
|
||||
|
||||
function makeClient(
|
||||
connId: string,
|
||||
role: "node" | "operator",
|
||||
scopes: string[],
|
||||
): { client: GatewayWsClient; socket: RecordingSocket } {
|
||||
const events: string[] = [];
|
||||
const socket: RecordingSocket = {
|
||||
bufferedAmount: 0,
|
||||
close: vi.fn(),
|
||||
send: vi.fn((payload: string) => {
|
||||
events.push((JSON.parse(payload) as { event: string }).event);
|
||||
}),
|
||||
events,
|
||||
};
|
||||
return {
|
||||
client: {
|
||||
socket: socket as unknown as GatewayWsClient["socket"],
|
||||
connect: { role, scopes } as GatewayWsClient["connect"],
|
||||
connId,
|
||||
usesSharedGatewayAuth: false,
|
||||
},
|
||||
socket,
|
||||
};
|
||||
}
|
||||
|
||||
describe("board event scope guards", () => {
|
||||
it("delivers board events only to read-capable operators", () => {
|
||||
const pairing = makeClient("pairing", "operator", ["operator.pairing"]);
|
||||
const node = makeClient("node", "node", ["operator.read"]);
|
||||
const read = makeClient("read", "operator", ["operator.read"]);
|
||||
const write = makeClient("write", "operator", ["operator.write"]);
|
||||
const admin = makeClient("admin", "operator", ["operator.admin"]);
|
||||
const clients = new Set([pairing, node, read, write, admin].map((entry) => entry.client));
|
||||
const { broadcast } = createGatewayBroadcaster({ clients });
|
||||
|
||||
broadcast("board.changed", { sessionKey: "agent:main:main", revision: 1 });
|
||||
broadcast("board.command", {
|
||||
sessionKey: "agent:main:main",
|
||||
command: { kind: "focus_tab", tabId: "main" },
|
||||
});
|
||||
|
||||
expect(pairing.socket.events).toEqual([]);
|
||||
expect(node.socket.events).toEqual([]);
|
||||
expect(read.socket.events).toEqual(["board.changed", "board.command"]);
|
||||
expect(write.socket.events).toEqual(["board.changed", "board.command"]);
|
||||
expect(admin.socket.events).toEqual(["board.changed", "board.command"]);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,8 @@ import { logWs, shouldLogWs, summarizeAgentEventForWsLog } from "./ws-log.js";
|
||||
const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
|
||||
agent: [READ_SCOPE],
|
||||
chat: [READ_SCOPE],
|
||||
"board.changed": [READ_SCOPE],
|
||||
"board.command": [READ_SCOPE],
|
||||
"ui.command": [READ_SCOPE],
|
||||
"chat.send_timing": [READ_SCOPE],
|
||||
"chat.side_result": [READ_SCOPE],
|
||||
|
||||
@@ -119,6 +119,14 @@ describe("gateway probe endpoints", () => {
|
||||
error: { code: "gateway_unavailable" },
|
||||
});
|
||||
|
||||
const blockedBoard = await sendGatewayRequest(server, {
|
||||
path: "/__openclaw__/board/agent%3Amain%3Amain/status/index.html?bt=garbage",
|
||||
});
|
||||
expect(blockedBoard.res.statusCode).toBe(503);
|
||||
expect(JSON.parse(blockedBoard.getBody())).toMatchObject({
|
||||
error: { code: "gateway_unavailable" },
|
||||
});
|
||||
|
||||
expect(resumeGatewaySuspend(prepared.suspensionId)).toEqual({
|
||||
ok: true,
|
||||
status: "running",
|
||||
|
||||
@@ -98,6 +98,8 @@ const getControlUiModule = createLazyRuntimeModule(() => import("./control-ui.js
|
||||
|
||||
const getCanvasServeModule = createLazyRuntimeModule(() => import("../canvas/serve.runtime.js"));
|
||||
|
||||
const getBoardHttpModule = createLazyRuntimeModule(() => import("./board-http.js"));
|
||||
|
||||
const getEmbeddingsHttpModule = createLazyRuntimeModule(() => import("./embeddings-http.js"));
|
||||
|
||||
const getManagedImageAttachmentsModule = createLazyRuntimeModule(
|
||||
@@ -193,6 +195,10 @@ function isMcpAppStandalonePath(pathname: string): boolean {
|
||||
return pathname === "/__openclaw__/mcp-app" || pathname === "/__openclaw__/mcp-app/view";
|
||||
}
|
||||
|
||||
function isBoardWidgetPath(pathname: string): boolean {
|
||||
return pathname.startsWith("/__openclaw__/board/");
|
||||
}
|
||||
|
||||
function isEmbeddingsPath(pathname: string): boolean {
|
||||
return pathname === "/v1/embeddings";
|
||||
}
|
||||
@@ -682,6 +688,15 @@ export function createGatewayHttpServer(opts: {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (isBoardWidgetPath(scopedRequestPath)) {
|
||||
requestStages.push({
|
||||
name: "board-widget",
|
||||
run: async () =>
|
||||
await runWithGatewayHttpWorkAdmission(res, async () =>
|
||||
(await getBoardHttpModule()).handleBoardHttpRequest(req, res),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (openResponsesEnabled && isOpenResponsesPath(scopedRequestPath)) {
|
||||
requestStages.push({
|
||||
name: "openresponses",
|
||||
|
||||
@@ -61,6 +61,10 @@ const loadArtifactsHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/artifacts.js"),
|
||||
(module) => module.artifactsHandlers,
|
||||
);
|
||||
const loadBoardHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/board.js"),
|
||||
(module) => module.boardHandlers,
|
||||
);
|
||||
const loadAuditHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/audit.js"),
|
||||
(module) => module.auditHandlers,
|
||||
@@ -372,6 +376,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
methods: ["ui.command"],
|
||||
loadHandlers: loadUiCommandHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: ["board.get", "board.update", "board.widget.put", "board.widget.grant", "board.event"],
|
||||
loadHandlers: loadBoardHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: ["voicewake.get", "voicewake.set"],
|
||||
loadHandlers: loadVoicewakeHandlers,
|
||||
|
||||
350
src/gateway/server-methods/board.test.ts
Normal file
350
src/gateway/server-methods/board.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardSnapshot } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import { resetBoardEventNoticeStateForTest } from "../../boards/board-notices.js";
|
||||
import { InMemoryBoardStore } from "../../boards/board-store.js";
|
||||
import { SqliteBoardStore } from "../../boards/sqlite-board-store.js";
|
||||
import { replaceSessionEntrySync } from "../../config/sessions/session-accessor.entry.js";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../../infra/system-events.js";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
openOpenClawAgentDatabase,
|
||||
} from "../../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
|
||||
import { resolveCoreOperatorGatewayMethodScope } from "../methods/core-descriptors.js";
|
||||
import { createBoardHandlers } from "./board.js";
|
||||
import { sessionMutationHandlers } from "./sessions-mutations.js";
|
||||
import type { GatewayRequestContext, RespondFn } from "./types.js";
|
||||
|
||||
vi.mock("./sessions.runtime.js", () => ({
|
||||
performGatewaySessionReset: vi.fn(async ({ key, reason }: { key: string; reason: string }) => ({
|
||||
ok: true,
|
||||
key,
|
||||
agentId: "main",
|
||||
entry: { sessionId: `reset-${reason}` },
|
||||
resolved: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
function createHarness() {
|
||||
const store = new InMemoryBoardStore();
|
||||
const broadcast = vi.fn();
|
||||
const handlers = createBoardHandlers(store);
|
||||
const invoke = async (method: string, params: Record<string, unknown>) => {
|
||||
const respond = vi.fn<RespondFn>();
|
||||
await handlers[method]!({
|
||||
req: { type: "req", id: "test", method, params },
|
||||
params,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: { broadcast } as unknown as GatewayRequestContext,
|
||||
});
|
||||
return respond;
|
||||
};
|
||||
return { store, broadcast, invoke };
|
||||
}
|
||||
|
||||
describe("board gateway methods", () => {
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
beforeEach(() => {
|
||||
resetBoardEventNoticeStateForTest();
|
||||
resetSystemEventsForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
});
|
||||
|
||||
it("registers every contract method with its required scope", () => {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
["board.get", "board.update", "board.widget.put", "board.widget.grant", "board.event"].map(
|
||||
(method) => [method, resolveCoreOperatorGatewayMethodScope(method)],
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
"board.get": "operator.read",
|
||||
"board.update": "operator.write",
|
||||
"board.widget.put": "operator.write",
|
||||
"board.widget.grant": "operator.approvals",
|
||||
"board.event": "operator.write",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed params before touching the store", async () => {
|
||||
const { invoke, store } = createHarness();
|
||||
const response = await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "Invalid Name",
|
||||
content: { kind: "html", html: "ok" },
|
||||
});
|
||||
expect(response).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "INVALID_REQUEST" }),
|
||||
);
|
||||
expect(store.listSessionsWithBoards()).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds fresh frame URLs only to admitted HTML widgets on board.get", async () => {
|
||||
const { invoke } = createHarness();
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "status",
|
||||
content: { kind: "html", html: "<p>ok</p>" },
|
||||
declared: {
|
||||
netOrigins: ["https://status.example"],
|
||||
tools: ["status.refresh"],
|
||||
},
|
||||
});
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "app",
|
||||
content: {
|
||||
kind: "mcp-app",
|
||||
descriptor: {
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
originSessionKey: "origin",
|
||||
toolCallId: "call",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pendingResponse = await invoke("board.get", { sessionKey: "agent:main:main" });
|
||||
const pending = pendingResponse.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
expect(pending.widgets.find((widget) => widget.name === "status")).not.toHaveProperty(
|
||||
"frameUrl",
|
||||
);
|
||||
|
||||
await invoke("board.widget.grant", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "status",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
});
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "rejected",
|
||||
content: { kind: "html", html: "<p>no</p>" },
|
||||
declared: { tools: ["status.reject"] },
|
||||
});
|
||||
await invoke("board.widget.grant", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "rejected",
|
||||
decision: "rejected",
|
||||
revision: 1,
|
||||
});
|
||||
|
||||
const firstResponse = await invoke("board.get", { sessionKey: "agent:main:main" });
|
||||
const first = firstResponse.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
const htmlFrameUrl = first.widgets.find((widget) => widget.name === "status")?.frameUrl;
|
||||
expect(htmlFrameUrl).toMatch(
|
||||
/^\/__openclaw__\/board\/agent%3Amain%3Amain\/status\/index\.html\?bt=v1\./u,
|
||||
);
|
||||
expect(first.widgets.find((widget) => widget.name === "status")?.declaredSummary).toEqual([
|
||||
"Network access: https://status.example",
|
||||
"Tool access: status.refresh",
|
||||
]);
|
||||
expect(first.widgets.find((widget) => widget.name === "app")).not.toHaveProperty("frameUrl");
|
||||
expect(first.widgets.find((widget) => widget.name === "rejected")).not.toHaveProperty(
|
||||
"frameUrl",
|
||||
);
|
||||
|
||||
const secondResponse = await invoke("board.get", { sessionKey: "agent:main:main" });
|
||||
const second = secondResponse.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
expect(second.widgets.find((widget) => widget.name === "status")?.frameUrl).not.toBe(
|
||||
htmlFrameUrl,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies updates and broadcasts board.changed", async () => {
|
||||
const { invoke, broadcast } = createHarness();
|
||||
const response = await invoke("board.update", {
|
||||
sessionKey: "session",
|
||||
ops: [{ kind: "tab_create", tabId: "notes", title: "Notes" }],
|
||||
});
|
||||
expect(response).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ sessionKey: "session", revision: 1 }),
|
||||
);
|
||||
expect(broadcast).toHaveBeenCalledWith("board.changed", {
|
||||
sessionKey: "session",
|
||||
revision: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("puts widgets, emits iframe-specific changes, and grants declared capabilities", async () => {
|
||||
const { invoke, broadcast } = createHarness();
|
||||
const put = await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "weather",
|
||||
content: { kind: "html", html: "<p>weather</p>" },
|
||||
declared: { tools: ["weather.refresh"] },
|
||||
});
|
||||
expect(put).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
widgets: [expect.objectContaining({ name: "weather", grantState: "pending" })],
|
||||
}),
|
||||
);
|
||||
expect(put.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
widgets: [expect.objectContaining({ declaredSummary: ["Tool access: weather.refresh"] })],
|
||||
}),
|
||||
);
|
||||
expect(broadcast).toHaveBeenCalledWith("board.changed", {
|
||||
sessionKey: "session",
|
||||
revision: 1,
|
||||
widget: "weather",
|
||||
});
|
||||
|
||||
const grant = await invoke("board.widget.grant", {
|
||||
sessionKey: "session",
|
||||
name: "weather",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
});
|
||||
expect(grant).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
revision: 2,
|
||||
widgets: [expect.objectContaining({ grantState: "granted" })],
|
||||
}),
|
||||
);
|
||||
expect(broadcast).toHaveBeenLastCalledWith("board.changed", {
|
||||
sessionKey: "session",
|
||||
revision: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports rejected grants and rejects grants from non-pending state", async () => {
|
||||
const { invoke } = createHarness();
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "widget",
|
||||
content: { kind: "html", html: "ok" },
|
||||
declared: { netOrigins: ["https://example.com"] },
|
||||
});
|
||||
const rejected = await invoke("board.widget.grant", {
|
||||
sessionKey: "session",
|
||||
name: "widget",
|
||||
decision: "rejected",
|
||||
revision: 1,
|
||||
});
|
||||
expect(rejected.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
widgets: [expect.objectContaining({ grantState: "rejected" })],
|
||||
}),
|
||||
);
|
||||
const repeated = await invoke("board.widget.grant", {
|
||||
sessionKey: "session",
|
||||
name: "widget",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
});
|
||||
expect(repeated.mock.calls[0]?.[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects stale grant revisions without changing the pending widget", async () => {
|
||||
const { invoke } = createHarness();
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "widget",
|
||||
content: { kind: "html", html: "ok" },
|
||||
declared: { tools: ["widget.read"] },
|
||||
});
|
||||
const stale = await invoke("board.widget.grant", {
|
||||
sessionKey: "session",
|
||||
name: "widget",
|
||||
decision: "granted",
|
||||
revision: 2,
|
||||
});
|
||||
expect(stale.mock.calls[0]?.[0]).toBe(false);
|
||||
const current = await invoke("board.get", { sessionKey: "session" });
|
||||
expect(current.mock.calls[0]?.[1]).toMatchObject({
|
||||
widgets: [{ name: "widget", revision: 1, grantState: "pending" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("appends bounded dashboard notices and coalesces duplicate bursts", async () => {
|
||||
const { invoke } = createHarness();
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "counter",
|
||||
content: { kind: "html", html: "ok" },
|
||||
});
|
||||
const first = await invoke("board.event", {
|
||||
sessionKey: "session",
|
||||
widget: "counter",
|
||||
payload: { count: 1 },
|
||||
});
|
||||
const duplicate = await invoke("board.event", {
|
||||
sessionKey: "session",
|
||||
widget: "counter",
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(first.mock.calls[0]?.[1]).toEqual({ ok: true, appended: true });
|
||||
expect(duplicate.mock.calls[0]?.[1]).toEqual({ ok: true, appended: false });
|
||||
expect(peekSystemEvents("session")).toEqual(['[dashboard] {"count":1} on widget counter']);
|
||||
});
|
||||
|
||||
it("caps board.event payloads at 8KB and notices at 500 characters", async () => {
|
||||
const { invoke } = createHarness();
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "counter",
|
||||
content: { kind: "html", html: "ok" },
|
||||
});
|
||||
await invoke("board.event", {
|
||||
sessionKey: "session",
|
||||
widget: "counter",
|
||||
payload: "x".repeat(1_000),
|
||||
});
|
||||
expect(peekSystemEvents("session")[0]).toHaveLength(500);
|
||||
const oversized = await invoke("board.event", {
|
||||
sessionKey: "session",
|
||||
widget: "counter",
|
||||
payload: "x".repeat(8_193),
|
||||
});
|
||||
expect(oversized.mock.calls[0]?.[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps board state across the real sessions.reset handler", async () => {
|
||||
const sessionKey = "agent:main:board-reset-proof";
|
||||
const stateDir = tempDirs.make("openclaw-board-reset-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main", env });
|
||||
replaceSessionEntrySync(
|
||||
{ agentId: "main", sessionKey, storePath: database.path },
|
||||
{ sessionId: "board-reset-proof", updatedAt: Date.now() },
|
||||
);
|
||||
const boardStore = new SqliteBoardStore({
|
||||
resolveSession: () => ({ agentId: "main", sessionKey }),
|
||||
env,
|
||||
});
|
||||
boardStore.putWidget({
|
||||
sessionKey,
|
||||
name: "status",
|
||||
content: { kind: "html", html: "ok" },
|
||||
});
|
||||
const respond = vi.fn<RespondFn>();
|
||||
await sessionMutationHandlers["sessions.reset"]!({
|
||||
req: { type: "req", id: "reset", method: "sessions.reset", params: {} },
|
||||
params: { key: sessionKey, reason: "reset" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: {
|
||||
broadcast: vi.fn(),
|
||||
getSessionEventSubscriberConnIds: () => new Set<string>(),
|
||||
} as unknown as GatewayRequestContext,
|
||||
});
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
||||
expect(boardStore.getSnapshot(sessionKey).widgets).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
170
src/gateway/server-methods/board.ts
Normal file
170
src/gateway/server-methods/board.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
type BoardEventParams,
|
||||
type BoardUpdateParams,
|
||||
type BoardWidgetGrantParams,
|
||||
type BoardWidgetPutParams,
|
||||
validateBoardEventParams,
|
||||
validateBoardGetParams,
|
||||
validateBoardUpdateParams,
|
||||
validateBoardWidgetGrantParams,
|
||||
validateBoardWidgetPutParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { BoardValidationError } from "../../boards/board-layout.js";
|
||||
import { appendBoardEventNotice, BoardEventPayloadError } from "../../boards/board-notices.js";
|
||||
import type { BoardStore } from "../../boards/board-store.js";
|
||||
import { boardStore } from "../board-store.js";
|
||||
import { buildBoardWidgetFrameUrl, createBoardViewTicket } from "../board-view-ticket.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
type NoticeAppender = typeof appendBoardEventNotice;
|
||||
|
||||
function invalidParams(
|
||||
method: string,
|
||||
errors: unknown,
|
||||
respond: Parameters<GatewayRequestHandlers[string]>[0]["respond"],
|
||||
): void {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid ${method} params: ${formatValidationErrors(errors as never)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function respondBoardError(
|
||||
error: unknown,
|
||||
respond: Parameters<GatewayRequestHandlers[string]>[0]["respond"],
|
||||
): void {
|
||||
if (error instanceof BoardValidationError || error instanceof BoardEventPayloadError) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message));
|
||||
return;
|
||||
}
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(error)));
|
||||
}
|
||||
|
||||
export function createBoardHandlers(
|
||||
store: BoardStore,
|
||||
appendNotice: NoticeAppender = appendBoardEventNotice,
|
||||
): GatewayRequestHandlers {
|
||||
return {
|
||||
"board.get": ({ params, respond }) => {
|
||||
if (!validateBoardGetParams(params)) {
|
||||
invalidParams("board.get", validateBoardGetParams.errors, respond);
|
||||
return;
|
||||
}
|
||||
const snapshot = store.getSnapshot(params.sessionKey);
|
||||
for (const widget of snapshot.widgets) {
|
||||
if (widget.grantState !== "none" && widget.grantState !== "granted") {
|
||||
continue;
|
||||
}
|
||||
const document = store.readWidgetHtml(snapshot.sessionKey, widget.name);
|
||||
if (!document || !("html" in document) || document.revision !== widget.revision) {
|
||||
continue;
|
||||
}
|
||||
const { ticket } = createBoardViewTicket({
|
||||
sessionKey: snapshot.sessionKey,
|
||||
name: widget.name,
|
||||
revision: widget.revision,
|
||||
viewGeneration: document.viewGeneration,
|
||||
});
|
||||
widget.frameUrl = buildBoardWidgetFrameUrl({
|
||||
sessionKey: snapshot.sessionKey,
|
||||
name: widget.name,
|
||||
ticket,
|
||||
});
|
||||
}
|
||||
respond(true, snapshot);
|
||||
},
|
||||
"board.update": ({ params, respond, context }) => {
|
||||
if (!validateBoardUpdateParams(params)) {
|
||||
invalidParams("board.update", validateBoardUpdateParams.errors, respond);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const boardParams = params as BoardUpdateParams;
|
||||
const snapshot = store.applyOps(boardParams.sessionKey, boardParams.ops);
|
||||
if (boardParams.ops.length > 0) {
|
||||
context.broadcast("board.changed", {
|
||||
sessionKey: snapshot.sessionKey,
|
||||
revision: snapshot.revision,
|
||||
});
|
||||
}
|
||||
respond(true, snapshot);
|
||||
} catch (error) {
|
||||
respondBoardError(error, respond);
|
||||
}
|
||||
},
|
||||
"board.widget.put": ({ params, respond, context }) => {
|
||||
if (!validateBoardWidgetPutParams(params)) {
|
||||
invalidParams("board.widget.put", validateBoardWidgetPutParams.errors, respond);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const boardParams = params as BoardWidgetPutParams;
|
||||
const snapshot = store.putWidget(boardParams);
|
||||
context.broadcast("board.changed", {
|
||||
sessionKey: snapshot.sessionKey,
|
||||
revision: snapshot.revision,
|
||||
widget: boardParams.name,
|
||||
});
|
||||
respond(true, snapshot);
|
||||
} catch (error) {
|
||||
respondBoardError(error, respond);
|
||||
}
|
||||
},
|
||||
"board.widget.grant": ({ params, respond, context }) => {
|
||||
if (!validateBoardWidgetGrantParams(params)) {
|
||||
invalidParams("board.widget.grant", validateBoardWidgetGrantParams.errors, respond);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const boardParams = params as BoardWidgetGrantParams;
|
||||
const snapshot = store.grant(
|
||||
boardParams.sessionKey,
|
||||
boardParams.name,
|
||||
boardParams.decision,
|
||||
boardParams.revision,
|
||||
);
|
||||
context.broadcast("board.changed", {
|
||||
sessionKey: snapshot.sessionKey,
|
||||
revision: snapshot.revision,
|
||||
});
|
||||
respond(true, snapshot);
|
||||
} catch (error) {
|
||||
respondBoardError(error, respond);
|
||||
}
|
||||
},
|
||||
"board.event": ({ params, respond }) => {
|
||||
if (!validateBoardEventParams(params)) {
|
||||
invalidParams("board.event", validateBoardEventParams.errors, respond);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const boardParams = params as BoardEventParams;
|
||||
const snapshot = store.getSnapshot(boardParams.sessionKey);
|
||||
const widget = snapshot.widgets.some((candidate) => candidate.name === boardParams.widget);
|
||||
if (!widget) {
|
||||
throw new BoardValidationError(
|
||||
"not_found",
|
||||
`board widget not found: ${boardParams.widget}`,
|
||||
);
|
||||
}
|
||||
const appended = appendNotice({
|
||||
sessionKey: snapshot.sessionKey,
|
||||
widget: boardParams.widget,
|
||||
payload: boardParams.payload,
|
||||
});
|
||||
respond(true, { ok: true, appended });
|
||||
} catch (error) {
|
||||
respondBoardError(error, respond);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const boardHandlers = createBoardHandlers(boardStore);
|
||||
65
src/gateway/server.sessions.delete-board.test.ts
Normal file
65
src/gateway/server.sessions.delete-board.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { SqliteBoardStore } from "../boards/sqlite-board-store.js";
|
||||
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { testState, writeSessionStore } from "./test-helpers.js";
|
||||
import {
|
||||
directSessionReq,
|
||||
sessionStoreEntry,
|
||||
setupGatewaySessionsTestHarness,
|
||||
writeSingleLineSession,
|
||||
} from "./test/server-sessions.test-helpers.js";
|
||||
|
||||
const { createSessionStoreDir } = setupGatewaySessionsTestHarness();
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
});
|
||||
|
||||
test("sessions.delete removes the session board from its agent database", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
await writeSingleLineSession(dir, "sess-board", "hello");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
"discord:group:board-delete": sessionStoreEntry("sess-board"),
|
||||
},
|
||||
});
|
||||
const sessionKey = "agent:main:discord:group:board-delete";
|
||||
if (!testState.sessionStorePath) {
|
||||
throw new Error("expected gateway session store path");
|
||||
}
|
||||
const databasePath = resolveSqliteTargetFromSessionStorePath(testState.sessionStorePath, {
|
||||
agentId: "main",
|
||||
}).path;
|
||||
if (!databasePath) {
|
||||
throw new Error("expected gateway agent database path");
|
||||
}
|
||||
const store = new SqliteBoardStore({
|
||||
resolveSession: () => ({
|
||||
agentId: "main",
|
||||
path: databasePath,
|
||||
sessionKey,
|
||||
}),
|
||||
env: process.env,
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey,
|
||||
name: "status",
|
||||
content: { kind: "html", html: "ok" },
|
||||
});
|
||||
|
||||
const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", {
|
||||
key: "discord:group:board-delete",
|
||||
});
|
||||
|
||||
expect(deleted.ok).toBe(true);
|
||||
expect(deleted.payload?.deleted).toBe(true);
|
||||
expect(store.getSnapshot(sessionKey)).toEqual({
|
||||
sessionKey,
|
||||
revision: 0,
|
||||
tabs: [],
|
||||
widgets: [],
|
||||
});
|
||||
});
|
||||
21
src/state/openclaw-agent-board-schema.ts
Normal file
21
src/state/openclaw-agent-board-schema.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.generated.js";
|
||||
|
||||
const BOARD_SCHEMA_START = "CREATE TABLE IF NOT EXISTS board_tabs (";
|
||||
const BOARD_SCHEMA_END = "CREATE TABLE IF NOT EXISTS heartbeat_outcomes (";
|
||||
|
||||
function splitBoardSchema(sql: string): { board: string; withoutBoard: string } {
|
||||
const start = sql.indexOf(BOARD_SCHEMA_START);
|
||||
const end = sql.indexOf(BOARD_SCHEMA_END, start);
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error("OpenClaw agent board schema markers are missing from the canonical schema.");
|
||||
}
|
||||
return {
|
||||
board: sql.slice(start, end),
|
||||
withoutBoard: `${sql.slice(0, start)}${sql.slice(end)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const boardSchema = splitBoardSchema(OPENCLAW_AGENT_SCHEMA_SQL);
|
||||
|
||||
export const OPENCLAW_AGENT_BOARD_SCHEMA_SQL = boardSchema.board;
|
||||
export const OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL = boardSchema.withoutBoard;
|
||||
@@ -13,6 +13,7 @@ import { configureSqlitePreSchemaPragmas } from "../infra/sqlite-wal.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL } from "./openclaw-agent-board-schema.js";
|
||||
import {
|
||||
OPENCLAW_AGENT_SCHEMA_VERSION,
|
||||
type OpenClawAgentDatabaseOptions,
|
||||
@@ -512,7 +513,11 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string):
|
||||
dropLegacySessionTranscriptSearchSchema(db);
|
||||
migrateMemoryIndexSourcesIdentity(db);
|
||||
migrateOpenClawAgentSchema(db);
|
||||
db.exec(OPENCLAW_AGENT_SCHEMA_SQL);
|
||||
db.exec(
|
||||
previousVersion === OPENCLAW_AGENT_SCHEMA_VERSION
|
||||
? OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL
|
||||
: OPENCLAW_AGENT_SCHEMA_SQL,
|
||||
);
|
||||
migrateSessionTranscriptGenerations(db, previousVersion);
|
||||
migrateConversationDeliveryTargetColumn(db);
|
||||
migrateSessionTranscriptActiveProjection(db, previousVersion);
|
||||
|
||||
34
src/state/openclaw-agent-db.generated.d.ts
vendored
34
src/state/openclaw-agent-db.generated.d.ts
vendored
@@ -30,6 +30,38 @@ export interface AuthProfileStore {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface BoardTabs {
|
||||
chat_dock: Generated<string>;
|
||||
created_by: string;
|
||||
position: number;
|
||||
revision: number;
|
||||
session_key: string;
|
||||
tab_id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface BoardWidgets {
|
||||
content_kind: string;
|
||||
created_at: number;
|
||||
created_by: string;
|
||||
descriptor_json: string | null;
|
||||
grant_state: Generated<string>;
|
||||
granted_sha: string | null;
|
||||
html: Uint8Array | null;
|
||||
manifest: Generated<string>;
|
||||
name: string;
|
||||
position: number;
|
||||
revision: number;
|
||||
session_key: string;
|
||||
sha256: string;
|
||||
size_h: number;
|
||||
size_w: number;
|
||||
tab_id: string;
|
||||
title: string | null;
|
||||
updated_at: number;
|
||||
view_generation: string | null;
|
||||
}
|
||||
|
||||
export interface CacheEntries {
|
||||
blob: Uint8Array | null;
|
||||
expires_at: number | null;
|
||||
@@ -295,6 +327,8 @@ export interface DB {
|
||||
acp_parent_stream_events: AcpParentStreamEvents;
|
||||
auth_profile_state: AuthProfileState;
|
||||
auth_profile_store: AuthProfileStore;
|
||||
board_tabs: BoardTabs;
|
||||
board_widgets: BoardWidgets;
|
||||
cache_entries: CacheEntries;
|
||||
conversation_deliveries: ConversationDeliveries;
|
||||
conversations: Conversations;
|
||||
|
||||
@@ -568,6 +568,41 @@ describe("openclaw agent database", () => {
|
||||
expect(registered?.sizeBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("opens a v13 database that already contains additive board storage", () => {
|
||||
expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(13);
|
||||
const stateDir = createTempStateDir();
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const opened = openOpenClawAgentDatabase({ agentId: "worker-1", env });
|
||||
const databasePath = opened.path;
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const existingV13 = new DatabaseSync(databasePath);
|
||||
existingV13.exec(`
|
||||
PRAGMA user_version = 13;
|
||||
UPDATE schema_meta SET schema_version = 13 WHERE meta_key = 'primary';
|
||||
`);
|
||||
existingV13.close();
|
||||
|
||||
const reopened = openOpenClawAgentDatabase({ agentId: "worker-1", env });
|
||||
expect(
|
||||
reopened.db
|
||||
.prepare(
|
||||
"SELECT name, strict FROM pragma_table_list WHERE name IN ('board_tabs', 'board_widgets') ORDER BY name",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ name: "board_tabs", strict: 1 },
|
||||
{ name: "board_widgets", strict: 1 },
|
||||
]);
|
||||
expect(readSqliteNumberPragma(reopened.db, "user_version")).toBe(OPENCLAW_AGENT_SCHEMA_VERSION);
|
||||
expect(
|
||||
reopened.db
|
||||
.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'")
|
||||
.get(),
|
||||
).toEqual({ schema_version: OPENCLAW_AGENT_SCHEMA_VERSION });
|
||||
});
|
||||
|
||||
it("keeps additive heartbeat repair while upgrading schema version 12", () => {
|
||||
expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(13);
|
||||
const stateDir = createTempStateDir();
|
||||
|
||||
@@ -182,6 +182,48 @@ CREATE INDEX IF NOT EXISTS idx_agent_session_entries_status
|
||||
ON session_entries(status, session_key)
|
||||
WHERE status IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS board_tabs (
|
||||
session_key TEXT NOT NULL,
|
||||
tab_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
chat_dock TEXT NOT NULL DEFAULT 'right' CHECK (chat_dock IN ('left', 'right', 'bottom', 'hidden')),
|
||||
created_by TEXT NOT NULL CHECK (created_by IN ('user', 'agent')),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 0),
|
||||
PRIMARY KEY (session_key, tab_id)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS board_widgets (
|
||||
session_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tab_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
content_kind TEXT NOT NULL CHECK (content_kind IN ('html', 'mcp-app')),
|
||||
html BLOB,
|
||||
descriptor_json TEXT,
|
||||
sha256 TEXT NOT NULL,
|
||||
view_generation TEXT,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
size_w INTEGER NOT NULL CHECK (size_w BETWEEN 1 AND 12),
|
||||
size_h INTEGER NOT NULL CHECK (size_h BETWEEN 1 AND 20),
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
manifest TEXT NOT NULL DEFAULT '{}',
|
||||
grant_state TEXT NOT NULL DEFAULT 'none' CHECK (grant_state IN ('none', 'pending', 'granted', 'rejected')),
|
||||
granted_sha TEXT,
|
||||
created_by TEXT NOT NULL CHECK (created_by IN ('user', 'agent')),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_key, name),
|
||||
FOREIGN KEY (session_key, tab_id) REFERENCES board_tabs(session_key, tab_id) ON DELETE CASCADE,
|
||||
CHECK (
|
||||
(content_kind = 'html' AND html IS NOT NULL AND descriptor_json IS NULL AND view_generation IS NOT NULL) OR
|
||||
(content_kind = 'mcp-app' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL)
|
||||
)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_board_widgets_tab_position
|
||||
ON board_widgets(session_key, tab_id, position);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_outcomes (
|
||||
session_key TEXT NOT NULL PRIMARY KEY,
|
||||
run_session_key TEXT NOT NULL,
|
||||
|
||||
@@ -177,6 +177,48 @@ CREATE INDEX IF NOT EXISTS idx_agent_session_entries_status
|
||||
ON session_entries(status, session_key)
|
||||
WHERE status IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS board_tabs (
|
||||
session_key TEXT NOT NULL,
|
||||
tab_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
chat_dock TEXT NOT NULL DEFAULT 'right' CHECK (chat_dock IN ('left', 'right', 'bottom', 'hidden')),
|
||||
created_by TEXT NOT NULL CHECK (created_by IN ('user', 'agent')),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 0),
|
||||
PRIMARY KEY (session_key, tab_id)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS board_widgets (
|
||||
session_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tab_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
content_kind TEXT NOT NULL CHECK (content_kind IN ('html', 'mcp-app')),
|
||||
html BLOB,
|
||||
descriptor_json TEXT,
|
||||
sha256 TEXT NOT NULL,
|
||||
view_generation TEXT,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
size_w INTEGER NOT NULL CHECK (size_w BETWEEN 1 AND 12),
|
||||
size_h INTEGER NOT NULL CHECK (size_h BETWEEN 1 AND 20),
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
manifest TEXT NOT NULL DEFAULT '{}',
|
||||
grant_state TEXT NOT NULL DEFAULT 'none' CHECK (grant_state IN ('none', 'pending', 'granted', 'rejected')),
|
||||
granted_sha TEXT,
|
||||
created_by TEXT NOT NULL CHECK (created_by IN ('user', 'agent')),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_key, name),
|
||||
FOREIGN KEY (session_key, tab_id) REFERENCES board_tabs(session_key, tab_id) ON DELETE CASCADE,
|
||||
CHECK (
|
||||
(content_kind = 'html' AND html IS NOT NULL AND descriptor_json IS NULL AND view_generation IS NOT NULL) OR
|
||||
(content_kind = 'mcp-app' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL)
|
||||
)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_board_widgets_tab_position
|
||||
ON board_widgets(session_key, tab_id, position);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_outcomes (
|
||||
session_key TEXT NOT NULL PRIMARY KEY,
|
||||
run_session_key TEXT NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user