From d4db159def85f11682d881ad455b5ce9cb70bacf Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:33:01 +0100 Subject: [PATCH 01/39] fix(web): drop duplicate formatRelativeTime import path Soup verify: scratchlist layer already uses @/lib/relative-time; keep canonical path and avoid TS2300 duplicate identifier at driver merge. --- web/src/components/SessionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 3e9198cbbb..47ba85f2bb 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -18,7 +18,7 @@ import { classifySessionAttention } from '@/lib/sessionAttention' import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip' -import { formatRelativeTime } from '@/lib/relativeTime' +import { formatRelativeTime } from '@/lib/relative-time' import { formatScheduledTooltipDetail } from '@/lib/scheduledTime' import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' From e277f2b9e46aa3431800f8ce1d5e7d223cae17b4 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:08:02 +0100 Subject: [PATCH 02/39] fix(web): avoid duplicate formatRelativeTime import at soup merge --- web/src/components/SessionList.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index f1f0b87441..680173021f 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -18,7 +18,6 @@ import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' -import { formatRelativeTime } from '@/lib/relative-time' type SessionGroup = { key: string @@ -546,9 +545,6 @@ function MachineIcon(props: { className?: string }) { ) } -// formatRelativeTime now lives in `@/lib/relative-time` so the -// scratchlist entry-age tooltip and any future surface can reuse the -// same buckets / wording without copy-paste drift. function formatCodexImportedRelativeTime(value: number, t: (key: string, params?: Record) => string): string | null { const ms = value < 1_000_000_000_000 ? value * 1000 : value From 20bbdeae8122dc117b94a1315231a7e584b7f20a Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:12:46 +0100 Subject: [PATCH 03/39] fix(runner): treat stop of unknown session as idempotent success Parallel stress-test stops can race child exit cleanup; returning true when the session is already gone matches ensure-stopped semantics. --- cli/src/runner/run.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index aac5b7c4a8..e4421e6174 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -698,8 +698,8 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } } - logger.debug(`[RUNNER RUN] Session ${sessionId} not found`); - return false; + logger.debug(`[RUNNER RUN] Session ${sessionId} not found (already stopped)`); + return true; }; // Handle child process exit From 03f112eedbc75f6cf40415a4746a55866cd1fd28 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:24:09 +0100 Subject: [PATCH 04/39] fix(garden): merge-safe vite.config for upstream share-target + PWA prompt --- web/vite.config.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/web/vite.config.ts b/web/vite.config.ts index 08f2c30332..b4282b029c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,8 +3,10 @@ import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { shareTargetPathnameFromBase } from './src/lib/sharePath' const base = process.env.VITE_BASE_URL || '/' +const shareAction = shareTargetPathnameFromBase(base) const hubTarget = process.env.VITE_HUB_PROXY || 'http://127.0.0.1:3006' const appVersion = readAppVersion() @@ -52,13 +54,10 @@ function getVendorChunkName(id: string): string | undefined { } export default defineConfig(({ mode }) => { - // In production we stub out the IWER WebXR emulator packages so they - // don't bloat the bundle or introduce the @bufbuild/protobuf 2.x conflict. - // In dev/test the real packages are live so IWER-based Playwright tests work. const stubIwer = mode === 'production' const iwerStub = resolve(__dirname, 'src/vendor-stubs/iwer-stub.ts') const iwerAliases = stubIwer - ? { '@iwer/sem': iwerStub, '@iwer/devui': iwerStub, 'iwer': iwerStub } + ? { '@iwer/sem': iwerStub, '@iwer/devui': iwerStub, iwer: iwerStub } : {} return { @@ -82,7 +81,7 @@ export default defineConfig(({ mode }) => { plugins: [ react(), VitePWA({ - registerType: 'autoUpdate', + registerType: 'prompt', includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'], strategies: 'injectManifest', srcDir: 'src', @@ -118,7 +117,7 @@ export default defineConfig(({ mode }) => { } ], share_target: { - action: '/share', + action: shareAction, method: 'POST', enctype: 'multipart/form-data', params: { @@ -154,7 +153,7 @@ export default defineConfig(({ mode }) => { resolve: { alias: { '@': resolve(__dirname, 'src'), - ...iwerAliases, + ...iwerAliases } }, build: { From 6c4040604f24652d7363923cd532be3834bc454a Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:41:06 +0100 Subject: [PATCH 05/39] fix(garden): minimal vite.config delta for soup merge (flat defineConfig) --- web/vite.config.ts | 221 +++++++++++++++++++++++---------------------- 1 file changed, 114 insertions(+), 107 deletions(-) diff --git a/web/vite.config.ts b/web/vite.config.ts index b4282b029c..07969fc494 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -10,6 +10,12 @@ const shareAction = shareTargetPathnameFromBase(base) const hubTarget = process.env.VITE_HUB_PROXY || 'http://127.0.0.1:3006' const appVersion = readAppVersion() +const iwerStub = resolve(__dirname, 'src/vendor-stubs/iwer-stub.ts') +const stubIwer = process.env.NODE_ENV === 'production' +const iwerAliases = stubIwer + ? { '@iwer/sem': iwerStub, '@iwer/devui': iwerStub, iwer: iwerStub } + : {} + function readAppVersion(): string { const buildInfoPath = resolve(__dirname, '../shared/src/buildInfo.ts') const buildInfo = readFileSync(buildInfoPath, 'utf8') @@ -53,117 +59,118 @@ function getVendorChunkName(id: string): string | undefined { return undefined } -export default defineConfig(({ mode }) => { - const stubIwer = mode === 'production' - const iwerStub = resolve(__dirname, 'src/vendor-stubs/iwer-stub.ts') - const iwerAliases = stubIwer - ? { '@iwer/sem': iwerStub, '@iwer/devui': iwerStub, iwer: iwerStub } - : {} - - return { - define: { - __APP_VERSION__: JSON.stringify(appVersion), - }, - server: { - host: true, - allowedHosts: ['hapidev.weishu.me'], - proxy: { - '/api': { - target: hubTarget, - changeOrigin: true - }, - '/socket.io': { - target: hubTarget, - ws: true - } +export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(appVersion), + }, + server: { + host: true, + allowedHosts: ['hapidev.weishu.me'], + proxy: { + '/api': { + target: hubTarget, + changeOrigin: true + }, + '/socket.io': { + target: hubTarget, + ws: true } - }, - plugins: [ - react(), - VitePWA({ - registerType: 'prompt', - includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'], - strategies: 'injectManifest', - srcDir: 'src', - filename: 'sw.ts', - manifest: { - name: 'HAPI', - short_name: 'HAPI', - description: 'AI-powered development assistant', - theme_color: '#ffffff', - background_color: '#ffffff', - display: 'standalone', - orientation: 'portrait', - scope: base, - start_url: base, - icons: [ - { - src: 'pwa-64x64.png', - sizes: '64x64', - type: 'image/png', - purpose: 'any' - }, - { - src: 'pwa-192x192.png', - sizes: '192x192', - type: 'image/png', - purpose: 'any' - }, - { - src: 'pwa-512x512.png', - sizes: '512x512', - type: 'image/png', - purpose: 'any' - } - ], - share_target: { - action: shareAction, - method: 'POST', - enctype: 'multipart/form-data', - params: { - title: 'title', - text: 'text', - url: 'url', - files: [ - { - name: 'files', - accept: [ - 'image/*', - 'application/pdf', - 'text/*', - 'application/json', - 'application/zip', - '*/*' - ] - } - ] - } + } + }, + plugins: [ + react(), + VitePWA({ + // User-controlled reload avoids mid-session surprise reloads (autoUpdate reloads all tabs). + registerType: 'prompt', + includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'], + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', + manifest: { + name: 'HAPI', + short_name: 'HAPI', + description: 'AI-powered development assistant', + theme_color: '#ffffff', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + scope: base, + start_url: base, + icons: [ + { + src: 'pwa-64x64.png', + sizes: '64x64', + type: 'image/png', + purpose: 'any' + }, + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any' + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any' + } + ], + // Web Share Target — Android Chrome routes POSTs to /share + // when the user picks HAPI in the system share sheet. The + // service worker (`web/src/sw.ts`) intercepts POST /share, + // stashes the multipart payload in IndexedDB, and 303- + // redirects to /share?id= for the SPA picker. + // `*/*` is the broad fallback; explicit MIME prefixes stay + // first because some Chrome versions only honor declared + // prefixes when surfacing in the share sheet. + share_target: { + action: shareAction, + method: 'POST', + enctype: 'multipart/form-data', + params: { + title: 'title', + text: 'text', + url: 'url', + files: [ + { + name: 'files', + accept: [ + 'image/*', + 'application/pdf', + 'text/*', + 'application/json', + 'application/zip', + '*/*' + ] + } + ] } - }, - injectManifest: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'] - }, - devOptions: { - enabled: true, - type: 'module' } - }) - ], - base, - resolve: { - alias: { - '@': resolve(__dirname, 'src'), - ...iwerAliases + }, + injectManifest: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'] + }, + devOptions: { + enabled: true, + type: 'module' } - }, - build: { - outDir: 'dist', - emptyOutDir: true, - rollupOptions: { - output: { - manualChunks(id) { - return getVendorChunkName(id) - } + }) + ], + base, + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + ...iwerAliases, + } + }, + build: { + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + output: { + manualChunks(id) { + return getVendorChunkName(id) } } } From 134f0464d5310c25901b3f711b673d583a3a1cf0 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:11:51 +0100 Subject: [PATCH 06/39] test(runner): serialize stress-test stops to avoid control-server flake --- cli/src/runner/runner.integration.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/src/runner/runner.integration.test.ts b/cli/src/runner/runner.integration.test.ts index 9d3f1547c4..fe8754f405 100644 --- a/cli/src/runner/runner.integration.test.ts +++ b/cli/src/runner/runner.integration.test.ts @@ -176,9 +176,13 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: const sessions = await listRunnerSessions(); expect(sessions).toHaveLength(sessionCount); - // Stop all sessions - const stopResults = await Promise.all(sessionIds.map(sessionId => stopRunnerSession(sessionId))); - expect(stopResults.every(r => r), 'Not all sessions reported stopped').toBe(true); + // Stop serially — parallel /stop-session under load races the control + // server and flakes with intermittent HTTP failures on busy hosts. + const stopResults: boolean[] = [] + for (const sessionId of sessionIds) { + stopResults.push(await stopRunnerSession(sessionId)) + } + expect(stopResults.every(r => r), 'Not all sessions reported stopped').toBe(true) // Verify all sessions are stopped const emptySessions = await listRunnerSessions(); From 978ed2be97092fc01d4b5a2e80548037bce98cb9 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:47:27 +0100 Subject: [PATCH 07/39] feat(hub): native companion (FCM) push channel + device registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in FCM HTTP v1 notification delivery so a companion mobile/wearable app can receive permission, ready, and task notifications end-to-end. The channel is gated entirely on FCM_SERVICE_ACCOUNT_PATH + FCM_PROJECT_ID being set; operators not running a companion see zero behavior change. What lands: - POST/DELETE /api/devices/register — JWT-authed FCM token registry, upsert on (namespace, deviceId, platform), platforms `phone` | `wear`. - Sqlite v9 → v10 migration adds `fcm_devices` (idx on namespace + token). - FcmService — minimal HTTP v1 client, RS256 service-account JWT via jose (dep already in tree), 5-minute access-token cache, 401 retry. - FcmNotificationChannel — implements NotificationChannel, sends data-only FCM (so companion can route to phone+watch surfaces). Body composition parses an optional trailing `AGENT_NOTIFY_SUMMARY {json}` line for richer ready summaries; truncates plain assistant text to 280 chars otherwise. Tags each payload with `severity` (info/warning/success/error) so clients can color/categorise the notification. - PushNotificationChannel gains a NativeFallbackProbe — when a namespace has at least one registered FCM device, web-push and SSE in-page toast are skipped so the operator does not double-notify on phone+browser. Probe is no-op when no FCM device is registered; PWA-only setups unchanged. Branch trace gated on HAPI_NOTIFY_DEBUG=1. - shared/src/messages.ts — `extractAssistantPlainText` (codex + Claude SDK shapes) and `extractNotifySummary` (strict end-anchored line parser). - hub/src/notifications/toolArgs.ts — tool-arg formatters lifted out of telegram/sessionView (kept duplicated there in this PR; refactor of Telegram is a follow-up). - docs/api/native-companion-contract.md — payload + endpoints + env vars, versioned at contract v1. Test coverage: - 260 hub tests pass (incl. 23 new across FCM channel, push dedup, v10 migration, devices route). - 60 shared tests pass (messages parsers). Notes for reviewers: - Reference companion implementation lives in a separate Android repo (Kotlin, phone APK + Wear OS APK) — this PR is hub-side only. - No new runtime deps (`jose` and `zod` already declared in hub). Co-authored-by: Cursor --- docs/api/native-companion-contract.md | 91 ++++ hub/src/fcm/fcmAuth.ts | 70 ++++ hub/src/fcm/fcmConfig.ts | 26 ++ hub/src/fcm/fcmNotificationChannel.test.ts | 413 +++++++++++++++++++ hub/src/fcm/fcmNotificationChannel.ts | 275 ++++++++++++ hub/src/fcm/fcmService.ts | 144 +++++++ hub/src/notifications/toolArgs.ts | 157 +++++++ hub/src/push/pushNotificationChannel.test.ts | 95 +++++ hub/src/push/pushNotificationChannel.ts | 78 +++- hub/src/push/pushService.ts | 2 + hub/src/startHub.ts | 26 +- hub/src/store/fcmDevices.ts | 62 +++ hub/src/store/fcmStore.ts | 23 ++ hub/src/store/index.ts | 38 +- hub/src/store/migration-v10.test.ts | 130 ++++++ hub/src/store/types.ts | 10 + hub/src/web/routes/devices.test.ts | 56 +++ hub/src/web/routes/devices.ts | 44 ++ hub/src/web/server.ts | 2 + shared/src/messages.test.ts | 221 ++++++++++ shared/src/messages.ts | 103 +++++ 21 files changed, 2059 insertions(+), 7 deletions(-) create mode 100644 docs/api/native-companion-contract.md create mode 100644 hub/src/fcm/fcmAuth.ts create mode 100644 hub/src/fcm/fcmConfig.ts create mode 100644 hub/src/fcm/fcmNotificationChannel.test.ts create mode 100644 hub/src/fcm/fcmNotificationChannel.ts create mode 100644 hub/src/fcm/fcmService.ts create mode 100644 hub/src/notifications/toolArgs.ts create mode 100644 hub/src/store/fcmDevices.ts create mode 100644 hub/src/store/fcmStore.ts create mode 100644 hub/src/store/migration-v10.test.ts create mode 100644 hub/src/web/routes/devices.test.ts create mode 100644 hub/src/web/routes/devices.ts create mode 100644 shared/src/messages.test.ts diff --git a/docs/api/native-companion-contract.md b/docs/api/native-companion-contract.md new file mode 100644 index 0000000000..2a0c8729e3 --- /dev/null +++ b/docs/api/native-companion-contract.md @@ -0,0 +1,91 @@ +# Native companion API contract (phone + Wear) + +**Audience:** Implementers of native companion apps (Android phone + Wear OS, iOS, etc.) that pair with a hapi hub via FCM. +**Auth:** Same JWT as the web client (`POST /api/bind` → `Authorization: Bearer`). + +--- + +## Device registration (FCM) + +### Register + +`POST /api/devices/register` + +```json +{ + "token": "", + "platform": "phone", + "deviceId": "" +} +``` + +`platform`: `"phone"` | `"wear"` + +**Response:** `{ "ok": true }` + +Upsert on `(namespace, deviceId, platform)` - same device re-registering replaces the FCM token. + +### Unregister + +`DELETE /api/devices/register` + +```json +{ + "token": "" +} +``` + +--- + +## Outbound push (hub → device) + +Hub sends FCM HTTP v1 when Web Push would fire, if FCM is configured and client not visible via SSE (same rule as `PushNotificationChannel`). + +### Data payload (all platforms) + +| Key | Example | Purpose | +|-----|---------|---------| +| `type` | `ready` | `ready`, `permission-request`, `task-notification`, `session-completed` | +| `sessionId` | uuid | Target session | +| `sessionName` | string | Display name (`agent - project`) | +| `url` | `/sessions/{id}` | Deep link path | +| `requestId` | uuid | Permission only - approve/deny | +| `title` | string | Notification title | +| `body` | string | Notification body | +| `severity` | `info` | `info` (ready), `warning` (permission), `success` / `error` (task) | +| `notifySummary` | JSON string | Optional: parsed `AGENT_NOTIFY_SUMMARY` line from agent text | + +Native apps **must** handle `data` for Wear; notification block is for display. + +### Client actions (native - not hub) + +| User action | Hub API | +|-------------|---------| +| Send text | `POST /api/sessions/:id/messages` `{ "text": "...", "localId": "..." }` | +| Allow | `POST /api/sessions/:id/permissions/:requestId/approve` | +| Deny | `POST /api/sessions/:id/permissions/:requestId/deny` | + +`sentFrom` extension (optional future): `android-phone`, `android-wear`. + +--- + +## Environment (hub operator) + +```bash +FCM_SERVICE_ACCOUNT_PATH=/path/to/service-account.json +FCM_PROJECT_ID=your-firebase-project-id +``` + +When unset, hub skips FCM channel (Web Push / Telegram unchanged). + +The native push channel is **opt-in**: operators who don't run a companion +app see no behavior change. When at least one device is registered for a +namespace, the existing Web Push channel suppresses its fallback for that +namespace to avoid double-notifying (one in the native app, one from the +PWA service worker). PWA-only operators are unaffected. + +--- + +## Versioning + +Contract version **1**. Breaking changes require `data.contractVersion` in FCM payload and doc update. diff --git a/hub/src/fcm/fcmAuth.ts b/hub/src/fcm/fcmAuth.ts new file mode 100644 index 0000000000..e173842152 --- /dev/null +++ b/hub/src/fcm/fcmAuth.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs' +import * as jose from 'jose' + +export type ServiceAccount = { + client_email: string + private_key: string + project_id?: string +} + +const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging' + +let cachedToken: { accessToken: string; expiresAtMs: number } | null = null + +export function loadServiceAccount(path: string): ServiceAccount { + const raw = readFileSync(path, 'utf8') + const parsed = JSON.parse(raw) as ServiceAccount + if (!parsed.client_email || !parsed.private_key) { + throw new Error('FCM service account JSON missing client_email or private_key') + } + return parsed +} + +export async function getFcmAccessToken(serviceAccount: ServiceAccount): Promise { + const nowMs = Date.now() + if (cachedToken && cachedToken.expiresAtMs > nowMs + 60_000) { + return cachedToken.accessToken + } + + const nowSec = Math.floor(nowMs / 1000) + const key = await jose.importPKCS8(serviceAccount.private_key, 'RS256') + const assertion = await new jose.SignJWT({ scope: FCM_SCOPE }) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(serviceAccount.client_email) + .setSubject(serviceAccount.client_email) + .setAudience('https://oauth2.googleapis.com/token') + .setIssuedAt(nowSec) + .setExpirationTime(nowSec + 3600) + .sign(key) + + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion + }) + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(`FCM OAuth token exchange failed: ${response.status} ${body}`) + } + + const json = await response.json() as { access_token?: string; expires_in?: number } + if (!json.access_token) { + throw new Error('FCM OAuth response missing access_token') + } + + const expiresInSec = json.expires_in ?? 3600 + cachedToken = { + accessToken: json.access_token, + expiresAtMs: nowMs + expiresInSec * 1000 + } + return cachedToken.accessToken +} + +/** Test helper */ +export function clearFcmAccessTokenCache(): void { + cachedToken = null +} diff --git a/hub/src/fcm/fcmConfig.ts b/hub/src/fcm/fcmConfig.ts new file mode 100644 index 0000000000..626c016bb4 --- /dev/null +++ b/hub/src/fcm/fcmConfig.ts @@ -0,0 +1,26 @@ +import { existsSync } from 'node:fs' +import { loadServiceAccount } from './fcmAuth' + +export type FcmConfig = { + projectId: string + serviceAccountPath: string + serviceAccount: ReturnType +} + +export function resolveFcmConfig(): FcmConfig | null { + const serviceAccountPath = process.env.FCM_SERVICE_ACCOUNT_PATH?.trim() + if (!serviceAccountPath || !existsSync(serviceAccountPath)) { + return null + } + + const serviceAccount = loadServiceAccount(serviceAccountPath) + const projectId = process.env.FCM_PROJECT_ID?.trim() + || serviceAccount.project_id + || null + if (!projectId) { + console.warn('[Fcm] FCM_PROJECT_ID unset and service account JSON has no project_id') + return null + } + + return { projectId, serviceAccountPath, serviceAccount } +} diff --git a/hub/src/fcm/fcmNotificationChannel.test.ts b/hub/src/fcm/fcmNotificationChannel.test.ts new file mode 100644 index 0000000000..cb1185b464 --- /dev/null +++ b/hub/src/fcm/fcmNotificationChannel.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it } from 'bun:test' +import type { Session } from '../sync/syncEngine' +import { FcmNotificationChannel } from './fcmNotificationChannel' +import type { FcmSendPayload } from './fcmService' + +function createSession(overrides: Partial = {}): Session { + return { + id: 'session-ready', + namespace: 'default', + name: 'Demo', + active: true, + metadata: { flavor: 'codex', name: 'Demo' }, + ...overrides + } as Session +} + +describe('FcmNotificationChannel', () => { + it('always fires FCM regardless of PWA visibility (wrist-first)', async () => { + const sent: FcmSendPayload[] = [] + const toasts: unknown[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { + sendToast: async (_namespace: string, event: unknown) => { + toasts.push(event) + return 1 + } + } as never, + { + hasVisibleConnection: () => true + } as never + ) + + await channel.sendReady(createSession()) + + // The watch is the canonical surface when a native companion is + // registered. The previous behaviour silently swallowed FCM when + // the PWA was foreground - that broke the wrist-first UX. We now + // fire FCM unconditionally and let the PWA's own SyncEngine event + // stream handle in-page toasts (or not, per UX preference). + expect(sent).toHaveLength(1) + expect(toasts).toHaveLength(0) + }) + + it('includes requestId on permission-request payloads', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never + ) + + await channel.sendPermissionRequest(createSession({ + agentState: { + requests: { + 'req-42': { tool: 'Bash', arguments: {} } + } + } + })) + + expect(sent).toHaveLength(1) + expect(sent[0].data.type).toBe('permission-request') + expect(sent[0].data.requestId).toBe('req-42') + expect(sent[0].data.contractVersion).toBe('1') + }) + + it('enriches permission-request body with tool args (Edit)', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never + ) + + await channel.sendPermissionRequest(createSession({ + agentState: { + requests: { + 'req-99': { + tool: 'Edit', + arguments: { + file_path: '/home/u/proj/hub/src/server.ts', + old_string: 'foo', + new_string: 'bar' + } + } + } + } + })) + + expect(sent).toHaveLength(1) + const body = sent[0].body ?? '' + const dataBody = sent[0].data.body ?? '' + // Glance line: agent + tool + compact arg (last two path segments). + expect(body).toContain('Edit') + expect(body).toContain('hub/src/server.ts') + // Detail: full file path on its own line, plus old/new previews - + // visible when the watch operator taps to expand. + expect(body).toContain('File: /home/u/proj/hub/src/server.ts') + expect(body).toContain('Old: "foo"') + expect(body).toContain('New: "bar"') + // data.body must mirror notification body so the watch sees the same + // text the FCM `notification` field would. + expect(dataBody).toBe(body) + }) + + it('falls back gracefully when no tool args are present', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never + ) + + await channel.sendPermissionRequest(createSession({ + agentState: { + requests: { + 'req-1': { tool: 'NewExperimentalTool', arguments: { foo: 'bar' } } + } + } + })) + + const body = sent[0].body ?? '' + // Compact returns '' for tools not in its switch table, so the glance + // line collapses to bare " " - confirming we never emit + // " : " with a dangling colon. + expect(body.split('\n')[0]).toMatch(/NewExperimentalTool$/) + expect(body).not.toContain(': \n') + }) + + function makeStoreWithMessages(messages: Array<{ content: unknown }>) { + // Minimal store stub: only the bits FcmNotificationChannel touches. + // Mirrors the real `getMessages` contract: callers receive the last N + // rows in ASCENDING seq order (oldest first, latest last). + return { + messages: { + getMessages: (_sessionId: string, _limit: number) => messages.map((m, i) => ({ + id: `m-${i}`, + sessionId: 'session-ready', + content: m.content, + createdAt: i, + seq: i + 1, + localId: null, + invokedAt: null, + scheduledAt: null + })) + } + } as never + } + + it('sendReady prefers AGENT_NOTIFY_SUMMARY when last assistant message has one', async () => { + const sent: FcmSendPayload[] = [] + const store = makeStoreWithMessages([ + // DESC order: index 0 = latest. Latest assistant text contains a summary. + { + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'Did the work.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"summary":"Tokens revoked","action":"Upload preview","status":"done"}' + } + } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent).toHaveLength(1) + const p = sent[0] + expect(p.title).toBe('Codex - Demo') + expect(p.body).toBe('Tokens revoked\n-> Upload preview') + expect(p.data.notifySummary).toBeDefined() + const parsed = JSON.parse(p.data.notifySummary as string) + expect(parsed.summary).toBe('Tokens revoked') + expect(parsed.action).toBe('Upload preview') + }) + + it('sendReady truncates last assistant text when no summary is present', async () => { + const sent: FcmSendPayload[] = [] + const longText = 'A'.repeat(500) + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: longText } } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + const body = sent[0].body + expect(body.length).toBeLessThanOrEqual(280) + expect(body.endsWith('...')).toBe(true) + expect(sent[0].data.notifySummary).toBeUndefined() + }) + + it('sendReady skips tool-call messages and uses the last assistant TEXT message', async () => { + const sent: FcmSendPayload[] = [] + // Real getMessages returns ASC (oldest first, newest last). The newest + // here is a tool-call-result; the channel must walk back past two + // tool-call frames to find the actual assistant text. + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'The actual reply.' } } + } + }, + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'tool-call', name: 'Bash', callId: 'x', input: {} } } + } + }, + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'tool-call-result', output: {} } } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent[0].body).toBe('The actual reply.') + }) + + it('sendReady picks the LATEST assistant text when multiple text messages exist (ASC ordering regression guard)', async () => { + const sent: FcmSendPayload[] = [] + // Two text messages, oldest first - the channel must return the + // last one ("Latest reply.") not the first ("Older reply."). + // This guards against a real bug where we walked the array + // assuming DESC ordering and picked the oldest. + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'Older reply.' } } + } + }, + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'Latest reply.' } } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent[0].body).toBe('Latest reply.') + }) + + it('sendReady falls back to "is waiting" line when no agent text exists', async () => { + const sent: FcmSendPayload[] = [] + const store = makeStoreWithMessages([]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent[0].title).toBe('Ready for input') + expect(sent[0].body).toBe('Codex is waiting in Demo') + }) + + it('sendReady falls back when the channel has no store (test/legacy wiring)', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + // store omitted on purpose + ) + + await channel.sendReady(createSession()) + + expect(sent[0].title).toBe('Ready for input') + expect(sent[0].body).toBe('Codex is waiting in Demo') + }) + + it('sets severity=info on ready notifications', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + await channel.sendReady(createSession()) + expect(sent[0].data.severity).toBe('info') + }) + + it('sets severity=warning on permission-request notifications', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + await channel.sendPermissionRequest(createSession({ + agentState: { requests: { 'r-1': { tool: 'Bash', arguments: {} } } } + })) + expect(sent[0].data.severity).toBe('warning') + }) + + it('sets severity=success on completed task notifications', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + await channel.sendTaskNotification(createSession(), { status: 'completed', summary: 'Tests passed' }) + expect(sent[0].data.severity).toBe('success') + }) + + it('sets severity=error on failed task notifications', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + // The hub's failure detection catches 'failed' / 'error' / 'killed' / 'aborted'. + for (const status of ['failed', 'error', 'killed', 'aborted']) { + sent.length = 0 + await channel.sendTaskNotification(createSession(), { status, summary: 'oh no' }) + expect(sent[0].data.severity).toBe('error') + } + }) +}) diff --git a/hub/src/fcm/fcmNotificationChannel.ts b/hub/src/fcm/fcmNotificationChannel.ts new file mode 100644 index 0000000000..18422a1b06 --- /dev/null +++ b/hub/src/fcm/fcmNotificationChannel.ts @@ -0,0 +1,275 @@ +import type { Session } from '../sync/syncEngine' +import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes' +import { getAgentName, getSessionName } from '../notifications/sessionInfo' +import { formatToolArgumentsCompact, formatToolArgumentsDetailed } from '../notifications/toolArgs' +import { extractAssistantPlainText, extractNotifySummary, unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' +import type { Store } from '../store' +import type { SSEManager } from '../sse/sseManager' +import type { VisibilityTracker } from '../visibility/visibilityTracker' +import type { FcmSendPayload, FcmService } from './fcmService' + +const CONTRACT_VERSION = '1' + +/** + * `ready` body content limit for the wrist-glance line on the watch. + * BigTextStyle still expands when the operator taps, so this cap only + * affects the collapsed glance. ~280 chars matches the watch's three-line + * collapsed render at default font scale. + */ +const READY_BODY_GLANCE_LIMIT = 280 + +export class FcmNotificationChannel implements NotificationChannel { + constructor( + private readonly fcmService: FcmService, + private readonly sseManager: SSEManager, + private readonly visibilityTracker: VisibilityTracker, + private readonly store?: Store + ) {} + + async sendPermissionRequest(session: Session): Promise { + if (!session.active) { + return + } + + const name = getSessionName(session) + const agentName = getAgentName(session) + const requests = session.agentState?.requests ?? null + const requestEntries = requests ? Object.entries(requests) : [] + const [requestId, request] = requestEntries[0] ?? [undefined, null] + + // Glance line: keep brutally short so the wrist-collapsed + // notification still shows the first ~40 chars without truncation. + // Format: " : " e.g. "Claude Edit: .../hub/server.ts" + // Fallback when args aren't useful: " " e.g. "Cursor Bash" + const toolName = request?.tool ?? '' + const compact = request ? formatToolArgumentsCompact(request.tool, request.arguments) : '' + const glance = toolName + ? (compact ? `${agentName} ${toolName}: ${compact}` : `${agentName} ${toolName}`) + : `${agentName} - ${name}` + + // Detailed body: rendered when the operator taps the notification on + // Wear OS (BigTextStyle on the watch side). Lines after the first + // are hidden in the collapsed glance, so we can be generous here. + const detailed = request + ? formatToolArgumentsDetailed(request.tool, request.arguments, { maxArgLength: 120 }) + : '' + const bodyLines = [glance] + if (name && name !== glance) { + bodyLines.push(`Session: ${name}`) + } + if (detailed) { + bodyLines.push(detailed) + } + + const path = this.buildSessionPath(session.id) + + const payload = this.buildPayload({ + title: 'Permission Request', + body: bodyLines.join('\n'), + tag: `permission-${session.id}`, + type: 'permission-request', + sessionId: session.id, + sessionName: name, + url: path, + requestId, + severity: 'warning' + }) + + await this.deliver(session, payload) + } + + async sendReady(session: Session): Promise { + if (!session.active) { + return + } + + const agentName = getAgentName(session) + const name = getSessionName(session) + const path = this.buildSessionPath(session.id) + + const composed = this.composeReadyBody(session, agentName, name) + + const payload = this.buildPayload({ + title: composed.title, + body: composed.body, + tag: `ready-${session.id}`, + type: 'ready', + sessionId: session.id, + sessionName: name, + url: path, + severity: 'info' + }) + + if (composed.notifySummary) { + payload.data.notifySummary = JSON.stringify(composed.notifySummary) + } + + await this.deliver(session, payload) + } + + /** + * Build the title/body for a `ready` notification. + * + * Strategy: + * 1. If the operator's `AGENTS.md` has the agent emit + * `AGENT_NOTIFY_SUMMARY {...json...}` as the trailing line, parse it + * and use `summary` (+ `action` on a second line) for the body. + * Title becomes ` - ` so the summary text owns the + * body. + * 2. Otherwise fall back to the first ~280 chars of the most recent + * assistant text. Same title pattern. + * 3. If no assistant text can be found at all (cold start, store + * unavailable, all recent messages are tool calls), fall back to + * the previous " is waiting in " content so we + * never regress to a worse notification than today. + */ + private composeReadyBody( + session: Session, + agentName: string, + sessionName: string + ): { title: string; body: string; notifySummary?: Record } { + const fallback = { + title: 'Ready for input', + body: `${agentName} is waiting in ${sessionName}` + } + + if (!this.store) return fallback + + const lastText = this.findLastAssistantPlainText(session.id) + if (!lastText) return fallback + + const summary = extractNotifySummary(lastText) + const headerTitle = `${agentName} - ${sessionName}` + + if (summary?.summary) { + const lines: string[] = [summary.summary] + if (summary.action && summary.action !== summary.summary) { + lines.push(`-> ${summary.action}`) + } + return { + title: headerTitle, + body: lines.join('\n'), + notifySummary: { ...summary } + } + } + + const trimmed = lastText.trim() + if (trimmed.length === 0) return fallback + + // -3 leaves room for the '...' suffix so the body still fits the + // glance limit. Without this it would tip over by 2 characters. + const body = trimmed.length > READY_BODY_GLANCE_LIMIT + ? trimmed.slice(0, READY_BODY_GLANCE_LIMIT - 3).trimEnd() + '...' + : trimmed + + return { title: headerTitle, body } + } + + /** + * Walk the most recent stored messages and return the plain text of + * the latest assistant message that *has* text content. Tool calls, + * tool results, and reasoning blocks are skipped (they have no + * text body, they would just show as `null` and force the fallback). + */ + private findLastAssistantPlainText(sessionId: string): string | null { + if (!this.store) return null + + let messages + try { + // 20 is generous: most ready events fire 1-3 messages after + // the latest assistant text, and we cap to 20 to avoid + // pathological scans on long sessions. + messages = this.store.messages.getMessages(sessionId, 20) + } catch { + return null + } + + // getMessages returns the LAST `limit` rows in ASCENDING seq order + // (it queries DESC then reverses for caller convenience), so the + // freshest message lives at the END of the array. Walk backwards + // so we hit the latest assistant text first. + for (let i = messages.length - 1; i >= 0; i -= 1) { + const msg = messages[i] + const record = unwrapRoleWrappedRecordEnvelope(msg.content) + if (record?.role !== 'agent') continue + const text = extractAssistantPlainText(record.content) + if (text && text.trim().length > 0) { + return text + } + } + return null + } + + async sendTaskNotification(session: Session, notification: TaskNotification): Promise { + if (!session.active) { + return + } + + const agentName = getAgentName(session) + const name = getSessionName(session) + const normalizedStatus = notification.status?.trim().toLowerCase() + const isFailure = normalizedStatus === 'failed' + || normalizedStatus === 'error' + || normalizedStatus === 'killed' + || normalizedStatus === 'aborted' + const path = this.buildSessionPath(session.id) + + const payload = this.buildPayload({ + title: isFailure ? 'Task failed' : 'Task completed', + body: `${agentName} · ${name} · ${notification.summary}`, + type: 'task-notification', + sessionId: session.id, + sessionName: name, + url: path, + severity: isFailure ? 'error' : 'success' + }) + + await this.deliver(session, payload) + } + + private buildPayload(input: { + title: string + body: string + tag?: string + type: string + sessionId: string + sessionName: string + url: string + requestId?: string + severity?: 'info' | 'success' | 'warning' | 'error' + }): FcmSendPayload { + return { + title: input.title, + body: input.body, + tag: input.tag, + data: { + type: input.type, + sessionId: input.sessionId, + sessionName: input.sessionName, + url: input.url, + requestId: input.requestId, + title: input.title, + body: input.body, + contractVersion: CONTRACT_VERSION, + severity: input.severity + } + } + } + + private async deliver(session: Session, payload: FcmSendPayload): Promise { + // Native companion is the canonical surface: always fire FCM when the + // hub asks us to. The previous SSE-toast shortcut here meant that + // when the operator had the PWA open in foreground, the watch got + // NOTHING - the in-page React toast was the only signal. That broke + // the wrist-first UX (the whole point of installing a watch app) + // and confused the operator about whether the agent was making + // progress. SSE in-page toasts are still emitted by the PWA's own + // SyncEngine event stream for users who want them; this channel's + // job is to reach the wrist, period. + await this.fcmService.sendToNamespace(session.namespace, payload) + } + + private buildSessionPath(sessionId: string): string { + return `/sessions/${sessionId}` + } +} diff --git a/hub/src/fcm/fcmService.ts b/hub/src/fcm/fcmService.ts new file mode 100644 index 0000000000..ebdcd38b00 --- /dev/null +++ b/hub/src/fcm/fcmService.ts @@ -0,0 +1,144 @@ +import type { Store } from '../store' +import { getFcmAccessToken, type ServiceAccount } from './fcmAuth' + +export type FcmDataPayload = { + type: string + sessionId: string + sessionName: string + url: string + requestId?: string + title: string + body: string + contractVersion: string + /** + * Visual urgency hint for the client. Drives the notification accent + * color on Wear OS / phone, and may be used for sound channel routing + * later. Independent of `type` because `task-notification` is one + * type that splits across success and failure outcomes. + * + * - `info` ready / ambient ('no action needed') -> blue + * - `success` task completed -> green + * - `warning` permission request -> amber + * - `error` task failed / aborted -> red + */ + severity?: 'info' | 'success' | 'warning' | 'error' + /** + * JSON-stringified `AGENT_NOTIFY_SUMMARY` object when the agent emitted + * one as the trailing line of its last message. The companion app may + * use this for richer rendering or future event-bus routing; absent + * when the agent did not emit a summary. + */ + notifySummary?: string +} + +export type FcmSendPayload = { + title: string + body: string + tag?: string + data: FcmDataPayload +} + +type FcmSendResult = { + sent: number + failed: number + invalidTokens: string[] +} + +export class FcmService { + constructor( + private readonly projectId: string, + private readonly serviceAccount: ServiceAccount, + private readonly store: Store + ) {} + + async sendToNamespace(namespace: string, payload: FcmSendPayload): Promise { + const devices = this.store.fcm.getDevicesByNamespace(namespace) + if (devices.length === 0) { + return { sent: 0, failed: 0, invalidTokens: [] } + } + + const accessToken = await getFcmAccessToken(this.serviceAccount) + const invalidTokens: string[] = [] + let sent = 0 + let failed = 0 + + await Promise.all(devices.map(async (device) => { + const ok = await this.sendToToken(accessToken, device.token, payload, device.platform) + if (ok) { + sent += 1 + return + } + failed += 1 + invalidTokens.push(device.token) + this.store.fcm.removeDeviceByToken(namespace, device.token) + })) + + return { sent, failed, invalidTokens } + } + + private async sendToToken( + accessToken: string, + token: string, + payload: FcmSendPayload, + platform: 'phone' | 'wear' + ): Promise { + const url = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send` + const dataRecord: Record = { + type: payload.data.type, + sessionId: payload.data.sessionId, + sessionName: payload.data.sessionName, + url: payload.data.url, + title: payload.data.title, + body: payload.data.body, + contractVersion: payload.data.contractVersion + } + if (payload.data.requestId) { + dataRecord.requestId = payload.data.requestId + } + if (payload.data.severity) { + dataRecord.severity = payload.data.severity + } + if (payload.data.notifySummary) { + dataRecord.notifySummary = payload.data.notifySummary + } + + // Data-only: if we also send `notification`, Android does not call + // onMessageReceived while backgrounded — Wear relay never runs. + const message: Record = { + token, + data: dataRecord, + android: { + priority: 'HIGH' + } + } + + if (platform === 'wear') { + message.android = { + ...(message.android as Record), + direct_boot_ok: true + } + } + + const response = await fetch(url, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ message }) + }) + + if (response.ok) { + return true + } + + const body = await response.text().catch(() => '') + const invalid = response.status === 404 + || body.includes('UNREGISTERED') + || body.includes('NOT_FOUND') + if (!invalid) { + console.error('[FcmService] Send failed:', response.status, body.slice(0, 200)) + } + return false + } +} diff --git a/hub/src/notifications/toolArgs.ts b/hub/src/notifications/toolArgs.ts new file mode 100644 index 0000000000..7c4a63a174 --- /dev/null +++ b/hub/src/notifications/toolArgs.ts @@ -0,0 +1,157 @@ +/** + * Tool argument formatters shared across notification channels. + * + * Originally lived inside hub/src/telegram/sessionView.ts. Lifted into + * notifications/ so the FCM (Wear OS) channel can reuse the same + * tool-aware extraction without forking the switch table - keeping + * Telegram and Wear notifications in sync as we add tools. + * + * Two surfaces are exposed: + * + * - `formatToolArgumentsDetailed` - multi-line, Telegram-grade detail. + * Renders inside Telegram bot messages where vertical space is cheap. + * Also rendered by Wear OS when the operator taps the notification + * to expand (BigTextStyle). + * + * - `formatToolArgumentsCompact` - single-line, glance-friendly. + * Squeezed into the wrist's collapsed notification line (~40 chars + * before truncation). The detailed form is the source of truth; the + * compact form is a deliberately-brutal summary of just enough to + * know which file/cmd/url is at stake. + */ + +const DEFAULT_DETAIL_MAX_ARG_LENGTH = 150 + +function truncate(text: string, maxLen: number): string { + if (!text) return '' + if (text.length <= maxLen) return text + return text.slice(0, Math.max(0, maxLen - 3)) + '...' +} + +function shortPath(file: string): string { + if (!file) return '' + const segs = file.split('/') + if (segs.length <= 2) return file + return `.../${segs.slice(-2).join('/')}` +} + +export function formatToolArgumentsDetailed( + tool: string, + args: unknown, + opts: { maxArgLength?: number } = {} +): string { + if (!args || typeof args !== 'object') return '' + const maxLen = opts.maxArgLength ?? DEFAULT_DETAIL_MAX_ARG_LENGTH + const a = args as Record + + try { + switch (tool) { + case 'Edit': { + const file = (a.file_path as string | undefined) ?? (a.path as string | undefined) ?? 'unknown' + const oldStr = a.old_string ? truncate(String(a.old_string), 50) : '' + const newStr = a.new_string ? truncate(String(a.new_string), 50) : '' + let result = `File: ${truncate(file, maxLen)}` + if (oldStr) result += `\nOld: "${oldStr}"` + if (newStr) result += `\nNew: "${newStr}"` + return result + } + case 'Write': { + const file = (a.file_path as string | undefined) ?? (a.path as string | undefined) ?? 'unknown' + const content = a.content ? `${String(a.content).length} chars` : '' + return `File: ${truncate(file, maxLen)}${content ? ` (${content})` : ''}` + } + case 'Read': { + const file = (a.file_path as string | undefined) ?? (a.path as string | undefined) ?? 'unknown' + return `File: ${truncate(file, maxLen)}` + } + case 'Bash': { + const cmd = (a.command as string | undefined) ?? '' + return `Command: ${truncate(cmd, maxLen)}` + } + case 'Agent': + case 'Task': { + const desc = (a.description as string | undefined) ?? (a.prompt as string | undefined) ?? '' + return `Task: ${truncate(desc, maxLen)}` + } + case 'Grep': + case 'Glob': { + const pattern = (a.pattern as string | undefined) ?? '' + const path = (a.path as string | undefined) ?? '' + let result = `Pattern: ${pattern}` + if (path) result += `\nPath: ${truncate(path, 80)}` + return result + } + case 'WebFetch': { + const url = (a.url as string | undefined) ?? '' + return `URL: ${truncate(url, maxLen)}` + } + case 'TodoWrite': { + const todos = a.todos as unknown[] | undefined + const count = todos?.length ?? 0 + return `Updating ${count} todo items` + } + default: { + const argStr = JSON.stringify(args) + if (argStr && argStr.length > 10) { + return `Args: ${truncate(argStr, maxLen)}` + } + return '' + } + } + } catch { + return '' + } +} + +/** + * Single-line summary tuned for the Wear OS collapsed notification line + * (~40 chars displayable before the system truncates). Always returns + * a one-liner with no embedded newlines. Empty string means we have no + * useful summary to show beyond the tool name itself. + */ +export function formatToolArgumentsCompact(tool: string, args: unknown): string { + if (!args || typeof args !== 'object') return '' + const a = args as Record + + try { + switch (tool) { + case 'Edit': + case 'Write': + case 'Read': { + const file = (a.file_path as string | undefined) ?? (a.path as string | undefined) + if (!file) return '' + return shortPath(file) + } + case 'Bash': { + const cmd = (a.command as string | undefined) ?? '' + return truncate(cmd, 60) + } + case 'Agent': + case 'Task': { + const desc = (a.description as string | undefined) ?? (a.prompt as string | undefined) ?? '' + return truncate(desc, 60) + } + case 'Grep': + case 'Glob': { + const pattern = (a.pattern as string | undefined) ?? '' + return truncate(pattern, 60) + } + case 'WebFetch': { + const url = (a.url as string | undefined) ?? '' + try { + if (url) return new URL(url).host + } catch { /* fall through */ } + return truncate(url, 60) + } + case 'TodoWrite': { + const todos = a.todos as unknown[] | undefined + const count = todos?.length ?? 0 + return `${count} items` + } + default: + return '' + } + } catch { + return '' + } +} diff --git a/hub/src/push/pushNotificationChannel.test.ts b/hub/src/push/pushNotificationChannel.test.ts index 8ba04c7fbd..6906bce3fe 100644 --- a/hub/src/push/pushNotificationChannel.test.ts +++ b/hub/src/push/pushNotificationChannel.test.ts @@ -75,4 +75,99 @@ describe('PushNotificationChannel', () => { expect(pushed[0].payload.tag).toBeUndefined() expect(pushed[1].payload.tag).toBeUndefined() }) + + it('skips web-push fallback when a native companion is registered for the namespace', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '', + (namespace: string) => namespace === 'default' + ) + + await channel.sendPermissionRequest(createSession({ + agentState: { + requests: { 'req-1': { tool: 'Bash', arguments: {} } } + } + })) + await channel.sendReady(createSession()) + await channel.sendTaskNotification(createSession(), { + status: 'completed', + summary: 'Done' + }) + + // Native companion handles all three; web-push must stay quiet to + // avoid double-notifying the operator on phone+watch+browser. + expect(pushed).toHaveLength(0) + }) + + it('still sends web-push when namespace has no native companion', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '', + () => false + ) + + await channel.sendReady(createSession()) + + expect(pushed).toHaveLength(1) + }) + + it('also skips SSE in-page toast when a native companion is registered (defer-to-native)', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const toasts: unknown[] = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async (_namespace: string, event: unknown) => { + toasts.push(event) + return 99 + } + } as never, + { + hasVisibleConnection: () => true + } as never, + '', + (namespace: string) => namespace === 'default' + ) + + await channel.sendReady(createSession()) + await channel.sendPermissionRequest(createSession({ + agentState: { requests: { 'r-1': { tool: 'Bash', arguments: {} } } } + })) + await channel.sendTaskNotification(createSession(), { + status: 'completed', + summary: 'Done' + }) + + // Even when the PWA is foreground/visible, the operator asked to mute + // it - the in-page React toast and the OS web-push are both dropped + // when an FCM companion is on the wrist. + expect(toasts).toHaveLength(0) + expect(pushed).toHaveLength(0) + }) }) diff --git a/hub/src/push/pushNotificationChannel.ts b/hub/src/push/pushNotificationChannel.ts index de3dbe8889..a4ec4a3421 100644 --- a/hub/src/push/pushNotificationChannel.ts +++ b/hub/src/push/pushNotificationChannel.ts @@ -5,23 +5,56 @@ import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { PushPayload, PushService } from './pushService' +/** + * Probe that returns true when a native companion (Android FCM phone/wear) + * device is registered for this namespace. When provided to + * PushNotificationChannel, the channel suppresses its web-push fallback so + * the operator stops getting double notifications: one on the native app, + * one on the PWA service worker. The in-app SSE toast path is unaffected - + * if the operator has the PWA actively in the foreground, they'll still see + * the toast inside the page. + */ +export type NativeFallbackProbe = (namespace: string) => boolean + export class PushNotificationChannel implements NotificationChannel { constructor( private readonly pushService: PushService, private readonly sseManager: SSEManager, private readonly visibilityTracker: VisibilityTracker, - _appUrl: string + _appUrl: string, + private readonly nativeFallbackProbe?: NativeFallbackProbe ) {} + /** + * Returns true if web-push delivery should be skipped because a native + * companion will already cover this namespace. Centralised so all three + * send* methods stay in lock-step. + */ + private shouldSkipWebPush(namespace: string): boolean { + return this.nativeFallbackProbe?.(namespace) ?? false + } + + /** + * Debug observability: gated on `HAPI_NOTIFY_DEBUG=1`. Lets the operator + * see which branch each notification took so we can root-cause "still + * getting PWA notifications" reports without committing permanent log + * spam to the hub journal. + */ + private logBranch(method: string, namespace: string, branch: string, extra: string = ''): void { + if (process.env.HAPI_NOTIFY_DEBUG !== '1') return + const note = extra ? ` ${extra}` : '' + console.log(`[Push.${method}] ns=${namespace} ${branch}${note}`) + } + async sendPermissionRequest(session: Session): Promise { if (!session.active) { return } const name = getSessionName(session) - const request = session.agentState?.requests - ? Object.values(session.agentState.requests)[0] - : null + const requests = session.agentState?.requests ?? null + const requestEntries = requests ? Object.entries(requests) : [] + const [requestId, request] = requestEntries[0] ?? [undefined, null] const toolName = request?.tool ? ` (${request.tool})` : '' const payload: PushPayload = { @@ -31,10 +64,20 @@ export class PushNotificationChannel implements NotificationChannel { data: { type: 'permission-request', sessionId: session.id, - url: this.buildSessionPath(session.id) + url: this.buildSessionPath(session.id), + requestId } } + // Native-companion-first: when an FCM device is registered for this + // namespace the watch already covers this notification end-to-end. + // Skip the SSE in-page toast AND the web-push fallback - both are + // redundant surfaces that the operator explicitly asked us to mute. + if (this.shouldSkipWebPush(session.namespace)) { + this.logBranch('permission', session.namespace, 'defer-to-native', 'native-companion-registered') + return + } + const url = payload.data?.url ?? this.buildSessionPath(session.id) if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { const delivered = await this.sseManager.sendToast(session.namespace, { @@ -47,10 +90,15 @@ export class PushNotificationChannel implements NotificationChannel { } }) if (delivered > 0) { + this.logBranch('permission', session.namespace, 'sse-toast-delivered', `count=${delivered}`) return } + this.logBranch('permission', session.namespace, 'sse-toast-zero', 'visible but delivered=0') + } else { + this.logBranch('permission', session.namespace, 'not-visible') } + this.logBranch('permission', session.namespace, 'web-push-fired') await this.pushService.sendToNamespace(session.namespace, payload) } @@ -73,6 +121,11 @@ export class PushNotificationChannel implements NotificationChannel { } } + if (this.shouldSkipWebPush(session.namespace)) { + this.logBranch('ready', session.namespace, 'defer-to-native', 'native-companion-registered') + return + } + const url = payload.data?.url ?? this.buildSessionPath(session.id) if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { const delivered = await this.sseManager.sendToast(session.namespace, { @@ -85,10 +138,15 @@ export class PushNotificationChannel implements NotificationChannel { } }) if (delivered > 0) { + this.logBranch('ready', session.namespace, 'sse-toast-delivered', `count=${delivered}`) return } + this.logBranch('ready', session.namespace, 'sse-toast-zero', 'visible but delivered=0') + } else { + this.logBranch('ready', session.namespace, 'not-visible') } + this.logBranch('ready', session.namespace, 'web-push-fired') await this.pushService.sendToNamespace(session.namespace, payload) } @@ -115,6 +173,11 @@ export class PushNotificationChannel implements NotificationChannel { } } + if (this.shouldSkipWebPush(session.namespace)) { + this.logBranch('task', session.namespace, 'defer-to-native', 'native-companion-registered') + return + } + const url = payload.data?.url ?? this.buildSessionPath(session.id) if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { const delivered = await this.sseManager.sendToast(session.namespace, { @@ -127,10 +190,15 @@ export class PushNotificationChannel implements NotificationChannel { } }) if (delivered > 0) { + this.logBranch('task', session.namespace, 'sse-toast-delivered', `count=${delivered}`) return } + this.logBranch('task', session.namespace, 'sse-toast-zero', 'visible but delivered=0') + } else { + this.logBranch('task', session.namespace, 'not-visible') } + this.logBranch('task', session.namespace, 'web-push-fired') await this.pushService.sendToNamespace(session.namespace, payload) } diff --git a/hub/src/push/pushService.ts b/hub/src/push/pushService.ts index 3a02d1d92e..e44a8fd9ef 100644 --- a/hub/src/push/pushService.ts +++ b/hub/src/push/pushService.ts @@ -10,6 +10,8 @@ export type PushPayload = { type: string sessionId: string url: string + /** First pending permission request id (permission-request pushes only). */ + requestId?: string } } diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 58a4734477..c97f125ffb 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -11,6 +11,9 @@ import { SSEManager } from './sse/sseManager' import { getOrCreateVapidKeys } from './config/vapidKeys' import { PushService } from './push/pushService' import { PushNotificationChannel } from './push/pushNotificationChannel' +import { FcmService } from './fcm/fcmService' +import { FcmNotificationChannel } from './fcm/fcmNotificationChannel' +import { resolveFcmConfig } from './fcm/fcmConfig' import { VisibilityTracker } from './visibility/visibilityTracker' import { TunnelManager } from './tunnel' import { waitForTunnelTlsReady } from './tunnel/tlsGate' @@ -196,10 +199,31 @@ export async function startHub(options: StartHubOptions = {}): Promise + store.fcm.getDevicesByNamespace(namespace).length > 0 + const notificationChannels: NotificationChannel[] = [ - new PushNotificationChannel(pushService, sseManager, visibilityTracker, config.publicUrl) + new PushNotificationChannel( + pushService, + sseManager, + visibilityTracker, + config.publicUrl, + nativeFallbackProbe + ) ] + const fcmConfig = resolveFcmConfig() + if (fcmConfig) { + const fcmService = new FcmService(fcmConfig.projectId, fcmConfig.serviceAccount, store) + notificationChannels.push(new FcmNotificationChannel(fcmService, sseManager, visibilityTracker, store)) + console.log('[Fcm] Native companion push enabled (project:', fcmConfig.projectId + ')') + } + if (config.serverChanSendKey && config.serverChanNotification) { notificationChannels.push(new ServerChanChannel(config.serverChanSendKey, config.publicUrl)) } diff --git a/hub/src/store/fcmDevices.ts b/hub/src/store/fcmDevices.ts new file mode 100644 index 0000000000..b21c2fe148 --- /dev/null +++ b/hub/src/store/fcmDevices.ts @@ -0,0 +1,62 @@ +import type { Database } from 'bun:sqlite' + +import type { StoredFcmDevice } from './types' + +type DbFcmDeviceRow = { + id: number + namespace: string + token: string + platform: string + device_id: string + created_at: number + updated_at: number +} + +function toStoredFcmDevice(row: DbFcmDeviceRow): StoredFcmDevice { + return { + id: row.id, + namespace: row.namespace, + token: row.token, + platform: row.platform as StoredFcmDevice['platform'], + deviceId: row.device_id, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +export function upsertFcmDevice( + db: Database, + namespace: string, + device: { token: string; platform: 'phone' | 'wear'; deviceId: string } +): void { + const now = Date.now() + db.prepare(` + INSERT INTO fcm_devices ( + namespace, token, platform, device_id, created_at, updated_at + ) VALUES ( + @namespace, @token, @platform, @device_id, @created_at, @updated_at + ) + ON CONFLICT(namespace, device_id, platform) + DO UPDATE SET + token = excluded.token, + updated_at = excluded.updated_at + `).run({ + namespace, + token: device.token, + platform: device.platform, + device_id: device.deviceId, + created_at: now, + updated_at: now + }) +} + +export function removeFcmDeviceByToken(db: Database, namespace: string, token: string): void { + db.prepare('DELETE FROM fcm_devices WHERE namespace = ? AND token = ?').run(namespace, token) +} + +export function getFcmDevicesByNamespace(db: Database, namespace: string): StoredFcmDevice[] { + const rows = db.prepare( + 'SELECT * FROM fcm_devices WHERE namespace = ? ORDER BY updated_at DESC' + ).all(namespace) as DbFcmDeviceRow[] + return rows.map(toStoredFcmDevice) +} diff --git a/hub/src/store/fcmStore.ts b/hub/src/store/fcmStore.ts new file mode 100644 index 0000000000..b90ca1bbe6 --- /dev/null +++ b/hub/src/store/fcmStore.ts @@ -0,0 +1,23 @@ +import type { Database } from 'bun:sqlite' + +import type { StoredFcmDevice } from './types' +import { getFcmDevicesByNamespace, removeFcmDeviceByToken, upsertFcmDevice } from './fcmDevices' + +export class FcmStore { + constructor(private readonly db: Database) {} + + upsertDevice( + namespace: string, + device: { token: string; platform: 'phone' | 'wear'; deviceId: string } + ): void { + upsertFcmDevice(this.db, namespace, device) + } + + removeDeviceByToken(namespace: string, token: string): void { + removeFcmDeviceByToken(this.db, namespace, token) + } + + getDevicesByNamespace(namespace: string): StoredFcmDevice[] { + return getFcmDevicesByNamespace(this.db, namespace) + } +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index b0b2c6b05d..7b871f298d 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -5,6 +5,7 @@ import { dirname } from 'node:path' import { MachineStore } from './machineStore' import { MessageStore } from './messageStore' import { PushStore } from './pushStore' +import { FcmStore } from './fcmStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' @@ -12,6 +13,7 @@ export type { StoredMachine, StoredMessage, StoredPushSubscription, + StoredFcmDevice, StoredSession, StoredUser, VersionedUpdateResult @@ -20,6 +22,7 @@ export type { CancelQueuedMessageResult, LookupQueuedMessageResult } from './mes export { MachineStore } from './machineStore' export { MessageStore } from './messageStore' export { PushStore } from './pushStore' +export { FcmStore } from './fcmStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' @@ -29,7 +32,8 @@ const REQUIRED_TABLES = [ 'machines', 'messages', 'users', - 'push_subscriptions' + 'push_subscriptions', + 'fcm_devices' ] as const export class Store { @@ -42,6 +46,7 @@ export class Store { readonly messages: MessageStore readonly users: UserStore readonly push: PushStore + readonly fcm: FcmStore /** * Filesystem path of the underlying SQLite database, or ':memory:' for @@ -92,6 +97,7 @@ export class Store { this.messages = new MessageStore(this.db) this.users = new UserStore(this.db) this.push = new PushStore(this.db) + this.fcm = new FcmStore(this.db) } close(): void { @@ -252,6 +258,19 @@ export class Store { UNIQUE(namespace, endpoint) ); CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace); + + CREATE TABLE IF NOT EXISTS fcm_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + token TEXT NOT NULL, + platform TEXT NOT NULL, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(namespace, device_id, platform) + ); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_namespace ON fcm_devices(namespace); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_token ON fcm_devices(token); `) } @@ -409,6 +428,23 @@ export class Store { `) } + private migrateFromV9ToV10(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS fcm_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + token TEXT NOT NULL, + platform TEXT NOT NULL, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(namespace, device_id, platform) + ); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_namespace ON fcm_devices(namespace); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_token ON fcm_devices(token); + `) + } + private migrateFromV8ToV9(): void { const columns = this.getMessageColumnNames() if (columns.size === 0) { diff --git a/hub/src/store/migration-v10.test.ts b/hub/src/store/migration-v10.test.ts new file mode 100644 index 0000000000..e6c8a44f89 --- /dev/null +++ b/hub/src/store/migration-v10.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Store } from './index' + +describe('Store V9→V10 migration: fcm_devices', () => { + it('fresh DB has fcm_devices table', () => { + const store = new Store(':memory:') + expect(tableExists(store, 'fcm_devices')).toBe(true) + }) + + it('V9 DB migrates to V10: fcm_devices created', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v10-test-')) + const dbPath = join(dir, 'test.db') + let store: Store | undefined + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA journal_mode = WAL') + db.exec('PRAGMA foreign_keys = ON') + createV9Schema(db) + db.exec('PRAGMA user_version = 9') + db.close() + + store = new Store(dbPath) + expect(tableExists(store, 'fcm_devices')).toBe(true) + } finally { + store?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('upsert replaces token for same namespace+deviceId+platform', () => { + const store = new Store(':memory:') + store.fcm.upsertDevice('default', { + token: 'tok-a', + platform: 'phone', + deviceId: 'pixel-1' + }) + store.fcm.upsertDevice('default', { + token: 'tok-b', + platform: 'phone', + deviceId: 'pixel-1' + }) + const devices = store.fcm.getDevicesByNamespace('default') + expect(devices).toHaveLength(1) + expect(devices[0].token).toBe('tok-b') + }) +}) + +function tableExists(store: Store, name: string): boolean { + const db: Database = (store as unknown as { db: Database }).db + const row = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?" + ).get(name) as { name: string } | null + return row !== null +} + +function createV9Schema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + tag TEXT, + namespace TEXT NOT NULL DEFAULT 'default', + machine_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + agent_state TEXT, + agent_state_version INTEGER DEFAULT 1, + model TEXT, + model_reasoning_effort TEXT, + effort TEXT, + todos TEXT, + todos_updated_at INTEGER, + team_state TEXT, + team_state_updated_at INTEGER, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS machines ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + runner_state TEXT, + runner_state_version INTEGER DEFAULT 1, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + seq INTEGER NOT NULL, + local_id TEXT, + invoked_at INTEGER, + scheduled_at INTEGER, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_user_id TEXT NOT NULL, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + UNIQUE(platform, platform_user_id) + ); + + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(namespace, endpoint) + ); + `) +} diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index ae3f648291..6917c19a52 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -64,6 +64,16 @@ export type StoredPushSubscription = { createdAt: number } +export type StoredFcmDevice = { + id: number + namespace: string + token: string + platform: 'phone' | 'wear' + deviceId: string + createdAt: number + updatedAt: number +} + export type VersionedUpdateResult = | { result: 'success'; version: number; value: T } | { result: 'version-mismatch'; version: number; value: T } diff --git a/hub/src/web/routes/devices.test.ts b/hub/src/web/routes/devices.test.ts new file mode 100644 index 0000000000..07819e2dcc --- /dev/null +++ b/hub/src/web/routes/devices.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { SignJWT } from 'jose' +import type { WebAppEnv } from '../middleware/auth' +import { createAuthMiddleware } from '../middleware/auth' +import { Store } from '../../store' +import { createDevicesRoutes } from './devices' + +const JWT_SECRET = new TextEncoder().encode('test-secret') + +async function authHeaders() { + const token = await new SignJWT({ uid: 1, ns: 'default' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(JWT_SECRET) + return { authorization: `Bearer ${token}` } +} + +function createApp(store: Store) { + const app = new Hono() + app.use('*', createAuthMiddleware(JWT_SECRET)) + app.route('/api', createDevicesRoutes(store)) + return app +} + +describe('devices routes', () => { + it('registers and unregisters FCM devices for namespace', async () => { + const store = new Store(':memory:') + const app = createApp(store) + const headers = await authHeaders() + + const register = await app.request('/api/devices/register', { + method: 'POST', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ + token: 'fcm-tok-1', + platform: 'wear', + deviceId: 'watch-1' + }) + }) + expect(register.status).toBe(200) + + const devices = store.fcm.getDevicesByNamespace('default') + expect(devices).toHaveLength(1) + expect(devices[0].platform).toBe('wear') + + const unregister = await app.request('/api/devices/register', { + method: 'DELETE', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ token: 'fcm-tok-1' }) + }) + expect(unregister.status).toBe(200) + expect(store.fcm.getDevicesByNamespace('default')).toHaveLength(0) + }) +}) diff --git a/hub/src/web/routes/devices.ts b/hub/src/web/routes/devices.ts new file mode 100644 index 0000000000..e42fb6ca42 --- /dev/null +++ b/hub/src/web/routes/devices.ts @@ -0,0 +1,44 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import type { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' + +const registerSchema = z.object({ + token: z.string().min(1), + platform: z.enum(['phone', 'wear']), + deviceId: z.string().min(1).max(128) +}) + +const unregisterSchema = z.object({ + token: z.string().min(1) +}) + +export function createDevicesRoutes(store: Store): Hono { + const app = new Hono() + + app.post('/devices/register', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = registerSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + + const namespace = c.get('namespace') + store.fcm.upsertDevice(namespace, parsed.data) + return c.json({ ok: true }) + }) + + app.delete('/devices/register', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = unregisterSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + + const namespace = c.get('namespace') + store.fcm.removeDeviceByToken(namespace, parsed.data.token) + return c.json({ ok: true }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b0cf0592c5..2d7053100c 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -22,6 +22,7 @@ import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createCodexDesktopRoutes } from './routes/codexDesktop' import { createPushRoutes } from './routes/push' +import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' @@ -246,6 +247,7 @@ function createWebApp(options: { getSyncEngine: options.getSyncEngine })) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) + app.route('/api', createDevicesRoutes(options.store)) app.route('/api', createVoiceRoutes()) // Skip static serving in relay mode, show helpful message on root diff --git a/shared/src/messages.test.ts b/shared/src/messages.test.ts new file mode 100644 index 0000000000..b6f4de141a --- /dev/null +++ b/shared/src/messages.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from 'bun:test' +import { + extractAssistantPlainText, + extractNotifySummary, + isRedundantGoalStatusEventContent, + type NotifySummary +} from './messages' + +describe('extractAssistantPlainText', () => { + test('returns null for non-objects', () => { + expect(extractAssistantPlainText(null)).toBeNull() + expect(extractAssistantPlainText(undefined)).toBeNull() + expect(extractAssistantPlainText('string')).toBeNull() + expect(extractAssistantPlainText(42)).toBeNull() + }) + + test('extracts codex/message text', () => { + const content = { + type: 'codex', + data: { + type: 'message', + message: 'Hello there.' + } + } + expect(extractAssistantPlainText(content)).toBe('Hello there.') + }) + + test('returns null for codex/tool-call (no text)', () => { + const content = { + type: 'codex', + data: { + type: 'tool-call', + name: 'Edit', + callId: 'x', + input: {} + } + } + expect(extractAssistantPlainText(content)).toBeNull() + }) + + test('returns null for codex/tool-call-result (no text)', () => { + const content = { + type: 'codex', + data: { + type: 'tool-call-result', + output: {} + } + } + expect(extractAssistantPlainText(content)).toBeNull() + }) + + test('returns null when codex/message string is empty', () => { + const content = { type: 'codex', data: { type: 'message', message: '' } } + expect(extractAssistantPlainText(content)).toBeNull() + }) + + test('extracts output/assistant text from claude SDK content array', () => { + const content = { + type: 'output', + data: { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Line one.' }, + { type: 'tool_use', name: 'Edit' }, + { type: 'text', text: 'Line two.' } + ] + } + } + } + expect(extractAssistantPlainText(content)).toBe('Line one.\nLine two.') + }) + + test('returns null for output/assistant with no text blocks', () => { + const content = { + type: 'output', + data: { + type: 'assistant', + message: { content: [{ type: 'tool_use', name: 'Edit' }] } + } + } + expect(extractAssistantPlainText(content)).toBeNull() + }) + + test('returns null for output/user (not assistant)', () => { + const content = { type: 'output', data: { type: 'user', message: { content: [] } } } + expect(extractAssistantPlainText(content)).toBeNull() + }) + + test('returns null for unknown content shapes', () => { + expect(extractAssistantPlainText({ type: 'event', data: {} })).toBeNull() + expect(extractAssistantPlainText({ type: 'text' })).toBeNull() + }) +}) + +describe('extractNotifySummary', () => { + const FULL_LINE = 'AGENT_NOTIFY_SUMMARY {"version":1,"agent":"hapi-monitor agent","project":"hapi-monitor","status":"done","action":"Revoke tokens","summary":"Published v0.1.0"}' + + test('returns null on non-string input', () => { + expect(extractNotifySummary(null)).toBeNull() + expect(extractNotifySummary(undefined)).toBeNull() + expect(extractNotifySummary({})).toBeNull() + expect(extractNotifySummary(42)).toBeNull() + expect(extractNotifySummary('')).toBeNull() + }) + + test('parses a summary on its own line at the very end', () => { + const result = extractNotifySummary(FULL_LINE) + expect(result).not.toBeNull() + const r = result as NotifySummary + expect(r.version).toBe(1) + expect(r.agent).toBe('hapi-monitor agent') + expect(r.project).toBe('hapi-monitor') + expect(r.status).toBe('done') + expect(r.action).toBe('Revoke tokens') + expect(r.summary).toBe('Published v0.1.0') + }) + + test('parses summary as last non-empty line after preceding prose', () => { + const text = `Here is what I did.\n\nThings worked.\n\n${FULL_LINE}` + const r = extractNotifySummary(text) + expect(r?.summary).toBe('Published v0.1.0') + }) + + test('tolerates trailing whitespace and blank lines', () => { + const r = extractNotifySummary(`prose\n\n${FULL_LINE}\n\n \n`) + expect(r?.summary).toBe('Published v0.1.0') + }) + + test('returns null when summary is not on the LAST non-empty line', () => { + // Operator wrote prose AFTER the line - non-compliant. + const text = `${FULL_LINE}\nOh, one more thing.` + expect(extractNotifySummary(text)).toBeNull() + }) + + test('returns null when prefix is missing', () => { + expect(extractNotifySummary('NOTIFY_SUMMARY {"summary":"x"}')).toBeNull() + expect(extractNotifySummary('agent_notify_summary {"summary":"x"}')).toBeNull() + }) + + test('returns null when JSON is malformed', () => { + expect(extractNotifySummary('AGENT_NOTIFY_SUMMARY {bogus}')).toBeNull() + expect(extractNotifySummary('AGENT_NOTIFY_SUMMARY {"summary":')).toBeNull() + expect(extractNotifySummary('AGENT_NOTIFY_SUMMARY not-json')).toBeNull() + }) + + test('drops fields with wrong types but keeps valid ones', () => { + const text = 'AGENT_NOTIFY_SUMMARY {"version":"oops","summary":"x","action":42,"status":"done"}' + const r = extractNotifySummary(text) + expect(r?.summary).toBe('x') + expect(r?.status).toBe('done') + expect(r?.version).toBeUndefined() + expect(r?.action).toBeUndefined() + }) + + test('ignores in-message quotes of the line - only the LAST line is parsed', () => { + // This very test message contains the literal prefix in a quoted explanation, + // but the trailing line is plain prose, so we return null. + const text = `Earlier I described the format as 'AGENT_NOTIFY_SUMMARY {...}', but here is plain text.` + expect(extractNotifySummary(text)).toBeNull() + }) + + test('returns null for whitespace-only input', () => { + expect(extractNotifySummary(' \n\n ')).toBeNull() + }) + + test('handles JSON with internal braces (escaped within strings)', () => { + const text = 'AGENT_NOTIFY_SUMMARY {"summary":"thing {nested} thing","status":"done"}' + const r = extractNotifySummary(text) + expect(r?.summary).toBe('thing {nested} thing') + expect(r?.status).toBe('done') + }) +}) + +describe('extractNotifySummary + extractAssistantPlainText (integration)', () => { + test('codex assistant text containing a trailing summary line', () => { + const content = { + type: 'codex', + data: { + type: 'message', + message: 'Did the work.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done"}' + } + } + const text = extractAssistantPlainText(content) + expect(text).not.toBeNull() + const r = extractNotifySummary(text!) + expect(r?.summary).toBe('Done') + }) + + test('claude SDK output with summary in the last text block', () => { + const content = { + type: 'output', + data: { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Quick update.' }, + { type: 'text', text: 'AGENT_NOTIFY_SUMMARY {"summary":"All checks green","status":"done","action":"Merge PR"}' } + ] + } + } + } + const text = extractAssistantPlainText(content) + const r = extractNotifySummary(text!) + expect(r?.summary).toBe('All checks green') + expect(r?.action).toBe('Merge PR') + }) +}) + +describe('isRedundantGoalStatusEventContent (regression-guard for messages.ts edits)', () => { + test('still detects goal-active events', () => { + const value = { + role: 'agent', + content: { + type: 'event', + data: { type: 'message', message: 'Goal active · build the thing' } + } + } + expect(isRedundantGoalStatusEventContent(value)).toBe(true) + }) +}) diff --git a/shared/src/messages.ts b/shared/src/messages.ts index 7b65149d1a..551b308207 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -70,4 +70,107 @@ export function isRedundantGoalStatusEventContent(value: unknown): boolean { return isRedundantGoalStatusMessageText(data.message) } +/** + * Best-effort plain-text extraction from a stored agent message's `content`. + * + * Two structural shapes are common in this fork: + * + * 1. `codex` flavor: content.type = 'codex', content.data.type = 'message' + * -> assistant text at `content.data.message` (string). + * + * 2. `output` flavor (Claude SDK passthrough): content.type = 'output', + * content.data.type = 'assistant' -> text at + * `content.data.message.content[i].text` (array of `{type:'text', text}`). + * + * Returns `null` when the content does not look like assistant *text* + * (tool calls, tool results, reasoning, token counts, etc.) so callers can + * skip those messages and fall back to the previous one. + */ +export function extractAssistantPlainText(content: unknown): string | null { + if (!isObject(content)) return null + + if (content.type === 'codex') { + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'message') return null + return typeof data.message === 'string' && data.message.length > 0 + ? data.message + : null + } + + if (content.type === 'output') { + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'assistant') return null + const message = isObject(data.message) ? data.message : null + const blocks = Array.isArray(message?.content) ? message.content : null + if (!blocks) return null + const textParts: string[] = [] + for (const block of blocks) { + if (!isObject(block)) continue + if (block.type === 'text' && typeof block.text === 'string') { + textParts.push(block.text) + } + } + if (textParts.length === 0) return null + return textParts.join('\n') + } + + return null +} + +const NOTIFY_SUMMARY_PREFIX = 'AGENT_NOTIFY_SUMMARY ' + +export type NotifySummary = { + version?: number + agent?: string + project?: string + status?: string + action?: string + summary?: string +} + +/** + * Look for an `AGENT_NOTIFY_SUMMARY {...json...}` line as the **last + * non-empty line** of an agent's plain-text message. + * + * Strict end-anchor: anything below the JSON line (even whitespace) is + * fine, but if the agent wrote prose AFTER the line we treat it as + * non-compliant and return null. This also makes false positives from + * `AGENT_NOTIFY_SUMMARY` quoted inside an earlier paragraph harmless, + * because such a quote is never the last line. + * + * Returns the parsed object on success, `null` on any deviation. The + * shape is intentionally loose - we only trust `summary`, `action`, and + * `status` for notification rendering, but the full object is forwarded + * onto the meta-event bus when Phase 2 lands. + */ +export function extractNotifySummary(text: unknown): NotifySummary | null { + if (typeof text !== 'string' || text.length === 0) return null + + const lines = text.split('\n') + let lastIdx = lines.length - 1 + while (lastIdx >= 0 && lines[lastIdx].trim() === '') lastIdx -= 1 + if (lastIdx < 0) return null + + const lastLine = lines[lastIdx].trim() + if (!lastLine.startsWith(NOTIFY_SUMMARY_PREFIX)) return null + + const jsonPart = lastLine.slice(NOTIFY_SUMMARY_PREFIX.length).trim() + if (!jsonPart.startsWith('{') || !jsonPart.endsWith('}')) return null + + try { + const parsed: unknown = JSON.parse(jsonPart) + if (!isObject(parsed)) return null + const result: NotifySummary = {} + if (typeof parsed.version === 'number') result.version = parsed.version + if (typeof parsed.agent === 'string') result.agent = parsed.agent + if (typeof parsed.project === 'string') result.project = parsed.project + if (typeof parsed.status === 'string') result.status = parsed.status + if (typeof parsed.action === 'string') result.action = parsed.action + if (typeof parsed.summary === 'string') result.summary = parsed.summary + return result + } catch { + return null + } +} + export type { RoleWrappedRecord } From 0bd7cf72467e85f2eaac14ea5197131a8dc0b92d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:07:48 +0100 Subject: [PATCH 08/39] docs(contract): clarify scope - companion is remote-hub client, not hub-on-phone Adds a Scope section to the native-companion contract so anyone implementing it knows the audience: operators running the hub on a server who want phone/watch as a notification surface, not users expecting a Termux-bundled hub. Mirrors the framing now in heavygee/hapi-companion README. Co-authored-by: Cursor --- docs/api/native-companion-contract.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api/native-companion-contract.md b/docs/api/native-companion-contract.md index 2a0c8729e3..7457e6b50f 100644 --- a/docs/api/native-companion-contract.md +++ b/docs/api/native-companion-contract.md @@ -3,6 +3,10 @@ **Audience:** Implementers of native companion apps (Android phone + Wear OS, iOS, etc.) that pair with a hapi hub via FCM. **Auth:** Same JWT as the web client (`POST /api/bind` → `Authorization: Bearer`). +## Scope + +A companion implementing this contract is a **thin client to a remote hub**, not a hub-on-phone. It receives notifications and acts as a reply / approval surface for a hub running on a server the operator controls. It is not a substitute for `hapi-runner` running on Android via Termux. + --- ## Device registration (FCM) From 0ccb60f984bee2a30a3e5acaae8d92467f234168 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:18:27 +0100 Subject: [PATCH 09/39] docs(contract): correct Scope section - hub topology is unchanged Removes the prior framing that referenced a non-existent 'Termux hub-on-phone' alternative. This contract describes a native client to the same hub the PWA talks to; it does not change where the hub runs. Co-authored-by: Cursor --- docs/api/native-companion-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/native-companion-contract.md b/docs/api/native-companion-contract.md index 7457e6b50f..279dabf54b 100644 --- a/docs/api/native-companion-contract.md +++ b/docs/api/native-companion-contract.md @@ -5,7 +5,7 @@ ## Scope -A companion implementing this contract is a **thin client to a remote hub**, not a hub-on-phone. It receives notifications and acts as a reply / approval surface for a hub running on a server the operator controls. It is not a substitute for `hapi-runner` running on Android via Termux. +A companion implementing this contract is a **native client to the same hub the PWA talks to**, surfacing notifications and reply / approve actions on a phone or wearable. Hub topology is unchanged - the hub still runs on the operator's dev machine. --- From f389bd01bc6c0239168be549c3e433a7480cd262 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:05:08 +0100 Subject: [PATCH 10/39] feat(web): companion app pairing QR in Settings Companion section in Settings renders a QR code encoding the deeplink hapicompanion://bind?hub=&code=. Scanning it from the HAPI companion app (Android phone or Wear OS) auto-fills the bind form and authenticates against this hub - no manual URL/token paste. QR is gated behind a Show button so the access token doesn't sit visible on screen by default; a Copy link affordance and the textual deeplink are also exposed for manual onboarding. Adds qrcode + @types/qrcode to web/ (already a hub dep, no new resolved package - just a workspace declaration). Co-authored-by: Cursor --- bun.lock | 2 + web/package.json | 2 + .../components/settings/CompanionPairing.tsx | 114 ++++++++++++++++++ web/src/routes/settings/index.tsx | 13 +- 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 web/src/components/settings/CompanionPairing.tsx diff --git a/bun.lock b/bun.lock index 7a40e43908..25a44e9a35 100644 --- a/bun.lock +++ b/bun.lock @@ -116,6 +116,7 @@ "hast-util-to-jsx-runtime": "^2.3.6", "katex": "^0.16.45", "mermaid": "^11.12.0", + "qrcode": "^1.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", "rehype-katex": "^7.0.1", @@ -135,6 +136,7 @@ "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", diff --git a/web/package.json b/web/package.json index ba28bffca1..bbf2063192 100644 --- a/web/package.json +++ b/web/package.json @@ -37,6 +37,7 @@ "mermaid": "^11.12.0", "react": "^19.2.3", "react-dom": "^19.2.3", + "qrcode": "^1.5.4", "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", @@ -55,6 +56,7 @@ "@testing-library/react": "^16.3.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/qrcode": "^1.5.6", "@tailwindcss/postcss": "^4.1.18", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.23", diff --git a/web/src/components/settings/CompanionPairing.tsx b/web/src/components/settings/CompanionPairing.tsx new file mode 100644 index 0000000000..2b0dca9295 --- /dev/null +++ b/web/src/components/settings/CompanionPairing.tsx @@ -0,0 +1,114 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import QRCode from 'qrcode' +import { Button } from '@/components/ui/button' + +type CompanionPairingProps = { + baseUrl: string + accessToken: string +} + +const COMPANION_DEEPLINK_SCHEME = 'hapicompanion://bind' + +function buildDeeplink(hub: string, code: string): string { + const params = new URLSearchParams({ hub, code }) + return `${COMPANION_DEEPLINK_SCHEME}?${params.toString()}` +} + +export function CompanionPairing({ baseUrl, accessToken }: CompanionPairingProps) { + const [revealed, setRevealed] = useState(false) + const [copied, setCopied] = useState(false) + const canvasRef = useRef(null) + + const deeplink = useMemo(() => { + const hub = (baseUrl || '').trim() + const code = (accessToken || '').trim() + if (!hub || !code) return '' + return buildDeeplink(hub, code) + }, [baseUrl, accessToken]) + + useEffect(() => { + if (!revealed || !deeplink || !canvasRef.current) return + let cancelled = false + QRCode.toCanvas(canvasRef.current, deeplink, { + errorCorrectionLevel: 'M', + margin: 2, + scale: 6, + color: { + dark: '#000000', + light: '#ffffff' + } + }).catch(() => { + // QR rendering failures are non-fatal; the textual link below still works. + }) + return () => { + cancelled = true + void cancelled + } + }, [deeplink, revealed]) + + const handleCopy = async () => { + if (!deeplink) return + try { + await navigator.clipboard.writeText(deeplink) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Clipboard may be unavailable (e.g. insecure context); user can long-press the link instead. + } + } + + if (!deeplink) { + return ( +

+ Pairing requires an active session. Sign in first, then return here. +

+ ) + } + + return ( +
+

+ Scan from the HAPI companion app on Android (phone or Wear OS) to bind it to this hub. + The pairing code is your access token - treat it like a password. +

+ + {!revealed ? ( + + ) : ( +
+ +
+ + +
+

+ {deeplink} +

+
+ )} +
+ ) +} diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 256033628c..d4c7f7cdc4 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -4,6 +4,7 @@ import { useAppGoBack } from '@/hooks/useAppGoBack' import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages' import { VOICES, getFallbackVoices } from '@/lib/voices' import { useAppContext } from '@/lib/app-context' +import { CompanionPairing } from '@/components/settings/CompanionPairing' import { fetchVoiceBackend, fetchVoices, type VoiceInfo } from '@/api/voice' import { getStaticVoiceOptions, @@ -371,7 +372,7 @@ function ThemeColorControl(props: { t: (key: string) => string }) { export default function SettingsPage() { const { t, locale, setLocale } = useTranslation() - const { api } = useAppContext() + const { api, token, baseUrl } = useAppContext() const goBack = useAppGoBack() const [isOpen, setIsOpen] = useState(false) const [isAppearanceOpen, setIsAppearanceOpen] = useState(false) @@ -1257,6 +1258,16 @@ export default function SettingsPage() { + {/* Companion section */} +
+
+ Companion +
+
+ +
+
+ {/* About section */}
From 6866b97c4a29e84de9e57821e1ceee76fe75f96a Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:05:08 +0100 Subject: [PATCH 11/39] feat(hub): terminal QR for companion app pairing alongside PWA QR After the existing PWA access QR is rendered on tunnel start, also print the hapicompanion://bind?hub=...&code=... deeplink and a matching QR. Same tunnel + token, different scheme: phones with the companion app installed pick up the deeplink via the manifest intent filter; phones without it ignore it and fall back to the PWA QR above. QR rendering failure is non-fatal in both cases - the textual deeplink above the QR is sufficient for manual paste. Co-authored-by: Cursor --- hub/src/startHub.ts | 22 +++++++++++++++++++ .../components/settings/CompanionPairing.tsx | 19 +++++++++++++--- web/src/routes/settings/index.tsx | 4 ++-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index c97f125ffb..2499e6e6b4 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -323,6 +323,28 @@ export async function startHub(options: StartHubOptions = {}): Promise(null) + const accessToken = useMemo(() => readAccessToken(baseUrl), [baseUrl]) + const deeplink = useMemo(() => { const hub = (baseUrl || '').trim() const code = (accessToken || '').trim() @@ -60,7 +71,9 @@ export function CompanionPairing({ baseUrl, accessToken }: CompanionPairingProps if (!deeplink) { return (

- Pairing requires an active session. Sign in first, then return here. + Pairing requires the original access token (CLI_API_TOKEN). It looks like + you signed in via Telegram or another flow that did not store one - paste + the token manually in the companion app instead.

) } diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index d4c7f7cdc4..42e9c7491d 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -372,7 +372,7 @@ function ThemeColorControl(props: { t: (key: string) => string }) { export default function SettingsPage() { const { t, locale, setLocale } = useTranslation() - const { api, token, baseUrl } = useAppContext() + const { api, baseUrl } = useAppContext() const goBack = useAppGoBack() const [isOpen, setIsOpen] = useState(false) const [isAppearanceOpen, setIsAppearanceOpen] = useState(false) @@ -1264,7 +1264,7 @@ export default function SettingsPage() { Companion
- +
From a2a0a30f9f51b83920be68ea3d83db1138c55b17 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:06:10 +0100 Subject: [PATCH 12/39] fix(fcm): address HAPI Bot review on PR #803 Two bugs surfaced by the upstream review bot: 1) Web Push silently dropped when FCM is not actually configured. The native-fallback probe only checked the device registry; it did not check whether resolveFcmConfig() actually succeeded. So an operator who previously enabled FCM, registered a phone, then later started the hub WITHOUT FCM_SERVICE_ACCOUNT_PATH would see the probe return true (devices still in DB) -> Web Push suppressed -> no FCM channel registered -> notifications go to /dev/null. Fix: extracted the probe construction into buildNativeFallbackProbe() which short-circuits to () => false when fcmConfig is missing. Probe never even consults the device store in the no-config branch, so stale rows can never matter. 2) Transient FCM failures permanently unregistered devices. sendToToken() returned a single boolean and sendToNamespace() removed any device whose send returned false. A 429 (rate limit), 503 (server error), 401 (auth glitch), or even an ECONNREFUSED would delete the device row, after which the user would need to re-pair to get notifications again. The bot caught it; the fix is the obvious one. Fix: sendToToken() now returns 'sent' | 'invalid' | 'failed'. - 'invalid' is reserved for the responses that genuinely indicate a dead token: HTTP 404 with UNREGISTERED/NOT_FOUND, and HTTP 400 with INVALID_ARGUMENT explicitly referencing the token field. - Everything else (429, 5xx, 401, 403, network errors) is 'failed' and counts toward the failed tally without removing the device. sendToNamespace() only calls removeDeviceByToken() on 'invalid'. Tests: 11 new tests across two new files. fcmService.test.ts covers all six branches (200, 404 unregistered, 429, 503, 401, network error) plus a mixed-batch case that proves invalid tokens get removed in the same call where transient-failure tokens survive. nativeFallbackProbe .test.ts covers both no-config and configured branches plus the explicit "no-config never touches the store" guarantee. Hub test count: 273 -> 284 (all passing). Co-authored-by: Cursor --- hub/src/fcm/fcmService.test.ts | 201 ++++++++++++++++++++++++ hub/src/fcm/fcmService.ts | 69 +++++--- hub/src/fcm/nativeFallbackProbe.test.ts | 58 +++++++ hub/src/fcm/nativeFallbackProbe.ts | 25 +++ hub/src/startHub.ts | 18 ++- 5 files changed, 344 insertions(+), 27 deletions(-) create mode 100644 hub/src/fcm/fcmService.test.ts create mode 100644 hub/src/fcm/nativeFallbackProbe.test.ts create mode 100644 hub/src/fcm/nativeFallbackProbe.ts diff --git a/hub/src/fcm/fcmService.test.ts b/hub/src/fcm/fcmService.test.ts new file mode 100644 index 0000000000..bccd545450 --- /dev/null +++ b/hub/src/fcm/fcmService.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { FcmService, type FcmSendPayload } from './fcmService' + +mock.module('./fcmAuth', () => ({ + getFcmAccessToken: async () => 'test-access-token', + loadServiceAccount: () => ({ client_email: 'x', private_key: 'y' }) +})) + +type FakeStore = { + fcm: { + getDevicesByNamespace: ReturnType + removeDeviceByToken: ReturnType + } +} + +function makeStore(devices: Array<{ token: string; platform: 'phone' | 'wear'; deviceId: string; namespace: string }>): FakeStore { + return { + fcm: { + getDevicesByNamespace: mock((ns: string) => + devices + .filter(d => d.namespace === ns) + .map(d => ({ + id: 0, + namespace: d.namespace, + token: d.token, + platform: d.platform, + deviceId: d.deviceId, + createdAt: 0, + updatedAt: 0 + })) + ), + removeDeviceByToken: mock(() => {}) + } + } +} + +function makePayload(overrides: Partial = {}): FcmSendPayload { + return { + title: 'T', + body: 'B', + data: { + type: 'ready', + sessionId: 'sess-1', + sessionName: 'Demo', + url: 'https://hapi.example.com/sessions/sess-1', + title: 'T', + body: 'B', + contractVersion: '1', + ...overrides + } + } +} + +describe('FcmService.sendToNamespace', () => { + let originalFetch: typeof globalThis.fetch + beforeEach(() => { + originalFetch = globalThis.fetch + }) + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('removes the device row when FCM returns 404 UNREGISTERED (token rotated)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'rotated-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"error":{"status":"UNREGISTERED"}}', { status: 404 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.sent).toBe(0) + expect(result.failed).toBe(1) + expect(result.invalidTokens).toEqual(['rotated-token']) + expect(store.fcm.removeDeviceByToken).toHaveBeenCalledWith('default', 'rotated-token') + }) + + it('keeps the device row on transient 429 (rate limit) - regression for HAPI Bot finding', async () => { + const store = makeStore([ + { namespace: 'default', token: 'rate-limited-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"error":{"status":"RESOURCE_EXHAUSTED"}}', { status: 429 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.sent).toBe(0) + expect(result.failed).toBe(1) + expect(result.invalidTokens).toEqual([]) + // Critical: must NOT remove the device on a transient failure. + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('keeps the device row on transient 503 (server error)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'wear', deviceId: 'w1' } + ]) + globalThis.fetch = mock(async () => + new Response('Service Unavailable', { status: 503 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(result.invalidTokens).toEqual([]) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('keeps the device row on 401 auth glitch (our problem, not the device\'s)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"error":{"status":"UNAUTHENTICATED"}}', { status: 401 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('keeps the device row when fetch itself throws (network error)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('counts a 200 response as sent', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"name":"projects/proj-id/messages/0:1234567890"}', { status: 200 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.sent).toBe(1) + expect(result.failed).toBe(0) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('mixed batch: removes invalid token, keeps device with transient failure, counts good send', async () => { + const store = makeStore([ + { namespace: 'default', token: 'good-token', platform: 'phone', deviceId: 'p1' }, + { namespace: 'default', token: 'rotated-token', platform: 'phone', deviceId: 'p2' }, + { namespace: 'default', token: 'rate-limited-token', platform: 'wear', deviceId: 'w1' } + ]) + + const responseFor: Record Response> = { + 'good-token': () => new Response('{"name":"ok"}', { status: 200 }), + 'rotated-token': () => new Response('{"error":{"status":"UNREGISTERED"}}', { status: 404 }), + 'rate-limited-token': () => new Response('{"error":{"status":"RESOURCE_EXHAUSTED"}}', { status: 429 }) + } + globalThis.fetch = mock(async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse((init?.body as string) ?? '{}') as { message?: { token?: string } } + const token = body.message?.token ?? '' + const fn = responseFor[token] + return fn ? fn() : new Response('unknown', { status: 500 }) + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.sent).toBe(1) + expect(result.failed).toBe(2) + expect(result.invalidTokens).toEqual(['rotated-token']) + // Only the truly-rotated token gets unregistered. The rate-limited + // device must survive to be retried on the next notification. + expect(store.fcm.removeDeviceByToken).toHaveBeenCalledTimes(1) + expect(store.fcm.removeDeviceByToken).toHaveBeenCalledWith('default', 'rotated-token') + }) + + it('returns zero counts when namespace has no devices', async () => { + const store = makeStore([]) + globalThis.fetch = mock(async () => new Response('should-not-be-called', { status: 200 })) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('empty-ns', makePayload()) + + expect(result).toEqual({ sent: 0, failed: 0, invalidTokens: [] }) + expect(globalThis.fetch).not.toHaveBeenCalled() + }) +}) diff --git a/hub/src/fcm/fcmService.ts b/hub/src/fcm/fcmService.ts index ebdcd38b00..99b6f4c9bb 100644 --- a/hub/src/fcm/fcmService.ts +++ b/hub/src/fcm/fcmService.ts @@ -44,6 +44,16 @@ type FcmSendResult = { invalidTokens: string[] } +/** + * Outcome of a single FCM send. We split `failed` into: + * - `invalid`: token is dead and will never succeed (uninstall, rotation, + * malformed). Safe to remove from the device registry. + * - `failed`: transient or out-of-band error (rate limit, 5xx, auth + * glitch). MUST NOT be treated as token death - we'd silently + * unregister live devices on every Google blip. + */ +type FcmTokenSendResult = 'sent' | 'invalid' | 'failed' + export class FcmService { constructor( private readonly projectId: string, @@ -63,14 +73,16 @@ export class FcmService { let failed = 0 await Promise.all(devices.map(async (device) => { - const ok = await this.sendToToken(accessToken, device.token, payload, device.platform) - if (ok) { + const result = await this.sendToToken(accessToken, device.token, payload, device.platform) + if (result === 'sent') { sent += 1 return } failed += 1 - invalidTokens.push(device.token) - this.store.fcm.removeDeviceByToken(namespace, device.token) + if (result === 'invalid') { + invalidTokens.push(device.token) + this.store.fcm.removeDeviceByToken(namespace, device.token) + } })) return { sent, failed, invalidTokens } @@ -81,7 +93,7 @@ export class FcmService { token: string, payload: FcmSendPayload, platform: 'phone' | 'wear' - ): Promise { + ): Promise { const url = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send` const dataRecord: Record = { type: payload.data.type, @@ -119,26 +131,45 @@ export class FcmService { } } - const response = await fetch(url, { - method: 'POST', - headers: { - authorization: `Bearer ${accessToken}`, - 'content-type': 'application/json' - }, - body: JSON.stringify({ message }) - }) + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ message }) + }) + } catch (e) { + // Network error (DNS, TCP, TLS) - transient, never a token-death signal. + console.error('[FcmService] Send threw:', e instanceof Error ? e.message : e) + return 'failed' + } if (response.ok) { - return true + return 'sent' } const body = await response.text().catch(() => '') - const invalid = response.status === 404 - || body.includes('UNREGISTERED') - || body.includes('NOT_FOUND') + // Per FCM v1 docs, only these signal a permanently-dead token: + // - HTTP 404 with UNREGISTERED (app uninstalled / token rotated) + // - HTTP 404 with NOT_FOUND (legacy alias) + // - HTTP 400 with INVALID_ARGUMENT against the `token` field + // (malformed; we conservatively only treat this as invalid when the + // body explicitly references the token field, otherwise we may be + // sending a malformed payload and would mis-blame the device). + // Everything else - 401 auth, 403 permission, 429 quota, 5xx server - + // is transient and devices stay registered. + const isUnregistered = response.status === 404 + && (body.includes('UNREGISTERED') || body.includes('NOT_FOUND')) + const isMalformedToken = response.status === 400 + && body.includes('INVALID_ARGUMENT') + && /token/i.test(body) + const invalid = isUnregistered || isMalformedToken if (!invalid) { - console.error('[FcmService] Send failed:', response.status, body.slice(0, 200)) + console.error('[FcmService] Send failed (transient):', response.status, body.slice(0, 200)) } - return false + return invalid ? 'invalid' : 'failed' } } diff --git a/hub/src/fcm/nativeFallbackProbe.test.ts b/hub/src/fcm/nativeFallbackProbe.test.ts new file mode 100644 index 0000000000..f26c3c2914 --- /dev/null +++ b/hub/src/fcm/nativeFallbackProbe.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, mock } from 'bun:test' +import type { Store } from '../store' +import { buildNativeFallbackProbe } from './nativeFallbackProbe' + +type FakeStore = { + fcm: { getDevicesByNamespace: ReturnType } +} + +function makeStore(perNs: Record): FakeStore { + return { + fcm: { + getDevicesByNamespace: mock((ns: string) => + Array.from({ length: perNs[ns] ?? 0 }, (_, i) => ({ + id: i, + namespace: ns, + token: `tok-${i}`, + platform: 'phone' as const, + deviceId: `dev-${i}`, + createdAt: 0, + updatedAt: 0 + })) + ) + } + } +} + +describe('buildNativeFallbackProbe', () => { + it('returns false for every namespace when FCM is not configured (regression for HAPI Bot finding)', () => { + const store = makeStore({ default: 5, alt: 1 }) + const probe = buildNativeFallbackProbe(store as unknown as Store, null) + + expect(probe('default')).toBe(false) + expect(probe('alt')).toBe(false) + expect(probe('nonexistent')).toBe(false) + }) + + it('does not query the device store when FCM is not configured', () => { + const store = makeStore({ default: 5 }) + const probe = buildNativeFallbackProbe(store as unknown as Store, null) + + probe('default') + + // If we hit the store, stale device rows would silently suppress + // web-push for a hub running without FCM. The contract is that + // the no-config branch never even consults the store. + expect(store.fcm.getDevicesByNamespace).not.toHaveBeenCalled() + }) + + it('returns true only for namespaces with at least one registered device when FCM is configured', () => { + const store = makeStore({ default: 2, empty: 0 }) + const fcmConfig = { projectId: 'p', serviceAccount: { client_email: 'x', private_key: 'y' } } + const probe = buildNativeFallbackProbe(store as unknown as Store, fcmConfig) + + expect(probe('default')).toBe(true) + expect(probe('empty')).toBe(false) + expect(probe('untouched')).toBe(false) + }) +}) diff --git a/hub/src/fcm/nativeFallbackProbe.ts b/hub/src/fcm/nativeFallbackProbe.ts new file mode 100644 index 0000000000..974f578b70 --- /dev/null +++ b/hub/src/fcm/nativeFallbackProbe.ts @@ -0,0 +1,25 @@ +import type { Store } from '../store' + +/** + * Build a per-namespace probe used by the web-push channel to decide whether + * to defer to the native FCM channel. The probe MUST only return true when + * BOTH conditions hold: + * + * 1. FCM is actually configured on this hub start (fcmConfig is truthy), + * so a registered FCM channel exists to deliver the notification. + * 2. At least one device row is registered for the namespace. + * + * Failing either condition means web-push must fire normally - otherwise + * notifications go to /dev/null when, for example, an operator removes + * `FCM_SERVICE_ACCOUNT_PATH` from the env without clearing stored device + * registrations from the database. + */ +export function buildNativeFallbackProbe( + store: Store, + fcmConfig: unknown +): (namespace: string) => boolean { + if (!fcmConfig) { + return () => false + } + return (namespace: string) => store.fcm.getDevicesByNamespace(namespace).length > 0 +} diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 2499e6e6b4..f3054c6ce3 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -14,6 +14,7 @@ import { PushNotificationChannel } from './push/pushNotificationChannel' import { FcmService } from './fcm/fcmService' import { FcmNotificationChannel } from './fcm/fcmNotificationChannel' import { resolveFcmConfig } from './fcm/fcmConfig' +import { buildNativeFallbackProbe } from './fcm/nativeFallbackProbe' import { VisibilityTracker } from './visibility/visibilityTracker' import { TunnelManager } from './tunnel' import { waitForTunnelTlsReady } from './tunnel/tlsGate' @@ -199,13 +200,15 @@ export async function startHub(options: StartHubOptions = {}): Promise - store.fcm.getDevicesByNamespace(namespace).length > 0 + const fcmConfig = resolveFcmConfig() + + // Only suppress web-push when a native FCM channel is actually live + // AND a device is registered for the namespace. The fcmConfig gate is + // critical: without it, stale device rows from a prior FCM-enabled + // boot would silently drop web-push on a hub started without the + // service-account env var (notifications would vanish entirely). + // See `buildNativeFallbackProbe` for the contract. + const nativeFallbackProbe = buildNativeFallbackProbe(store, fcmConfig) const notificationChannels: NotificationChannel[] = [ new PushNotificationChannel( @@ -217,7 +220,6 @@ export async function startHub(options: StartHubOptions = {}): Promise Date: Fri, 5 Jun 2026 01:38:26 +0100 Subject: [PATCH 13/39] docs(contract): correct FCM visibility rule and remove unsupported event type HAPI Bot review on PR #803 caught two contract-doc accuracy gaps: 1) Visibility rule was wrong. Doc said "FCM fires when Web Push would fire AND client not visible via SSE", but FcmNotificationChannel ALWAYS fires regardless of PWA visibility (deliberately - native companion is the canonical wrist-first surface, and there is a passing test asserting this). Companion app implementers reading the contract would have built foreground-suppression logic and then dropped notifications when the PWA tab was open. 2) Documented `session-completed` event doesn't exist. NotificationHub never calls into a 'session-completed' channel method on FcmNotificationChannel; the type would never reach a native client. Removed from the documented enum, leaving only the three actual events: ready, permission-request, task-notification. Co-authored-by: Cursor --- docs/api/native-companion-contract.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/api/native-companion-contract.md b/docs/api/native-companion-contract.md index 279dabf54b..8f4485efe5 100644 --- a/docs/api/native-companion-contract.md +++ b/docs/api/native-companion-contract.md @@ -43,13 +43,19 @@ Upsert on `(namespace, deviceId, platform)` - same device re-registering replace ## Outbound push (hub → device) -Hub sends FCM HTTP v1 when Web Push would fire, if FCM is configured and client not visible via SSE (same rule as `PushNotificationChannel`). +Hub sends FCM HTTP v1 whenever a notification event is emitted for a +namespace with registered native devices and FCM is configured. The native +companion is treated as the canonical wrist-first surface, so FCM fires +**unconditionally** (independent of whether a PWA tab happens to be +foreground / visible via SSE) - that's deliberate, see +`FcmNotificationChannel.deliver()`. Web Push is suppressed for the same +namespace to avoid duplicate OS notifications. ### Data payload (all platforms) | Key | Example | Purpose | |-----|---------|---------| -| `type` | `ready` | `ready`, `permission-request`, `task-notification`, `session-completed` | +| `type` | `ready` | `ready`, `permission-request`, `task-notification` | | `sessionId` | uuid | Target session | | `sessionName` | string | Display name (`agent - project`) | | `url` | `/sessions/{id}` | Deep link path | From 16bafb9d8b6ce8b1dcaa45164a131996a06f21ca Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:03:06 +0100 Subject: [PATCH 14/39] docs(contract): drop trailing whitespace, use blank line for paragraph break Co-authored-by: Cursor --- docs/api/native-companion-contract.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/api/native-companion-contract.md b/docs/api/native-companion-contract.md index 8f4485efe5..589a50dd9e 100644 --- a/docs/api/native-companion-contract.md +++ b/docs/api/native-companion-contract.md @@ -1,6 +1,7 @@ # Native companion API contract (phone + Wear) -**Audience:** Implementers of native companion apps (Android phone + Wear OS, iOS, etc.) that pair with a hapi hub via FCM. +**Audience:** Implementers of native companion apps (Android phone + Wear OS, iOS, etc.) that pair with a hapi hub via FCM. + **Auth:** Same JWT as the web client (`POST /api/bind` → `Authorization: Bearer`). ## Scope From 6e3f66a89e0a8b13ce5cdb4ba6a351e708683e2e Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:10:51 +0100 Subject: [PATCH 15/39] fix(web): persist CLI access token after Telegram bind so pairing QR works The Settings -> Companion pairing QR reads the original CLI access token from localStorage (hapi_access_token::) so it can be encoded into the hapicompanion://bind deeplink. For browser/CLI logins useAuthSource already persists the token via setAccessToken, but the Telegram Mini App bind path went through useAuth.bind() which exchanged the typed CLI token for a JWT and never persisted it. Telegram users therefore always saw the "signed in via Telegram..." fallback and got no usable QR. After a successful client.bind() we now mirror useAuthSource's behavior and write the same accessToken to the same localStorage key, restoring parity between the two auth paths. No change for browser/CLI users. Co-authored-by: Cursor --- web/src/hooks/useAuth.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index 8a6c38b2b1..48f04fa14a 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -37,6 +37,16 @@ function isNotBoundError(error: unknown): boolean { return error instanceof ApiError && error.status === 401 && error.code === 'not_bound' } +const ACCESS_TOKEN_PREFIX = 'hapi_access_token::' + +function rememberAccessToken(baseUrl: string, accessToken: string): void { + try { + localStorage.setItem(`${ACCESS_TOKEN_PREFIX}${baseUrl}`, accessToken) + } catch { + // Ignore storage errors (private mode, full quota, etc.) + } +} + export function useAuth(authSource: AuthSource | null, baseUrl: string): { token: string | null user: AuthResponse['user'] | null @@ -149,6 +159,10 @@ export function useAuth(authSource: AuthSource | null, baseUrl: string): { setToken(auth.token) setUser(auth.user) setNeedsBinding(false) + // Persist the CLI access token so Settings → Companion pairing QR + // can encode the same long-lived token in the deeplink. The PWA + // already does this for browser/CLI logins via useAuthSource. + rememberAccessToken(baseUrl, accessToken) } catch (error) { setError(error instanceof Error ? error.message : 'Binding failed') throw error From 5d444cccb63981030a80f526b891160eb7ce5d7c Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:23:41 +0100 Subject: [PATCH 16/39] fix(fcm): gate native-fallback probe on rolling FCM health The native-fallback probe previously returned true whenever FCM was configured AND devices were registered, which suppressed web-push for the namespace. The HAPI Bot correctly pointed out the gap: if the FCM pipeline silently breaks (expired service-account key, sustained 5xx, OAuth token-fetch failure, network blackhole) the operator gets nothing on either channel until they manually intervene. Approach (deliberate, not the bot's exact suggested fix): - FcmService now keeps a small rolling window (last 8 outcomes) of send attempts and exposes `isHealthy()`. The threshold is 5+/8 failures = unhealthy; the buffer starts empty so a freshly-booted hub is optimistic ("innocent until proven guilty") and does not double-fire on event #1. - Token-fetch failure (`getFcmAccessToken` throws) now records exactly one health-failure (not one per device), short-circuits the send loop, and returns a result so `sendToNamespace` no longer leaks the exception. - `invalid` token responses are explicitly excluded from the health buffer because they are per-device facts (rotated/uninstalled token), not pipeline failures - FCM was reachable, it just rejected one stale token. - `buildNativeFallbackProbe` now optionally accepts the FcmService and short-circuits to "let web-push fire" when health is bad, before it even queries the device registry. The single-arg call shape is still supported for back-compat. Why not the bot's exact suggestion ("invert: call FCM first, fall back on result.sent === 0"): - Couples PushNotificationChannel to FcmService and FcmSendPayload, reversing the clean parallel-channel architecture established earlier in this PR. - Treats every transient single-event failure as fallback-worthy, which re-opens the duplicate-notification race that the suppression logic was added to close (FCM HTTP timeout that delivers later + the web push we sent in the meantime = two pings). - A rolling health window only flips on sustained breakage, which is the actual operational scenario the bot is worried about. The wrist-first design intent ("FCM fires unconditionally, web-push is suppressed for the same namespace") documented in docs/api/native-companion-contract.md is preserved on the happy path. The probe only re-enables web-push when there is concrete evidence the native pipeline is not delivering. Tests: - New FcmService.isHealthy suite covers empty-buffer, threshold flip, recovery as failures age out of the window, invalid-token exclusion, and network-error path. - nativeFallbackProbe gains coverage for the unhealthy-but-registered, healthy-and-registered, and absent-fcmService (back-compat) cases. - All 292 hub tests still pass; typecheck clean. Co-authored-by: Cursor --- hub/src/fcm/fcmService.test.ts | 99 +++++++++++++++++++++++++ hub/src/fcm/fcmService.ts | 54 +++++++++++++- hub/src/fcm/nativeFallbackProbe.test.ts | 32 ++++++++ hub/src/fcm/nativeFallbackProbe.ts | 28 +++++-- hub/src/startHub.ts | 30 ++++++-- 5 files changed, 226 insertions(+), 17 deletions(-) diff --git a/hub/src/fcm/fcmService.test.ts b/hub/src/fcm/fcmService.test.ts index bccd545450..870d31b95b 100644 --- a/hub/src/fcm/fcmService.test.ts +++ b/hub/src/fcm/fcmService.test.ts @@ -199,3 +199,102 @@ describe('FcmService.sendToNamespace', () => { expect(globalThis.fetch).not.toHaveBeenCalled() }) }) + +describe('FcmService.isHealthy (rolling outcome window)', () => { + let originalFetch: typeof globalThis.fetch + beforeEach(() => { + originalFetch = globalThis.fetch + }) + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('starts healthy with an empty outcome buffer (innocent until proven guilty)', () => { + const store = makeStore([]) + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + expect(svc.isHealthy()).toBe(true) + }) + + it('flips to unhealthy after 5 consecutive transient failures', async () => { + const store = makeStore([ + { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('Service Unavailable', { status: 503 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + + // 4 failures: still healthy (threshold is 5) + for (let i = 0; i < 4; i += 1) { + await svc.sendToNamespace('default', makePayload()) + } + expect(svc.isHealthy()).toBe(true) + + // 5th failure crosses the threshold + await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(false) + }) + + it('recovers to healthy as recent successes age out the failure tail', async () => { + const store = makeStore([ + { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } + ]) + let callCount = 0 + globalThis.fetch = mock(async () => { + callCount += 1 + // First 5 calls fail (503), rest succeed + if (callCount <= 5) { + return new Response('Service Unavailable', { status: 503 }) + } + return new Response('{"name":"ok"}', { status: 200 }) + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + + for (let i = 0; i < 5; i += 1) { + await svc.sendToNamespace('default', makePayload()) + } + expect(svc.isHealthy()).toBe(false) + + // 4 successes after 5 failures: window is [F,F,F,F,F,S,S,S,S] -> trim + // to last 8: [F,F,F,F,S,S,S,S] -> 4 failures, threshold 5 -> healthy. + for (let i = 0; i < 4; i += 1) { + await svc.sendToNamespace('default', makePayload()) + } + expect(svc.isHealthy()).toBe(true) + }) + + it('does NOT count invalid-token responses against health (per-device fact, not pipeline failure)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'rotated', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"error":{"status":"UNREGISTERED"}}', { status: 404 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + + // 10 invalid-token responses - the pipeline is fine, just the + // device is dead. Health must not flip. + for (let i = 0; i < 10; i += 1) { + await svc.sendToNamespace('default', makePayload()) + } + expect(svc.isHealthy()).toBe(true) + }) + + it('counts fetch-throw (network error) as a health failure', async () => { + const store = makeStore([ + { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + for (let i = 0; i < 5; i += 1) { + await svc.sendToNamespace('default', makePayload()) + } + expect(svc.isHealthy()).toBe(false) + }) +}) diff --git a/hub/src/fcm/fcmService.ts b/hub/src/fcm/fcmService.ts index 99b6f4c9bb..da969e0ad3 100644 --- a/hub/src/fcm/fcmService.ts +++ b/hub/src/fcm/fcmService.ts @@ -55,25 +55,77 @@ type FcmSendResult = { type FcmTokenSendResult = 'sent' | 'invalid' | 'failed' export class FcmService { + /** + * Rolling window of the last N send outcomes. Drives `isHealthy()`, + * which the native-fallback probe consults to decide whether suppressing + * web-push for this namespace is still safe. We deliberately do NOT + * count `invalid` here - an invalid token is a per-device fact, not an + * FCM-pipeline-broken signal (FCM was reachable, it just rejected one + * stale token). Only `sent` and `failed` populate the buffer. + */ + private recentOutcomes: Array<'sent' | 'failed'> = [] + private static readonly HEALTH_WINDOW = 8 + private static readonly HEALTH_FAILURE_THRESHOLD = 5 + constructor( private readonly projectId: string, private readonly serviceAccount: ServiceAccount, private readonly store: Store ) {} + /** + * Health gate for the native-fallback probe. Returns false when the + * recent-outcome window is dominated by failures (broken Firebase + * credentials, sustained 5xx, network blackhole). When unhealthy, the + * probe lets web-push fire as a last-resort surface for this namespace. + * + * Empty buffer is treated as healthy (innocent until proven guilty) so + * a freshly-started hub does not silently double-fire on event #1. + */ + isHealthy(): boolean { + if (this.recentOutcomes.length === 0) return true + const failures = this.recentOutcomes.filter((o) => o === 'failed').length + return failures < FcmService.HEALTH_FAILURE_THRESHOLD + } + + private recordOutcome(outcome: 'sent' | 'failed'): void { + this.recentOutcomes.push(outcome) + if (this.recentOutcomes.length > FcmService.HEALTH_WINDOW) { + this.recentOutcomes.shift() + } + } + async sendToNamespace(namespace: string, payload: FcmSendPayload): Promise { const devices = this.store.fcm.getDevicesByNamespace(namespace) if (devices.length === 0) { return { sent: 0, failed: 0, invalidTokens: [] } } - const accessToken = await getFcmAccessToken(this.serviceAccount) + let accessToken: string + try { + accessToken = await getFcmAccessToken(this.serviceAccount) + } catch (e) { + // Token-fetch failure (expired service account key, OAuth + // outage, network) - count one health-failure (not one per + // device, that would over-weight the buffer) and return. + console.error('[FcmService] Token fetch failed:', e instanceof Error ? e.message : e) + this.recordOutcome('failed') + return { sent: 0, failed: devices.length, invalidTokens: [] } + } + const invalidTokens: string[] = [] let sent = 0 let failed = 0 await Promise.all(devices.map(async (device) => { const result = await this.sendToToken(accessToken, device.token, payload, device.platform) + // `invalid` is a per-device fact, not a pipeline signal - + // exclude it from the health buffer (see field doc above). + if (result === 'sent') { + this.recordOutcome('sent') + } else if (result === 'failed') { + this.recordOutcome('failed') + } if (result === 'sent') { sent += 1 return diff --git a/hub/src/fcm/nativeFallbackProbe.test.ts b/hub/src/fcm/nativeFallbackProbe.test.ts index f26c3c2914..0fce9cfcad 100644 --- a/hub/src/fcm/nativeFallbackProbe.test.ts +++ b/hub/src/fcm/nativeFallbackProbe.test.ts @@ -55,4 +55,36 @@ describe('buildNativeFallbackProbe', () => { expect(probe('empty')).toBe(false) expect(probe('untouched')).toBe(false) }) + + it('returns false when fcmService reports unhealthy, even with registered devices', () => { + const store = makeStore({ default: 3 }) + const fcmConfig = { projectId: 'p', serviceAccount: { client_email: 'x', private_key: 'y' } } + const fcmService = { isHealthy: mock(() => false) } + const probe = buildNativeFallbackProbe(store as unknown as Store, fcmConfig, fcmService) + + expect(probe('default')).toBe(false) + expect(fcmService.isHealthy).toHaveBeenCalled() + // We short-circuit before hitting the device store - the namespace + // has registered devices but a broken FCM pipeline means web-push + // is the only surface still able to reach the operator. + expect(store.fcm.getDevicesByNamespace).not.toHaveBeenCalled() + }) + + it('returns true when fcmService reports healthy and devices exist', () => { + const store = makeStore({ default: 1 }) + const fcmConfig = { projectId: 'p', serviceAccount: { client_email: 'x', private_key: 'y' } } + const fcmService = { isHealthy: mock(() => true) } + const probe = buildNativeFallbackProbe(store as unknown as Store, fcmConfig, fcmService) + + expect(probe('default')).toBe(true) + expect(fcmService.isHealthy).toHaveBeenCalled() + }) + + it('treats absent fcmService as healthy (back-compat with single-arg call site)', () => { + const store = makeStore({ default: 2 }) + const fcmConfig = { projectId: 'p', serviceAccount: { client_email: 'x', private_key: 'y' } } + const probe = buildNativeFallbackProbe(store as unknown as Store, fcmConfig) + + expect(probe('default')).toBe(true) + }) }) diff --git a/hub/src/fcm/nativeFallbackProbe.ts b/hub/src/fcm/nativeFallbackProbe.ts index 974f578b70..c490f365f7 100644 --- a/hub/src/fcm/nativeFallbackProbe.ts +++ b/hub/src/fcm/nativeFallbackProbe.ts @@ -1,25 +1,37 @@ import type { Store } from '../store' +import type { FcmService } from './fcmService' /** * Build a per-namespace probe used by the web-push channel to decide whether * to defer to the native FCM channel. The probe MUST only return true when - * BOTH conditions hold: + * ALL of these hold: * * 1. FCM is actually configured on this hub start (fcmConfig is truthy), * so a registered FCM channel exists to deliver the notification. - * 2. At least one device row is registered for the namespace. + * 2. The FCM service is currently healthy (recent sends are not all + * failing). When credentials expire or the FCM pipeline blackholes, + * we let web-push run again so the operator does not get silently + * cut off from all notifications. + * 3. At least one device row is registered for the namespace. * - * Failing either condition means web-push must fire normally - otherwise - * notifications go to /dev/null when, for example, an operator removes - * `FCM_SERVICE_ACCOUNT_PATH` from the env without clearing stored device - * registrations from the database. + * Failing any condition means web-push must fire normally. The default + * "happy path" remains: native is the canonical wrist-first surface and + * web-push is suppressed to avoid duplicate OS notifications - this probe + * only re-enables web-push when we have evidence the native pipeline is + * not actually delivering. */ export function buildNativeFallbackProbe( store: Store, - fcmConfig: unknown + fcmConfig: unknown, + fcmService?: Pick ): (namespace: string) => boolean { if (!fcmConfig) { return () => false } - return (namespace: string) => store.fcm.getDevicesByNamespace(namespace).length > 0 + return (namespace: string) => { + if (fcmService && !fcmService.isHealthy()) { + return false + } + return store.fcm.getDevicesByNamespace(namespace).length > 0 + } } diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index f3054c6ce3..8c699347c5 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -202,13 +202,28 @@ export async function startHub(options: StartHubOptions = {}): Promise Date: Sun, 7 Jun 2026 21:30:14 +0100 Subject: [PATCH 17/39] refactor(telegram): drop duplicate tool-args formatter, use shared module The Telegram session view had its own copy of formatToolArgumentsDetailed identical to the one in hub/src/notifications/toolArgs.ts (already used by the FCM channel). Replace the local copy with an import. Removes ~70 lines of duplication, plus the now-unused MAX_TOOL_ARGS_LENGTH constant and `truncate` import. The shared signature accepts an optional opts arg whose default maxArgLength is 150 - matching the prior constant - so the call site is unchanged. Two benign upgrades come along for the ride from the shared module: ?? instead of || on field fallbacks (no real-world difference; permission arguments never carry empty-string fields), and String(...) wrapping plus a typeof object guard that makes non-string values render gracefully instead of throwing into the catch block. Hub tests: 311 pass / 0 fail. Telegram subset: 5 pass / 0 fail. typecheck green. Cold-reviewed by an out-of-context Claude Opus peer before push. Co-authored-by: Cursor --- hub/src/telegram/sessionView.ts | 78 +-------------------------------- 1 file changed, 2 insertions(+), 76 deletions(-) diff --git a/hub/src/telegram/sessionView.ts b/hub/src/telegram/sessionView.ts index f61d5b6df9..0f64303cf0 100644 --- a/hub/src/telegram/sessionView.ts +++ b/hub/src/telegram/sessionView.ts @@ -8,10 +8,9 @@ import { InlineKeyboard } from 'grammy' import type { Machine, Session } from '../sync/syncEngine' import { ACTIONS } from './callbacks' -import { createCallbackData, truncate, getSessionName } from './renderer' +import { createCallbackData, getSessionName } from './renderer' import { getAgentName } from '../notifications/sessionInfo' - -const MAX_TOOL_ARGS_LENGTH = 150 +import { formatToolArgumentsDetailed } from '../notifications/toolArgs' type NotificationContext = { hasContext: boolean @@ -157,79 +156,6 @@ export function createNotificationKeyboard(session: Session, publicUrl: string): return keyboard } -/** - * Format detailed tool arguments for notification display - */ -function formatToolArgumentsDetailed(tool: string, args: any): string { - if (!args) return '' - - try { - switch (tool) { - case 'Edit': { - const file = args.file_path || args.path || 'unknown' - const oldStr = args.old_string ? truncate(args.old_string, 50) : '' - const newStr = args.new_string ? truncate(args.new_string, 50) : '' - let result = `File: ${truncate(file, MAX_TOOL_ARGS_LENGTH)}` - if (oldStr) result += `\nOld: "${oldStr}"` - if (newStr) result += `\nNew: "${newStr}"` - return result - } - - case 'Write': { - const file = args.file_path || args.path || 'unknown' - const content = args.content ? `${args.content.length} chars` : '' - return `File: ${truncate(file, MAX_TOOL_ARGS_LENGTH)}${content ? ` (${content})` : ''}` - } - - case 'Read': { - const file = args.file_path || args.path || 'unknown' - return `File: ${truncate(file, MAX_TOOL_ARGS_LENGTH)}` - } - - case 'Bash': { - const cmd = args.command || '' - return `Command: ${truncate(cmd, MAX_TOOL_ARGS_LENGTH)}` - } - - case 'Agent': - case 'Task': { - const desc = args.description || args.prompt || '' - return `Task: ${truncate(desc, MAX_TOOL_ARGS_LENGTH)}` - } - - case 'Grep': - case 'Glob': { - const pattern = args.pattern || '' - const path = args.path || '' - let result = `Pattern: ${pattern}` - if (path) result += `\nPath: ${truncate(path, 80)}` - return result - } - - case 'WebFetch': { - const url = args.url || '' - return `URL: ${truncate(url, MAX_TOOL_ARGS_LENGTH)}` - } - - case 'TodoWrite': { - const count = args.todos?.length || 0 - return `Updating ${count} todo items` - } - - default: { - // Generic args display for unknown tools - const argStr = JSON.stringify(args) - if (argStr.length > 10) { - return `Args: ${truncate(argStr, MAX_TOOL_ARGS_LENGTH)}` - } - return '' - } - } - } catch { - return '' - } -} - function buildMiniAppDeepLink(baseUrl: string, startParam: string): string { try { const url = new URL(baseUrl) From 49c1d0a398cc50924b92ff333d3df2800458400b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:46:48 +0100 Subject: [PATCH 18/39] fix(fcm): require positive evidence in health window before suppressing web-push Addresses HAPI Bot Major review on PR #803. The previous health gate treated an empty outcome buffer as healthy ("innocent until proven guilty"). That created a silent-blackhole window on cold start with broken FCM credentials: the push channel suppressed SSE/Web Push for the first ~5 events while the FCM channel attempted each delivery and recorded failures, until enough stacked to flip the threshold. Every notification in that gap was silently lost. New invariant: isHealthy() requires at least one successful FCM send in the recent window (HEALTH_WINDOW=8) AND failures below threshold (HEALTH_FAILURE_THRESHOLD=5). Both conditions are necessary; either alone is insufficient evidence to safely suppress web-push fallback. Trade-off: one duplicated notification per hub restart per namespace. On the first event after restart, web-push fires alongside FCM (because the gate has no positive evidence yet). Once FCM records that first success, the gate engages and subsequent events are FCM-only. Worth it for guaranteed delivery during cold-start outages. Tests reworked to match new semantics: - "starts UNHEALTHY with empty buffer" (was: healthy) - "flips to healthy after first successful send" (new) - "stays unhealthy across failures-only run" (new, exercises the exact blackhole scenario the bot flagged) - "flips back to unhealthy after threshold breach with prior successes" (renamed, establishes successes first) - "invalid tokens don't count against health" (reworked: send a mixed batch first to establish health, then verify invalids don't flip it) - "network errors count as failures" (reworked: establish health first) Hub tests: 313 pass / 0 fail. typecheck green. Co-authored-by: Cursor --- hub/src/fcm/fcmService.test.ts | 91 ++++++++++++++++++++++++++++------ hub/src/fcm/fcmService.ts | 23 ++++++--- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/hub/src/fcm/fcmService.test.ts b/hub/src/fcm/fcmService.test.ts index 870d31b95b..b77e6ed127 100644 --- a/hub/src/fcm/fcmService.test.ts +++ b/hub/src/fcm/fcmService.test.ts @@ -209,13 +209,32 @@ describe('FcmService.isHealthy (rolling outcome window)', () => { globalThis.fetch = originalFetch }) - it('starts healthy with an empty outcome buffer (innocent until proven guilty)', () => { + it('starts UNHEALTHY with an empty outcome buffer (no positive evidence yet)', () => { + // Cold-start invariant (HAPI Bot Major fix on PR #803): the gate + // requires at least one observed success before suppressing + // web-push. Otherwise a hub started with broken FCM credentials + // silently drops the first N notifications while waiting for the + // failure threshold to trip. const store = makeStore([]) const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + expect(svc.isHealthy()).toBe(false) + }) + + it('flips to healthy after the first successful send', async () => { + const store = makeStore([ + { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"name":"ok"}', { status: 200 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + expect(svc.isHealthy()).toBe(false) + await svc.sendToNamespace('default', makePayload()) expect(svc.isHealthy()).toBe(true) }) - it('flips to unhealthy after 5 consecutive transient failures', async () => { + it('stays unhealthy across a run of failures with no successes (broken-FCM cold start)', async () => { const store = makeStore([ { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } ]) @@ -225,13 +244,38 @@ describe('FcmService.isHealthy (rolling outcome window)', () => { const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) - // 4 failures: still healthy (threshold is 5) - for (let i = 0; i < 4; i += 1) { + // Without any prior success the gate must stay unhealthy regardless + // of where we are in the failure-threshold count. This is the exact + // silent-blackhole window the bot flagged. + for (let i = 0; i < 5; i += 1) { await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(false) } + }) + + it('flips back to unhealthy when failures stack past threshold after prior successes', async () => { + const store = makeStore([ + { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } + ]) + let callCount = 0 + globalThis.fetch = mock(async () => { + callCount += 1 + // First 3 succeed, then 503s + if (callCount <= 3) return new Response('{"name":"ok"}', { status: 200 }) + return new Response('Service Unavailable', { status: 503 }) + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + + // 3 successes establish health + for (let i = 0; i < 3; i += 1) await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(true) + + // 4 failures: window is [S,S,S,F,F,F,F] - 4 < 5 -> still healthy + for (let i = 0; i < 4; i += 1) await svc.sendToNamespace('default', makePayload()) expect(svc.isHealthy()).toBe(true) - // 5th failure crosses the threshold + // 5th failure: [S,S,S,F,F,F,F,F] - 5 >= 5 -> unhealthy await svc.sendToNamespace('default', makePayload()) expect(svc.isHealthy()).toBe(false) }) @@ -267,16 +311,27 @@ describe('FcmService.isHealthy (rolling outcome window)', () => { it('does NOT count invalid-token responses against health (per-device fact, not pipeline failure)', async () => { const store = makeStore([ - { namespace: 'default', token: 'rotated', platform: 'phone', deviceId: 'p1' } + { namespace: 'default', token: 'good', platform: 'phone', deviceId: 'p1' }, + { namespace: 'default', token: 'rotated', platform: 'phone', deviceId: 'p2' } ]) - globalThis.fetch = mock(async () => - new Response('{"error":{"status":"UNREGISTERED"}}', { status: 404 }) - ) as unknown as typeof fetch + globalThis.fetch = mock(async (url: unknown, init?: unknown) => { + // Different responses per device token. We use the request + // body to discriminate - both calls go to the same URL. + const body = JSON.parse(((init as { body?: string })?.body) ?? '{}') + const token = body?.message?.token + if (token === 'good') return new Response('{"name":"ok"}', { status: 200 }) + return new Response('{"error":{"status":"UNREGISTERED"}}', { status: 404 }) + }) as unknown as typeof fetch const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) - // 10 invalid-token responses - the pipeline is fine, just the - // device is dead. Health must not flip. + // First send produces 1 sent + 1 invalid. After this the rotated + // token is removed from the store, leaving only the good one. + await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(true) + + // Subsequent successful sends do not record additional outcomes + // for the (now-pruned) invalid token. Health stays true. for (let i = 0; i < 10; i += 1) { await svc.sendToNamespace('default', makePayload()) } @@ -287,14 +342,22 @@ describe('FcmService.isHealthy (rolling outcome window)', () => { const store = makeStore([ { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } ]) + let callCount = 0 globalThis.fetch = mock(async () => { + callCount += 1 + // First few succeed (establish health), rest throw network error + if (callCount <= 3) return new Response('{"name":"ok"}', { status: 200 }) throw new Error('ECONNREFUSED') }) as unknown as typeof fetch const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) - for (let i = 0; i < 5; i += 1) { - await svc.sendToNamespace('default', makePayload()) - } + + // Establish health with 3 successes + for (let i = 0; i < 3; i += 1) await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(true) + + // 5 network errors stack past threshold and flip health + for (let i = 0; i < 5; i += 1) await svc.sendToNamespace('default', makePayload()) expect(svc.isHealthy()).toBe(false) }) }) diff --git a/hub/src/fcm/fcmService.ts b/hub/src/fcm/fcmService.ts index da969e0ad3..9a77ede561 100644 --- a/hub/src/fcm/fcmService.ts +++ b/hub/src/fcm/fcmService.ts @@ -74,16 +74,27 @@ export class FcmService { ) {} /** - * Health gate for the native-fallback probe. Returns false when the - * recent-outcome window is dominated by failures (broken Firebase - * credentials, sustained 5xx, network blackhole). When unhealthy, the + * Health gate for the native-fallback probe. Returns true only when the + * recent-outcome window contains at least one positive datapoint AND + * failures have not stacked past the threshold. When unhealthy, the * probe lets web-push fire as a last-resort surface for this namespace. * - * Empty buffer is treated as healthy (innocent until proven guilty) so - * a freshly-started hub does not silently double-fire on event #1. + * "Needs positive evidence" semantics intentionally: an empty buffer + * (cold-start) and a buffer dominated by failures-only both render + * unhealthy. This closes the silent-blackhole window where a hub with + * broken Firebase credentials would suppress web-push for the first N + * events while waiting for failures to accumulate past the threshold. + * + * Trade-off: one duplicated notification per hub restart per namespace + * (web-push + FCM both fire on event #1; FCM success records `sent` and + * the gate engages from event #2 onward). Worth it for guaranteed + * delivery on cold start. + * + * Addresses HAPI Bot Major review on PR #803. */ isHealthy(): boolean { - if (this.recentOutcomes.length === 0) return true + const successes = this.recentOutcomes.filter((o) => o === 'sent').length + if (successes === 0) return false const failures = this.recentOutcomes.filter((o) => o === 'failed').length return failures < FcmService.HEALTH_FAILURE_THRESHOLD } From ccac1f9554ceddd928d0b56e70a4d5b45923da44 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:19:53 +0100 Subject: [PATCH 19/39] =?UTF-8?q?fix(hub):=20bump=20FCM=20migration=20to?= =?UTF-8?q?=20V10=E2=86=92V11=20after=20upstream=20service=5Ftier=20V9?= =?UTF-8?q?=E2=86=92V10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream/main landed sessions.service_tier at schema v10. The companion FCM device registry now migrates at v11 so both changes compose cleanly after the courtesy rebase onto current upstream/main. Co-authored-by: Cursor --- hub/src/store/index.ts | 37 +++++++++++++++-------------- hub/src/store/migration-v10.test.ts | 13 +++++----- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 7b871f298d..c4c0e2f6ca 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -26,7 +26,7 @@ export { FcmStore } from './fcmStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 10 +const SCHEMA_VERSION: number = 11 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -130,6 +130,7 @@ export class Store { 7: () => this.migrateFromV7ToV8(), 8: () => this.migrateFromV8ToV9(), 9: () => this.migrateFromV9ToV10(), + 10: () => this.migrateFromV10ToV11(), }) if (currentVersion === 0) { @@ -428,23 +429,6 @@ export class Store { `) } - private migrateFromV9ToV10(): void { - this.db.exec(` - CREATE TABLE IF NOT EXISTS fcm_devices ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - namespace TEXT NOT NULL, - token TEXT NOT NULL, - platform TEXT NOT NULL, - device_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(namespace, device_id, platform) - ); - CREATE INDEX IF NOT EXISTS idx_fcm_devices_namespace ON fcm_devices(namespace); - CREATE INDEX IF NOT EXISTS idx_fcm_devices_token ON fcm_devices(token); - `) - } - private migrateFromV8ToV9(): void { const columns = this.getMessageColumnNames() if (columns.size === 0) { @@ -471,6 +455,23 @@ export class Store { } } + private migrateFromV10ToV11(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS fcm_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + token TEXT NOT NULL, + platform TEXT NOT NULL, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(namespace, device_id, platform) + ); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_namespace ON fcm_devices(namespace); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_token ON fcm_devices(token); + `) + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/migration-v10.test.ts b/hub/src/store/migration-v10.test.ts index e6c8a44f89..710f21e00a 100644 --- a/hub/src/store/migration-v10.test.ts +++ b/hub/src/store/migration-v10.test.ts @@ -5,22 +5,22 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Store } from './index' -describe('Store V9→V10 migration: fcm_devices', () => { +describe('Store V10→V11 migration: fcm_devices', () => { it('fresh DB has fcm_devices table', () => { const store = new Store(':memory:') expect(tableExists(store, 'fcm_devices')).toBe(true) }) - it('V9 DB migrates to V10: fcm_devices created', () => { - const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v10-test-')) + it('V10 DB migrates to V11: fcm_devices created', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v11-test-')) const dbPath = join(dir, 'test.db') let store: Store | undefined try { const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) db.exec('PRAGMA journal_mode = WAL') db.exec('PRAGMA foreign_keys = ON') - createV9Schema(db) - db.exec('PRAGMA user_version = 9') + createV10Schema(db) + db.exec('PRAGMA user_version = 10') db.close() store = new Store(dbPath) @@ -57,7 +57,7 @@ function tableExists(store: Store, name: string): boolean { return row !== null } -function createV9Schema(db: Database): void { +function createV10Schema(db: Database): void { db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, @@ -73,6 +73,7 @@ function createV9Schema(db: Database): void { model TEXT, model_reasoning_effort TEXT, effort TEXT, + service_tier TEXT, todos TEXT, todos_updated_at INTEGER, team_state TEXT, From 8f870516e0824aa9480cc1c6f7c5e2c7d563c09e Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:29:08 +0100 Subject: [PATCH 20/39] fix(hub): per-dispatch native gate instead of stale FCM probe FCM runs before web-push; PushNotificationChannel skips web/SSE only when the same notify() dispatch already delivered via FCM. Removes the isHealthy()+device-row probe that could suppress web-push after warm FCM outages. Co-authored-by: Cursor --- hub/src/fcm/fcmNotificationChannel.test.ts | 30 +++++ hub/src/fcm/fcmNotificationChannel.ts | 20 ++-- hub/src/fcm/nativeFallbackProbe.test.ts | 90 -------------- hub/src/fcm/nativeFallbackProbe.ts | 37 ------ hub/src/notifications/notificationHub.ts | 10 +- .../notifications/notificationSendContext.ts | 14 +++ hub/src/notifications/notificationTypes.ts | 7 +- hub/src/push/pushNotificationChannel.test.ts | 55 ++++++--- hub/src/push/pushNotificationChannel.ts | 111 ++++-------------- hub/src/startHub.ts | 31 ++--- 10 files changed, 136 insertions(+), 269 deletions(-) delete mode 100644 hub/src/fcm/nativeFallbackProbe.test.ts delete mode 100644 hub/src/fcm/nativeFallbackProbe.ts create mode 100644 hub/src/notifications/notificationSendContext.ts diff --git a/hub/src/fcm/fcmNotificationChannel.test.ts b/hub/src/fcm/fcmNotificationChannel.test.ts index cb1185b464..d0e5833a75 100644 --- a/hub/src/fcm/fcmNotificationChannel.test.ts +++ b/hub/src/fcm/fcmNotificationChannel.test.ts @@ -372,6 +372,36 @@ describe('FcmNotificationChannel', () => { expect(sent[0].data.severity).toBe('info') }) + it('sets nativeGate.sent when FCM delivers at least one message', async () => { + const gate = { sent: false } + const channel = new FcmNotificationChannel( + { + sendToNamespace: async () => ({ sent: 1, failed: 0, invalidTokens: [] }) + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + + await channel.sendReady(createSession(), { nativeGate: gate }) + + expect(gate.sent).toBe(true) + }) + + it('leaves nativeGate.sent false when FCM sends zero messages', async () => { + const gate = { sent: false } + const channel = new FcmNotificationChannel( + { + sendToNamespace: async () => ({ sent: 0, failed: 1, invalidTokens: [] }) + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + + await channel.sendReady(createSession(), { nativeGate: gate }) + + expect(gate.sent).toBe(false) + }) + it('sets severity=warning on permission-request notifications', async () => { const sent: FcmSendPayload[] = [] const channel = new FcmNotificationChannel( diff --git a/hub/src/fcm/fcmNotificationChannel.ts b/hub/src/fcm/fcmNotificationChannel.ts index 18422a1b06..46486023c5 100644 --- a/hub/src/fcm/fcmNotificationChannel.ts +++ b/hub/src/fcm/fcmNotificationChannel.ts @@ -1,5 +1,6 @@ import type { Session } from '../sync/syncEngine' import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes' +import type { NotificationSendContext } from '../notifications/notificationSendContext' import { getAgentName, getSessionName } from '../notifications/sessionInfo' import { formatToolArgumentsCompact, formatToolArgumentsDetailed } from '../notifications/toolArgs' import { extractAssistantPlainText, extractNotifySummary, unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' @@ -26,7 +27,7 @@ export class FcmNotificationChannel implements NotificationChannel { private readonly store?: Store ) {} - async sendPermissionRequest(session: Session): Promise { + async sendPermissionRequest(session: Session, ctx?: NotificationSendContext): Promise { if (!session.active) { return } @@ -75,10 +76,10 @@ export class FcmNotificationChannel implements NotificationChannel { severity: 'warning' }) - await this.deliver(session, payload) + await this.deliver(session, payload, ctx) } - async sendReady(session: Session): Promise { + async sendReady(session: Session, ctx?: NotificationSendContext): Promise { if (!session.active) { return } @@ -104,7 +105,7 @@ export class FcmNotificationChannel implements NotificationChannel { payload.data.notifySummary = JSON.stringify(composed.notifySummary) } - await this.deliver(session, payload) + await this.deliver(session, payload, ctx) } /** @@ -200,7 +201,7 @@ export class FcmNotificationChannel implements NotificationChannel { return null } - async sendTaskNotification(session: Session, notification: TaskNotification): Promise { + async sendTaskNotification(session: Session, notification: TaskNotification, ctx?: NotificationSendContext): Promise { if (!session.active) { return } @@ -224,7 +225,7 @@ export class FcmNotificationChannel implements NotificationChannel { severity: isFailure ? 'error' : 'success' }) - await this.deliver(session, payload) + await this.deliver(session, payload, ctx) } private buildPayload(input: { @@ -256,7 +257,7 @@ export class FcmNotificationChannel implements NotificationChannel { } } - private async deliver(session: Session, payload: FcmSendPayload): Promise { + private async deliver(session: Session, payload: FcmSendPayload, ctx?: NotificationSendContext): Promise { // Native companion is the canonical surface: always fire FCM when the // hub asks us to. The previous SSE-toast shortcut here meant that // when the operator had the PWA open in foreground, the watch got @@ -266,7 +267,10 @@ export class FcmNotificationChannel implements NotificationChannel { // progress. SSE in-page toasts are still emitted by the PWA's own // SyncEngine event stream for users who want them; this channel's // job is to reach the wrist, period. - await this.fcmService.sendToNamespace(session.namespace, payload) + const result = await this.fcmService.sendToNamespace(session.namespace, payload) + if ((result?.sent ?? 0) > 0 && ctx?.nativeGate) { + ctx.nativeGate.sent = true + } } private buildSessionPath(sessionId: string): string { diff --git a/hub/src/fcm/nativeFallbackProbe.test.ts b/hub/src/fcm/nativeFallbackProbe.test.ts deleted file mode 100644 index 0fce9cfcad..0000000000 --- a/hub/src/fcm/nativeFallbackProbe.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it, mock } from 'bun:test' -import type { Store } from '../store' -import { buildNativeFallbackProbe } from './nativeFallbackProbe' - -type FakeStore = { - fcm: { getDevicesByNamespace: ReturnType } -} - -function makeStore(perNs: Record): FakeStore { - return { - fcm: { - getDevicesByNamespace: mock((ns: string) => - Array.from({ length: perNs[ns] ?? 0 }, (_, i) => ({ - id: i, - namespace: ns, - token: `tok-${i}`, - platform: 'phone' as const, - deviceId: `dev-${i}`, - createdAt: 0, - updatedAt: 0 - })) - ) - } - } -} - -describe('buildNativeFallbackProbe', () => { - it('returns false for every namespace when FCM is not configured (regression for HAPI Bot finding)', () => { - const store = makeStore({ default: 5, alt: 1 }) - const probe = buildNativeFallbackProbe(store as unknown as Store, null) - - expect(probe('default')).toBe(false) - expect(probe('alt')).toBe(false) - expect(probe('nonexistent')).toBe(false) - }) - - it('does not query the device store when FCM is not configured', () => { - const store = makeStore({ default: 5 }) - const probe = buildNativeFallbackProbe(store as unknown as Store, null) - - probe('default') - - // If we hit the store, stale device rows would silently suppress - // web-push for a hub running without FCM. The contract is that - // the no-config branch never even consults the store. - expect(store.fcm.getDevicesByNamespace).not.toHaveBeenCalled() - }) - - it('returns true only for namespaces with at least one registered device when FCM is configured', () => { - const store = makeStore({ default: 2, empty: 0 }) - const fcmConfig = { projectId: 'p', serviceAccount: { client_email: 'x', private_key: 'y' } } - const probe = buildNativeFallbackProbe(store as unknown as Store, fcmConfig) - - expect(probe('default')).toBe(true) - expect(probe('empty')).toBe(false) - expect(probe('untouched')).toBe(false) - }) - - it('returns false when fcmService reports unhealthy, even with registered devices', () => { - const store = makeStore({ default: 3 }) - const fcmConfig = { projectId: 'p', serviceAccount: { client_email: 'x', private_key: 'y' } } - const fcmService = { isHealthy: mock(() => false) } - const probe = buildNativeFallbackProbe(store as unknown as Store, fcmConfig, fcmService) - - expect(probe('default')).toBe(false) - expect(fcmService.isHealthy).toHaveBeenCalled() - // We short-circuit before hitting the device store - the namespace - // has registered devices but a broken FCM pipeline means web-push - // is the only surface still able to reach the operator. - expect(store.fcm.getDevicesByNamespace).not.toHaveBeenCalled() - }) - - it('returns true when fcmService reports healthy and devices exist', () => { - const store = makeStore({ default: 1 }) - const fcmConfig = { projectId: 'p', serviceAccount: { client_email: 'x', private_key: 'y' } } - const fcmService = { isHealthy: mock(() => true) } - const probe = buildNativeFallbackProbe(store as unknown as Store, fcmConfig, fcmService) - - expect(probe('default')).toBe(true) - expect(fcmService.isHealthy).toHaveBeenCalled() - }) - - it('treats absent fcmService as healthy (back-compat with single-arg call site)', () => { - const store = makeStore({ default: 2 }) - const fcmConfig = { projectId: 'p', serviceAccount: { client_email: 'x', private_key: 'y' } } - const probe = buildNativeFallbackProbe(store as unknown as Store, fcmConfig) - - expect(probe('default')).toBe(true) - }) -}) diff --git a/hub/src/fcm/nativeFallbackProbe.ts b/hub/src/fcm/nativeFallbackProbe.ts deleted file mode 100644 index c490f365f7..0000000000 --- a/hub/src/fcm/nativeFallbackProbe.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Store } from '../store' -import type { FcmService } from './fcmService' - -/** - * Build a per-namespace probe used by the web-push channel to decide whether - * to defer to the native FCM channel. The probe MUST only return true when - * ALL of these hold: - * - * 1. FCM is actually configured on this hub start (fcmConfig is truthy), - * so a registered FCM channel exists to deliver the notification. - * 2. The FCM service is currently healthy (recent sends are not all - * failing). When credentials expire or the FCM pipeline blackholes, - * we let web-push run again so the operator does not get silently - * cut off from all notifications. - * 3. At least one device row is registered for the namespace. - * - * Failing any condition means web-push must fire normally. The default - * "happy path" remains: native is the canonical wrist-first surface and - * web-push is suppressed to avoid duplicate OS notifications - this probe - * only re-enables web-push when we have evidence the native pipeline is - * not actually delivering. - */ -export function buildNativeFallbackProbe( - store: Store, - fcmConfig: unknown, - fcmService?: Pick -): (namespace: string) => boolean { - if (!fcmConfig) { - return () => false - } - return (namespace: string) => { - if (fcmService && !fcmService.isHealthy()) { - return false - } - return store.fcm.getDevicesByNamespace(namespace).length > 0 - } -} diff --git a/hub/src/notifications/notificationHub.ts b/hub/src/notifications/notificationHub.ts index bfe109abf7..f9d627e1e7 100644 --- a/hub/src/notifications/notificationHub.ts +++ b/hub/src/notifications/notificationHub.ts @@ -1,6 +1,7 @@ import type { Session, SyncEngine, SyncEvent } from '../sync/syncEngine' import type { SessionEndReason } from '@hapi/protocol' import type { NotificationChannel, NotificationHubOptions, TaskNotification } from './notificationTypes' +import type { NotificationSendContext } from './notificationSendContext' import { extractMessageEventType, extractTaskNotification } from './eventParsing' export class NotificationHub { @@ -182,9 +183,10 @@ export class NotificationHub { } private async notifyReady(session: Session): Promise { + const ctx: NotificationSendContext = { nativeGate: { sent: false } } for (const channel of this.channels) { try { - await channel.sendReady(session) + await channel.sendReady(session, ctx) } catch (error) { console.error('[NotificationHub] Failed to send ready notification:', error) } @@ -192,9 +194,10 @@ export class NotificationHub { } private async notifyPermission(session: Session): Promise { + const ctx: NotificationSendContext = { nativeGate: { sent: false } } for (const channel of this.channels) { try { - await channel.sendPermissionRequest(session) + await channel.sendPermissionRequest(session, ctx) } catch (error) { console.error('[NotificationHub] Failed to send permission notification:', error) } @@ -202,9 +205,10 @@ export class NotificationHub { } private async notifyTask(session: Session, notification: TaskNotification): Promise { + const ctx: NotificationSendContext = { nativeGate: { sent: false } } for (const channel of this.channels) { try { - await channel.sendTaskNotification(session, notification) + await channel.sendTaskNotification(session, notification, ctx) } catch (error) { console.error('[NotificationHub] Failed to send task notification:', error) } diff --git a/hub/src/notifications/notificationSendContext.ts b/hub/src/notifications/notificationSendContext.ts new file mode 100644 index 0000000000..baef669a08 --- /dev/null +++ b/hub/src/notifications/notificationSendContext.ts @@ -0,0 +1,14 @@ +/** + * Per-notification dispatch context shared across channels in one + * NotificationHub notify* call. FcmNotificationChannel runs first and + * sets `nativeGate.sent` when FCM actually delivers; PushNotificationChannel + * consults the same gate before suppressing web-push/SSE (never on stale + * registration/health probes alone). + */ +export type NativeDeliveryGate = { + sent: boolean +} + +export type NotificationSendContext = { + nativeGate?: NativeDeliveryGate +} diff --git a/hub/src/notifications/notificationTypes.ts b/hub/src/notifications/notificationTypes.ts index 07e2b642c9..6130cf30bd 100644 --- a/hub/src/notifications/notificationTypes.ts +++ b/hub/src/notifications/notificationTypes.ts @@ -1,5 +1,6 @@ import type { Session } from '../sync/syncEngine' import type { SessionEndReason } from '@hapi/protocol' +import type { NotificationSendContext } from './notificationSendContext' export type TaskNotification = { summary: string @@ -7,9 +8,9 @@ export type TaskNotification = { } export type NotificationChannel = { - sendReady: (session: Session) => Promise - sendPermissionRequest: (session: Session) => Promise - sendTaskNotification: (session: Session, notification: TaskNotification) => Promise + sendReady: (session: Session, ctx?: NotificationSendContext) => Promise + sendPermissionRequest: (session: Session, ctx?: NotificationSendContext) => Promise + sendTaskNotification: (session: Session, notification: TaskNotification, ctx?: NotificationSendContext) => Promise sendSessionCompletion?: (session: Session, reason: SessionEndReason) => Promise } diff --git a/hub/src/push/pushNotificationChannel.test.ts b/hub/src/push/pushNotificationChannel.test.ts index 6906bce3fe..bd0d0c330b 100644 --- a/hub/src/push/pushNotificationChannel.test.ts +++ b/hub/src/push/pushNotificationChannel.test.ts @@ -76,7 +76,7 @@ describe('PushNotificationChannel', () => { expect(pushed[1].payload.tag).toBeUndefined() }) - it('skips web-push fallback when a native companion is registered for the namespace', async () => { + it('skips web-push when native FCM delivered in the same dispatch', async () => { const pushed: Array<{ namespace: string; payload: PushPayload }> = [] const channel = new PushNotificationChannel( { @@ -90,27 +90,26 @@ describe('PushNotificationChannel', () => { { hasVisibleConnection: () => false } as never, - '', - (namespace: string) => namespace === 'default' + '' ) + const ctx = { nativeGate: { sent: true } } + await channel.sendPermissionRequest(createSession({ agentState: { requests: { 'req-1': { tool: 'Bash', arguments: {} } } } - })) - await channel.sendReady(createSession()) + }), ctx) + await channel.sendReady(createSession(), ctx) await channel.sendTaskNotification(createSession(), { status: 'completed', summary: 'Done' - }) + }, ctx) - // Native companion handles all three; web-push must stay quiet to - // avoid double-notifying the operator on phone+watch+browser. expect(pushed).toHaveLength(0) }) - it('still sends web-push when namespace has no native companion', async () => { + it('falls back to web-push when native gate is unset (FCM failed or absent)', async () => { const pushed: Array<{ namespace: string; payload: PushPayload }> = [] const channel = new PushNotificationChannel( { @@ -124,8 +123,29 @@ describe('PushNotificationChannel', () => { { hasVisibleConnection: () => false } as never, - '', - () => false + '' + ) + + await channel.sendReady(createSession(), { nativeGate: { sent: false } }) + + expect(pushed).toHaveLength(1) + }) + + it('still sends web-push when no native gate is provided', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '' ) await channel.sendReady(createSession()) @@ -133,7 +153,7 @@ describe('PushNotificationChannel', () => { expect(pushed).toHaveLength(1) }) - it('also skips SSE in-page toast when a native companion is registered (defer-to-native)', async () => { + it('also skips SSE in-page toast when native gate reports delivery', async () => { const pushed: Array<{ namespace: string; payload: PushPayload }> = [] const toasts: unknown[] = [] const channel = new PushNotificationChannel( @@ -151,18 +171,19 @@ describe('PushNotificationChannel', () => { { hasVisibleConnection: () => true } as never, - '', - (namespace: string) => namespace === 'default' + '' ) - await channel.sendReady(createSession()) + const ctx = { nativeGate: { sent: true } } + + await channel.sendReady(createSession(), ctx) await channel.sendPermissionRequest(createSession({ agentState: { requests: { 'r-1': { tool: 'Bash', arguments: {} } } } - })) + }), ctx) await channel.sendTaskNotification(createSession(), { status: 'completed', summary: 'Done' - }) + }, ctx) // Even when the PWA is foreground/visible, the operator asked to mute // it - the in-page React toast and the OS web-push are both dropped diff --git a/hub/src/push/pushNotificationChannel.ts b/hub/src/push/pushNotificationChannel.ts index a4ec4a3421..bfe751f557 100644 --- a/hub/src/push/pushNotificationChannel.ts +++ b/hub/src/push/pushNotificationChannel.ts @@ -1,39 +1,19 @@ import type { Session } from '../sync/syncEngine' import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes' +import type { NotificationSendContext } from '../notifications/notificationSendContext' import { getAgentName, getSessionName } from '../notifications/sessionInfo' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { PushPayload, PushService } from './pushService' -/** - * Probe that returns true when a native companion (Android FCM phone/wear) - * device is registered for this namespace. When provided to - * PushNotificationChannel, the channel suppresses its web-push fallback so - * the operator stops getting double notifications: one on the native app, - * one on the PWA service worker. The in-app SSE toast path is unaffected - - * if the operator has the PWA actively in the foreground, they'll still see - * the toast inside the page. - */ -export type NativeFallbackProbe = (namespace: string) => boolean - export class PushNotificationChannel implements NotificationChannel { constructor( private readonly pushService: PushService, private readonly sseManager: SSEManager, private readonly visibilityTracker: VisibilityTracker, - _appUrl: string, - private readonly nativeFallbackProbe?: NativeFallbackProbe + _appUrl: string ) {} - /** - * Returns true if web-push delivery should be skipped because a native - * companion will already cover this namespace. Centralised so all three - * send* methods stay in lock-step. - */ - private shouldSkipWebPush(namespace: string): boolean { - return this.nativeFallbackProbe?.(namespace) ?? false - } - /** * Debug observability: gated on `HAPI_NOTIFY_DEBUG=1`. Lets the operator * see which branch each notification took so we can root-cause "still @@ -46,7 +26,7 @@ export class PushNotificationChannel implements NotificationChannel { console.log(`[Push.${method}] ns=${namespace} ${branch}${note}`) } - async sendPermissionRequest(session: Session): Promise { + async sendPermissionRequest(session: Session, ctx?: NotificationSendContext): Promise { if (!session.active) { return } @@ -69,40 +49,10 @@ export class PushNotificationChannel implements NotificationChannel { } } - // Native-companion-first: when an FCM device is registered for this - // namespace the watch already covers this notification end-to-end. - // Skip the SSE in-page toast AND the web-push fallback - both are - // redundant surfaces that the operator explicitly asked us to mute. - if (this.shouldSkipWebPush(session.namespace)) { - this.logBranch('permission', session.namespace, 'defer-to-native', 'native-companion-registered') - return - } - - const url = payload.data?.url ?? this.buildSessionPath(session.id) - if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { - const delivered = await this.sseManager.sendToast(session.namespace, { - type: 'toast', - data: { - title: payload.title, - body: payload.body, - sessionId: session.id, - url - } - }) - if (delivered > 0) { - this.logBranch('permission', session.namespace, 'sse-toast-delivered', `count=${delivered}`) - return - } - this.logBranch('permission', session.namespace, 'sse-toast-zero', 'visible but delivered=0') - } else { - this.logBranch('permission', session.namespace, 'not-visible') - } - - this.logBranch('permission', session.namespace, 'web-push-fired') - await this.pushService.sendToNamespace(session.namespace, payload) + await this.deliverWebOrToast(session, payload, ctx, 'permission') } - async sendReady(session: Session): Promise { + async sendReady(session: Session, ctx?: NotificationSendContext): Promise { if (!session.active) { return } @@ -121,36 +71,10 @@ export class PushNotificationChannel implements NotificationChannel { } } - if (this.shouldSkipWebPush(session.namespace)) { - this.logBranch('ready', session.namespace, 'defer-to-native', 'native-companion-registered') - return - } - - const url = payload.data?.url ?? this.buildSessionPath(session.id) - if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { - const delivered = await this.sseManager.sendToast(session.namespace, { - type: 'toast', - data: { - title: payload.title, - body: payload.body, - sessionId: session.id, - url - } - }) - if (delivered > 0) { - this.logBranch('ready', session.namespace, 'sse-toast-delivered', `count=${delivered}`) - return - } - this.logBranch('ready', session.namespace, 'sse-toast-zero', 'visible but delivered=0') - } else { - this.logBranch('ready', session.namespace, 'not-visible') - } - - this.logBranch('ready', session.namespace, 'web-push-fired') - await this.pushService.sendToNamespace(session.namespace, payload) + await this.deliverWebOrToast(session, payload, ctx, 'ready') } - async sendTaskNotification(session: Session, notification: TaskNotification): Promise { + async sendTaskNotification(session: Session, notification: TaskNotification, ctx?: NotificationSendContext): Promise { if (!session.active) { return } @@ -173,8 +97,17 @@ export class PushNotificationChannel implements NotificationChannel { } } - if (this.shouldSkipWebPush(session.namespace)) { - this.logBranch('task', session.namespace, 'defer-to-native', 'native-companion-registered') + await this.deliverWebOrToast(session, payload, ctx, 'task') + } + + private async deliverWebOrToast( + session: Session, + payload: PushPayload, + ctx: NotificationSendContext | undefined, + method: 'permission' | 'ready' | 'task' + ): Promise { + if (ctx?.nativeGate?.sent) { + this.logBranch(method, session.namespace, 'defer-to-native', 'fcm-delivered-this-dispatch') return } @@ -190,15 +123,15 @@ export class PushNotificationChannel implements NotificationChannel { } }) if (delivered > 0) { - this.logBranch('task', session.namespace, 'sse-toast-delivered', `count=${delivered}`) + this.logBranch(method, session.namespace, 'sse-toast-delivered', `count=${delivered}`) return } - this.logBranch('task', session.namespace, 'sse-toast-zero', 'visible but delivered=0') + this.logBranch(method, session.namespace, 'sse-toast-zero', 'visible but delivered=0') } else { - this.logBranch('task', session.namespace, 'not-visible') + this.logBranch(method, session.namespace, 'not-visible') } - this.logBranch('task', session.namespace, 'web-push-fired') + this.logBranch(method, session.namespace, 'web-push-fired') await this.pushService.sendToNamespace(session.namespace, payload) } diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 8c699347c5..a5df16dbdc 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -14,7 +14,6 @@ import { PushNotificationChannel } from './push/pushNotificationChannel' import { FcmService } from './fcm/fcmService' import { FcmNotificationChannel } from './fcm/fcmNotificationChannel' import { resolveFcmConfig } from './fcm/fcmConfig' -import { buildNativeFallbackProbe } from './fcm/nativeFallbackProbe' import { VisibilityTracker } from './visibility/visibilityTracker' import { TunnelManager } from './tunnel' import { waitForTunnelTlsReady } from './tunnel/tlsGate' @@ -212,33 +211,21 @@ export async function startHub(options: StartHubOptions = {}): Promise Date: Wed, 17 Jun 2026 16:17:13 +0100 Subject: [PATCH 21/39] fix(fcm): wrist push on model errors via sendModelError (#878) FcmNotificationChannel now implements the optional sendModelError hook so NotificationHub can reach the native companion when cursor-agent hits a model-side failure. Uses shared modelErrorCopy strings, severity=error, distinct model-error-${sessionId}-${atTs} tags, and always calls deliver() (wrist-first, no SSE shortcut). Depends on feat/companion-fcm-push-api and feat/cursor-detect-inline-model-errors both being lower in the driver soup stack. Co-authored-by: Cursor --- hub/src/fcm/fcmNotificationChannel.test.ts | 62 ++++++++++++++++++++++ hub/src/fcm/fcmNotificationChannel.ts | 28 +++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/hub/src/fcm/fcmNotificationChannel.test.ts b/hub/src/fcm/fcmNotificationChannel.test.ts index d0e5833a75..15bac92a35 100644 --- a/hub/src/fcm/fcmNotificationChannel.test.ts +++ b/hub/src/fcm/fcmNotificationChannel.test.ts @@ -440,4 +440,66 @@ describe('FcmNotificationChannel', () => { expect(sent[0].data.severity).toBe('error') } }) + + it('sendModelError fires FCM with severity=error even when PWA is foreground', async () => { + const sent: FcmSendPayload[] = [] + const toasts: unknown[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { + sendToast: async (_namespace: string, event: unknown) => { + toasts.push(event) + return 1 + } + } as never, + { + hasVisibleConnection: () => true + } as never + ) + + await channel.sendModelError(createSession(), { + kind: 'quota_exhausted', + transient: false, + rawSnippet: 'You have hit your usage limit', + priorAssistantClaimsDone: true, + atTs: 1710000000000 + }) + + expect(sent).toHaveLength(1) + expect(toasts).toHaveLength(0) + expect(sent[0].data.type).toBe('model-error') + expect(sent[0].data.severity).toBe('error') + expect(sent[0].tag).toBe('model-error-session-ready-1710000000000') + expect(sent[0].title).toBe('Quota exhausted') + expect(sent[0].body).toContain('Codex') + expect(sent[0].body).toContain('Demo') + }) + + it('sendModelError uses distinct tags per atTs so errors do not collapse', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + + const base = { + kind: 'rate_limited', + transient: true, + rawSnippet: 'slow down', + priorAssistantClaimsDone: false + } + + await channel.sendModelError(createSession(), { ...base, atTs: 1 }) + await channel.sendModelError(createSession(), { ...base, atTs: 2 }) + + expect(sent[0].tag).toBe('model-error-session-ready-1') + expect(sent[1].tag).toBe('model-error-session-ready-2') + expect(sent[0].data.type).toBe('model-error') + expect(sent[1].data.severity).toBe('error') + }) }) diff --git a/hub/src/fcm/fcmNotificationChannel.ts b/hub/src/fcm/fcmNotificationChannel.ts index 46486023c5..e43c4c46cf 100644 --- a/hub/src/fcm/fcmNotificationChannel.ts +++ b/hub/src/fcm/fcmNotificationChannel.ts @@ -1,6 +1,7 @@ import type { Session } from '../sync/syncEngine' -import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes' +import type { ModelErrorNotification, NotificationChannel, TaskNotification } from '../notifications/notificationTypes' import type { NotificationSendContext } from '../notifications/notificationSendContext' +import { formatModelErrorBody, formatModelErrorTitle } from '../notifications/modelErrorCopy' import { getAgentName, getSessionName } from '../notifications/sessionInfo' import { formatToolArgumentsCompact, formatToolArgumentsDetailed } from '../notifications/toolArgs' import { extractAssistantPlainText, extractNotifySummary, unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' @@ -228,6 +229,31 @@ export class FcmNotificationChannel implements NotificationChannel { await this.deliver(session, payload, ctx) } + async sendModelError(session: Session, notification: ModelErrorNotification): Promise { + if (!session.active) { + return + } + + const agentName = getAgentName(session) + const sessionName = getSessionName(session) + const title = formatModelErrorTitle(notification.kind) + const body = formatModelErrorBody(notification, { agentName, sessionName }) + const path = this.buildSessionPath(session.id) + + const payload = this.buildPayload({ + title, + body, + tag: `model-error-${session.id}-${notification.atTs}`, + type: 'model-error', + sessionId: session.id, + sessionName, + url: path, + severity: 'error' + }) + + await this.deliver(session, payload) + } + private buildPayload(input: { title: string body: string From dd370e13cc640bb7e47ecdcf15a4773e52309c2c Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:30:38 +0100 Subject: [PATCH 22/39] fix(hub,web): cap notifySummary for FCM limits; fix PWA test cast Rebase follow-up: truncate AGENT_NOTIFY_SUMMARY summary/action before FCM data payload (bot Major). Fix usePwaUpdate.test.ts setTimeout mock cast so bun typecheck passes on current main. Co-authored-by: Cursor --- hub/src/fcm/fcmNotificationChannel.test.ts | 38 ++++++++++++++++++++++ hub/src/fcm/fcmNotificationChannel.ts | 33 +++++++++++++++---- web/src/hooks/usePwaUpdate.test.ts | 2 +- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/hub/src/fcm/fcmNotificationChannel.test.ts b/hub/src/fcm/fcmNotificationChannel.test.ts index d0e5833a75..b4a3ac4233 100644 --- a/hub/src/fcm/fcmNotificationChannel.test.ts +++ b/hub/src/fcm/fcmNotificationChannel.test.ts @@ -215,6 +215,44 @@ describe('FcmNotificationChannel', () => { expect(parsed.action).toBe('Upload preview') }) + it('sendReady caps long AGENT_NOTIFY_SUMMARY text for FCM data limits', async () => { + const sent: FcmSendPayload[] = [] + const longSummary = 'S'.repeat(400) + const longAction = 'A'.repeat(400) + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: `Done.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"summary":"${longSummary}","action":"${longAction}","status":"done"}` + } + } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent).toHaveLength(1) + expect(sent[0].body.length).toBeLessThanOrEqual(280) + const parsed = JSON.parse(sent[0].data.notifySummary as string) + expect(parsed.summary.length).toBeLessThanOrEqual(280) + expect(parsed.action.length).toBeLessThanOrEqual(280) + }) + it('sendReady truncates last assistant text when no summary is present', async () => { const sent: FcmSendPayload[] = [] const longText = 'A'.repeat(500) diff --git a/hub/src/fcm/fcmNotificationChannel.ts b/hub/src/fcm/fcmNotificationChannel.ts index 46486023c5..266088532e 100644 --- a/hub/src/fcm/fcmNotificationChannel.ts +++ b/hub/src/fcm/fcmNotificationChannel.ts @@ -143,14 +143,24 @@ export class FcmNotificationChannel implements NotificationChannel { const headerTitle = `${agentName} - ${sessionName}` if (summary?.summary) { - const lines: string[] = [summary.summary] - if (summary.action && summary.action !== summary.summary) { - lines.push(`-> ${summary.action}`) - } + const summaryLine = this.truncateReadyText(summary.summary, READY_BODY_GLANCE_LIMIT) + const actionLine = summary.action && summary.action !== summary.summary + ? this.truncateReadyText( + `-> ${summary.action}`, + Math.max(0, READY_BODY_GLANCE_LIMIT - summaryLine.length - 1) + ) + : '' + const body = [summaryLine, actionLine].filter(Boolean).join('\n') return { title: headerTitle, - body: lines.join('\n'), - notifySummary: { ...summary } + body, + notifySummary: { + ...summary, + summary: summaryLine, + ...(summary.action + ? { action: this.truncateReadyText(summary.action, READY_BODY_GLANCE_LIMIT) } + : {}) + } } } @@ -166,6 +176,17 @@ export class FcmNotificationChannel implements NotificationChannel { return { title: headerTitle, body } } + private truncateReadyText(text: string, limit: number): string { + const trimmed = text.trim() + if (limit <= 0 || trimmed.length === 0) { + return '' + } + if (trimmed.length <= limit) { + return trimmed + } + return trimmed.slice(0, limit - 3).trimEnd() + '...' + } + /** * Walk the most recent stored messages and return the plain text of * the latest assistant message that *has* text content. Tool calls, diff --git a/web/src/hooks/usePwaUpdate.test.ts b/web/src/hooks/usePwaUpdate.test.ts index d5733b693c..1880b71889 100644 --- a/web/src/hooks/usePwaUpdate.test.ts +++ b/web/src/hooks/usePwaUpdate.test.ts @@ -130,7 +130,7 @@ describe('requestPwaUpdateReload', () => { setTimeoutFn: vi.fn((callback, delay) => { expect(delay).toBe(PWA_UPDATE_RELOAD_FALLBACK_MS) return setTimeout(callback, delay) - }) as typeof setTimeout, + }) as unknown as typeof setTimeout, }) await pending From fd89f050f25127b71493eddecf8e00e0267cf37b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:47:48 +0100 Subject: [PATCH 23/39] fix(hub): cap all FCM notifySummary fields and task bodies Whitelist and truncate AGENT_NOTIFY_SUMMARY auxiliary fields before JSON serialization; cap task-notification summaries to glance limit. Co-authored-by: Cursor --- hub/src/fcm/fcmNotificationChannel.test.ts | 52 ++++++++++++++++++++++ hub/src/fcm/fcmNotificationChannel.ts | 21 +++++---- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/hub/src/fcm/fcmNotificationChannel.test.ts b/hub/src/fcm/fcmNotificationChannel.test.ts index b4a3ac4233..84ab1388eb 100644 --- a/hub/src/fcm/fcmNotificationChannel.test.ts +++ b/hub/src/fcm/fcmNotificationChannel.test.ts @@ -253,6 +253,43 @@ describe('FcmNotificationChannel', () => { expect(parsed.action.length).toBeLessThanOrEqual(280) }) + it('sendReady caps auxiliary notifySummary fields for FCM data limits', async () => { + const sent: FcmSendPayload[] = [] + const longAgent = 'G'.repeat(200) + const longProject = 'P'.repeat(200) + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: `Done.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"summary":"ok","action":"go","status":"${'x'.repeat(80)}","agent":"${longAgent}","project":"${longProject}"}` + } + } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + const parsed = JSON.parse(sent[0].data.notifySummary as string) + expect(parsed.status.length).toBeLessThanOrEqual(32) + expect(parsed.agent.length).toBeLessThanOrEqual(80) + expect(parsed.project.length).toBeLessThanOrEqual(80) + }) + it('sendReady truncates last assistant text when no summary is present', async () => { const sent: FcmSendPayload[] = [] const longText = 'A'.repeat(500) @@ -478,4 +515,19 @@ describe('FcmNotificationChannel', () => { expect(sent[0].data.severity).toBe('error') } }) + + it('sendTaskNotification caps long task summaries for FCM data limits', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + await channel.sendTaskNotification(createSession(), { + status: 'completed', + summary: 'T'.repeat(500) + }) + expect(sent[0].body).toContain('...') + expect(sent[0].body.length).toBeLessThan(350) + }) }) diff --git a/hub/src/fcm/fcmNotificationChannel.ts b/hub/src/fcm/fcmNotificationChannel.ts index 266088532e..1469938793 100644 --- a/hub/src/fcm/fcmNotificationChannel.ts +++ b/hub/src/fcm/fcmNotificationChannel.ts @@ -151,16 +151,20 @@ export class FcmNotificationChannel implements NotificationChannel { ) : '' const body = [summaryLine, actionLine].filter(Boolean).join('\n') + const notifySummary = { + ...(typeof summary.version === 'number' ? { version: summary.version } : {}), + summary: summaryLine, + ...(summary.action + ? { action: this.truncateReadyText(summary.action, READY_BODY_GLANCE_LIMIT) } + : {}), + ...(summary.status ? { status: this.truncateReadyText(summary.status, 32) } : {}), + ...(summary.agent ? { agent: this.truncateReadyText(summary.agent, 80) } : {}), + ...(summary.project ? { project: this.truncateReadyText(summary.project, 80) } : {}) + } return { title: headerTitle, body, - notifySummary: { - ...summary, - summary: summaryLine, - ...(summary.action - ? { action: this.truncateReadyText(summary.action, READY_BODY_GLANCE_LIMIT) } - : {}) - } + notifySummary } } @@ -235,10 +239,11 @@ export class FcmNotificationChannel implements NotificationChannel { || normalizedStatus === 'killed' || normalizedStatus === 'aborted' const path = this.buildSessionPath(session.id) + const taskSummary = this.truncateReadyText(notification.summary, READY_BODY_GLANCE_LIMIT) const payload = this.buildPayload({ title: isFailure ? 'Task failed' : 'Task completed', - body: `${agentName} · ${name} · ${notification.summary}`, + body: `${agentName} · ${name} · ${taskSummary}`, type: 'task-notification', sessionId: session.id, sessionName: name, From 80c3490dc1ea031453b8d58366fc3f09bde69190 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:01:22 +0100 Subject: [PATCH 24/39] fix(hub): FCM fetch timeouts and cap Grep/Glob permission args 10s AbortSignal.timeout on OAuth + FCM send so sequential web-push fallback is not blocked on hung Google endpoints; truncate Grep/Glob pattern in permission detail formatter. Co-authored-by: Cursor --- hub/src/fcm/fcmAuth.ts | 4 +++- hub/src/fcm/fcmNotificationChannel.test.ts | 28 ++++++++++++++++++++++ hub/src/fcm/fcmService.test.ts | 16 +++++++++++++ hub/src/fcm/fcmService.ts | 5 ++-- hub/src/notifications/toolArgs.ts | 2 +- 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/hub/src/fcm/fcmAuth.ts b/hub/src/fcm/fcmAuth.ts index e173842152..9049bb2070 100644 --- a/hub/src/fcm/fcmAuth.ts +++ b/hub/src/fcm/fcmAuth.ts @@ -8,6 +8,7 @@ export type ServiceAccount = { } const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging' +export const FCM_REQUEST_TIMEOUT_MS = 10_000 let cachedToken: { accessToken: string; expiresAtMs: number } | null = null @@ -43,7 +44,8 @@ export async function getFcmAccessToken(serviceAccount: ServiceAccount): Promise body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion - }) + }), + signal: AbortSignal.timeout(FCM_REQUEST_TIMEOUT_MS) }) if (!response.ok) { diff --git a/hub/src/fcm/fcmNotificationChannel.test.ts b/hub/src/fcm/fcmNotificationChannel.test.ts index 84ab1388eb..e0487d2ce3 100644 --- a/hub/src/fcm/fcmNotificationChannel.test.ts +++ b/hub/src/fcm/fcmNotificationChannel.test.ts @@ -123,6 +123,34 @@ describe('FcmNotificationChannel', () => { expect(dataBody).toBe(body) }) + it('truncates long Grep permission patterns for FCM data limits', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + + await channel.sendPermissionRequest(createSession({ + agentState: { + requests: { + 'req-grep': { + tool: 'Grep', + arguments: { pattern: 'P'.repeat(500), path: '/tmp' } + } + } + } + })) + + expect(sent[0].body).toContain('Pattern:') + expect(sent[0].body).toContain('...') + expect(sent[0].data.body.length).toBeLessThan(600) + }) + it('falls back gracefully when no tool args are present', async () => { const sent: FcmSendPayload[] = [] const channel = new FcmNotificationChannel( diff --git a/hub/src/fcm/fcmService.test.ts b/hub/src/fcm/fcmService.test.ts index b77e6ed127..3d6d1db7cc 100644 --- a/hub/src/fcm/fcmService.test.ts +++ b/hub/src/fcm/fcmService.test.ts @@ -141,6 +141,22 @@ describe('FcmService.sendToNamespace', () => { expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() }) + it('treats timed-out FCM send as transient failure', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async (_url, init) => { + expect(init?.signal).toBeDefined() + throw new DOMException('The operation was aborted.', 'AbortError') + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + it('counts a 200 response as sent', async () => { const store = makeStore([ { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } diff --git a/hub/src/fcm/fcmService.ts b/hub/src/fcm/fcmService.ts index 9a77ede561..b99bcc09fe 100644 --- a/hub/src/fcm/fcmService.ts +++ b/hub/src/fcm/fcmService.ts @@ -1,5 +1,5 @@ import type { Store } from '../store' -import { getFcmAccessToken, type ServiceAccount } from './fcmAuth' +import { getFcmAccessToken, FCM_REQUEST_TIMEOUT_MS, type ServiceAccount } from './fcmAuth' export type FcmDataPayload = { type: string @@ -202,7 +202,8 @@ export class FcmService { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, - body: JSON.stringify({ message }) + body: JSON.stringify({ message }), + signal: AbortSignal.timeout(FCM_REQUEST_TIMEOUT_MS) }) } catch (e) { // Network error (DNS, TCP, TLS) - transient, never a token-death signal. diff --git a/hub/src/notifications/toolArgs.ts b/hub/src/notifications/toolArgs.ts index 7c4a63a174..a8092dc6ae 100644 --- a/hub/src/notifications/toolArgs.ts +++ b/hub/src/notifications/toolArgs.ts @@ -77,7 +77,7 @@ export function formatToolArgumentsDetailed( case 'Glob': { const pattern = (a.pattern as string | undefined) ?? '' const path = (a.path as string | undefined) ?? '' - let result = `Pattern: ${pattern}` + let result = `Pattern: ${truncate(pattern, maxLen)}` if (path) result += `\nPath: ${truncate(path, 80)}` return result } From ab16803763500e60cc5dc0319efbadd386dce9d8 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:17:10 +0100 Subject: [PATCH 25/39] fix(hub): bind FCM token to one namespace on re-pair Delete stale fcm_devices rows sharing the same token when a native install registers under a different namespace. Co-authored-by: Cursor --- hub/src/store/fcmDevices.test.ts | 16 ++++++++++++++ hub/src/store/fcmDevices.ts | 37 +++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 12 deletions(-) create mode 100644 hub/src/store/fcmDevices.test.ts diff --git a/hub/src/store/fcmDevices.test.ts b/hub/src/store/fcmDevices.test.ts new file mode 100644 index 0000000000..59e25f892b --- /dev/null +++ b/hub/src/store/fcmDevices.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from './index' + +describe('fcmDevices upsert', () => { + it('moves a token to a new namespace and removes the old namespace row', () => { + const store = new Store(':memory:') + const device = { token: 'shared-token', platform: 'phone' as const, deviceId: 'pixel-1' } + + store.fcm.upsertDevice('namespace-a', device) + store.fcm.upsertDevice('namespace-b', device) + + expect(store.fcm.getDevicesByNamespace('namespace-a')).toHaveLength(0) + expect(store.fcm.getDevicesByNamespace('namespace-b')).toHaveLength(1) + expect(store.fcm.getDevicesByNamespace('namespace-b')[0].token).toBe('shared-token') + }) +}) diff --git a/hub/src/store/fcmDevices.ts b/hub/src/store/fcmDevices.ts index b21c2fe148..a451bb001e 100644 --- a/hub/src/store/fcmDevices.ts +++ b/hub/src/store/fcmDevices.ts @@ -30,24 +30,37 @@ export function upsertFcmDevice( device: { token: string; platform: 'phone' | 'wear'; deviceId: string } ): void { const now = Date.now() - db.prepare(` - INSERT INTO fcm_devices ( - namespace, token, platform, device_id, created_at, updated_at - ) VALUES ( - @namespace, @token, @platform, @device_id, @created_at, @updated_at - ) - ON CONFLICT(namespace, device_id, platform) - DO UPDATE SET - token = excluded.token, - updated_at = excluded.updated_at - `).run({ + const params = { namespace, token: device.token, platform: device.platform, device_id: device.deviceId, created_at: now, updated_at: now - }) + } + + db.transaction(() => { + // One FCM token must not deliver across namespaces. Re-pairing the + // same native install under a new namespace drops stale rows that + // still reference this token elsewhere. + db.prepare(` + DELETE FROM fcm_devices + WHERE token = @token + AND (namespace != @namespace OR device_id != @device_id OR platform != @platform) + `).run(params) + + db.prepare(` + INSERT INTO fcm_devices ( + namespace, token, platform, device_id, created_at, updated_at + ) VALUES ( + @namespace, @token, @platform, @device_id, @created_at, @updated_at + ) + ON CONFLICT(namespace, device_id, platform) + DO UPDATE SET + token = excluded.token, + updated_at = excluded.updated_at + `).run(params) + })() } export function removeFcmDeviceByToken(db: Database, namespace: string, token: string): void { From 07570547ef08e77f7d8cbc681ae79b8c521328de Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:30:31 +0100 Subject: [PATCH 26/39] fix(web): localize Companion settings and pairing copy Add en/zh-CN keys for the Companion section title and CompanionPairing strings; matches locale-driven Settings pattern (bot Minor on #803). Co-authored-by: Cursor --- .../components/settings/CompanionPairing.tsx | 17 ++++++++--------- web/src/lib/locales/en.ts | 8 ++++++++ web/src/lib/locales/zh-CN.ts | 8 ++++++++ web/src/routes/settings/index.test.tsx | 7 +++++++ web/src/routes/settings/index.tsx | 2 +- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/web/src/components/settings/CompanionPairing.tsx b/web/src/components/settings/CompanionPairing.tsx index 6a916da58e..1c1db9c66a 100644 --- a/web/src/components/settings/CompanionPairing.tsx +++ b/web/src/components/settings/CompanionPairing.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import QRCode from 'qrcode' import { Button } from '@/components/ui/button' +import { useTranslation } from '@/lib/use-translation' type CompanionPairingProps = { baseUrl: string @@ -24,6 +25,7 @@ function buildDeeplink(hub: string, code: string): string { } export function CompanionPairing({ baseUrl }: CompanionPairingProps) { + const { t } = useTranslation() const [revealed, setRevealed] = useState(false) const [copied, setCopied] = useState(false) const canvasRef = useRef(null) @@ -71,9 +73,7 @@ export function CompanionPairing({ baseUrl }: CompanionPairingProps) { if (!deeplink) { return (

- Pairing requires the original access token (CLI_API_TOKEN). It looks like - you signed in via Telegram or another flow that did not store one - paste - the token manually in the companion app instead. + {t('settings.companion.noToken')}

) } @@ -81,8 +81,7 @@ export function CompanionPairing({ baseUrl }: CompanionPairingProps) { return (

- Scan from the HAPI companion app on Android (phone or Wear OS) to bind it to this hub. - The pairing code is your access token - treat it like a password. + {t('settings.companion.description')}

{!revealed ? ( @@ -92,14 +91,14 @@ export function CompanionPairing({ baseUrl }: CompanionPairingProps) { onClick={() => setRevealed(true)} className="self-start" > - Show pairing QR + {t('settings.companion.showQr')} ) : (

diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index dcaeeb20d3..fa88a4cea2 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -640,6 +640,14 @@ export default { 'settings.voice.session.label': 'Session behavior', 'settings.voice.proactive': 'Start voice session with summary', 'settings.voice.proactive.description': 'When on, starting a voice session opens with a spoken summary of current agent activity. When off, the assistant greets you and waits for you to speak.', + 'settings.companion.title': 'Companion', + 'settings.companion.noToken': 'Pairing requires the original access token (CLI_API_TOKEN). It looks like you signed in via Telegram or another flow that did not store one — paste the token manually in the companion app instead.', + 'settings.companion.description': 'Scan from the HAPI companion app on Android (phone or Wear OS) to bind it to this hub. The pairing code is your access token — treat it like a password.', + 'settings.companion.showQr': 'Show pairing QR', + 'settings.companion.qrAriaLabel': 'Companion pairing QR code', + 'settings.companion.copyLink': 'Copy link', + 'settings.companion.copied': 'Copied!', + 'settings.companion.hide': 'Hide', 'settings.about.title': 'About', 'settings.about.website': 'Website', 'settings.about.appVersion': 'App Version', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 7508af1039..d5f384f8f9 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -644,6 +644,14 @@ export default { 'settings.voice.session.label': '会话行为', 'settings.voice.proactive': '以摘要开始语音会话', 'settings.voice.proactive.description': '开启后,启动语音会话时将朗读当前代理活动的摘要。关闭后,助手向您打招呼并等待您先开口。', + 'settings.companion.title': '伴侣应用', + 'settings.companion.noToken': '配对需要原始访问令牌(CLI_API_TOKEN)。您似乎通过 Telegram 或其他未保存令牌的流程登录 — 请在伴侣应用中手动粘贴令牌。', + 'settings.companion.description': '在 Android 手机或 Wear OS 上的 HAPI 伴侣应用中扫描,以绑定此 Hub。配对码即您的访问令牌 — 请像密码一样妥善保管。', + 'settings.companion.showQr': '显示配对二维码', + 'settings.companion.qrAriaLabel': '伴侣应用配对二维码', + 'settings.companion.copyLink': '复制链接', + 'settings.companion.copied': '已复制!', + 'settings.companion.hide': '隐藏', 'settings.about.title': '关于', 'settings.about.website': '官方网站', 'settings.about.appVersion': '应用版本', diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index cd437768b2..092c7cc654 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -216,6 +216,13 @@ describe('SettingsPage', () => { expect(calledKeys).toContain('settings.about.protocolVersion') }) + it('uses correct i18n keys for Companion section', () => { + const spyT = renderWithSpyT() + const calledKeys = spyT.mock.calls.map((call) => call[0]) + expect(calledKeys).toContain('settings.companion.title') + expect(calledKeys).toContain('settings.companion.noToken') + }) + it('renders the Appearance setting', () => { renderWithProviders() expect(screen.getAllByText('Appearance').length).toBeGreaterThanOrEqual(1) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 42e9c7491d..14d0abb85e 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -1261,7 +1261,7 @@ export default function SettingsPage() { {/* Companion section */}

- Companion + {t('settings.companion.title')}
From 6d6bf77576992d8159cd51f5530408526ced81a9 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:11:58 +0100 Subject: [PATCH 27/39] fix(hub): tighten FCM token-invalid detection and truncation edge cases Parse FCM error JSON: only UNREGISTERED or token-field INVALID_ARGUMENT unregister devices; generic NOT_FOUND stays transient. Guard limit<=3 in truncateReadyText so tiny action budgets cannot blow the glance cap. Co-authored-by: Cursor --- hub/src/fcm/fcmNotificationChannel.test.ts | 34 +++++++++++++ hub/src/fcm/fcmNotificationChannel.ts | 3 ++ hub/src/fcm/fcmService.test.ts | 38 +++++++++++++++ hub/src/fcm/fcmService.ts | 56 ++++++++++++++++------ 4 files changed, 116 insertions(+), 15 deletions(-) diff --git a/hub/src/fcm/fcmNotificationChannel.test.ts b/hub/src/fcm/fcmNotificationChannel.test.ts index e0487d2ce3..50c00e54c7 100644 --- a/hub/src/fcm/fcmNotificationChannel.test.ts +++ b/hub/src/fcm/fcmNotificationChannel.test.ts @@ -281,6 +281,40 @@ describe('FcmNotificationChannel', () => { expect(parsed.action.length).toBeLessThanOrEqual(280) }) + it('sendReady respects tiny action budget when summary fills the glance limit', async () => { + const sent: FcmSendPayload[] = [] + const summary278 = 'S'.repeat(278) + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: `Done.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"summary":"${summary278}","action":"ACTION","status":"done"}` + } + } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent[0].body.length).toBeLessThanOrEqual(280) + expect(sent[0].body).not.toContain('ACTION') + }) + it('sendReady caps auxiliary notifySummary fields for FCM data limits', async () => { const sent: FcmSendPayload[] = [] const longAgent = 'G'.repeat(200) diff --git a/hub/src/fcm/fcmNotificationChannel.ts b/hub/src/fcm/fcmNotificationChannel.ts index 1469938793..ba00427044 100644 --- a/hub/src/fcm/fcmNotificationChannel.ts +++ b/hub/src/fcm/fcmNotificationChannel.ts @@ -188,6 +188,9 @@ export class FcmNotificationChannel implements NotificationChannel { if (trimmed.length <= limit) { return trimmed } + if (limit <= 3) { + return '.'.repeat(limit) + } return trimmed.slice(0, limit - 3).trimEnd() + '...' } diff --git a/hub/src/fcm/fcmService.test.ts b/hub/src/fcm/fcmService.test.ts index 3d6d1db7cc..de03903ad5 100644 --- a/hub/src/fcm/fcmService.test.ts +++ b/hub/src/fcm/fcmService.test.ts @@ -77,6 +77,44 @@ describe('FcmService.sendToNamespace', () => { expect(store.fcm.removeDeviceByToken).toHaveBeenCalledWith('default', 'rotated-token') }) + it('keeps the device row on generic 404 NOT_FOUND (bad project/resource config)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"error":{"status":"NOT_FOUND","message":"Requested entity was not found."}}', { status: 404 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(result.invalidTokens).toEqual([]) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('keeps the device row on 400 INVALID_ARGUMENT without token field violation', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response(JSON.stringify({ + error: { + status: 'INVALID_ARGUMENT', + details: [{ + fieldViolations: [{ field: 'message.data.body', description: 'too long' }] + }] + } + }), { status: 400 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + it('keeps the device row on transient 429 (rate limit) - regression for HAPI Bot finding', async () => { const store = makeStore([ { namespace: 'default', token: 'rate-limited-token', platform: 'phone', deviceId: 'p1' } diff --git a/hub/src/fcm/fcmService.ts b/hub/src/fcm/fcmService.ts index b99bcc09fe..b0697cec93 100644 --- a/hub/src/fcm/fcmService.ts +++ b/hub/src/fcm/fcmService.ts @@ -216,24 +216,50 @@ export class FcmService { } const body = await response.text().catch(() => '') - // Per FCM v1 docs, only these signal a permanently-dead token: - // - HTTP 404 with UNREGISTERED (app uninstalled / token rotated) - // - HTTP 404 with NOT_FOUND (legacy alias) - // - HTTP 400 with INVALID_ARGUMENT against the `token` field - // (malformed; we conservatively only treat this as invalid when the - // body explicitly references the token field, otherwise we may be - // sending a malformed payload and would mis-blame the device). - // Everything else - 401 auth, 403 permission, 429 quota, 5xx server - - // is transient and devices stay registered. - const isUnregistered = response.status === 404 - && (body.includes('UNREGISTERED') || body.includes('NOT_FOUND')) - const isMalformedToken = response.status === 400 - && body.includes('INVALID_ARGUMENT') - && /token/i.test(body) - const invalid = isUnregistered || isMalformedToken + const invalid = this.isInvalidFcmTokenResponse(response.status, body) if (!invalid) { console.error('[FcmService] Send failed (transient):', response.status, body.slice(0, 200)) } return invalid ? 'invalid' : 'failed' } + + /** + * Parse FCM v1 error JSON and decide whether the token itself is dead. + * Generic 404/NOT_FOUND (bad project id, missing resource) must not + * unregister live devices — only explicit UNREGISTERED or token-field + * INVALID_ARGUMENT qualifies. + */ + private isInvalidFcmTokenResponse(status: number, body: string): boolean { + const parsedError = ((): { + error?: { + status?: string + details?: Array<{ fieldViolations?: Array<{ field?: string }> }> + } + } | null => { + try { + return JSON.parse(body) as { + error?: { + status?: string + details?: Array<{ fieldViolations?: Array<{ field?: string }> }> + } + } + } catch { + return null + } + })() + + const errorStatus = parsedError?.error?.status ?? '' + const tokenFieldViolation = parsedError?.error?.details?.some((detail) => + detail.fieldViolations?.some((violation) => + /message\.token|token/i.test(violation.field ?? '') + ) + ) ?? false + + const isUnregistered = status === 404 && errorStatus === 'UNREGISTERED' + const isMalformedToken = status === 400 + && errorStatus === 'INVALID_ARGUMENT' + && tokenFieldViolation + + return isUnregistered || isMalformedToken + } } From e0ab169073b78d012bebbcc4e22b745f7ee57716 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:25:06 +0100 Subject: [PATCH 28/39] fix(hub): parse FcmError details.errorCode for UNREGISTERED tokens FCM v1 often returns HTTP 404 with root NOT_FOUND plus details[].errorCode UNREGISTERED; prune those tokens while keeping generic project/resource NOT_FOUND transient. Co-authored-by: Cursor --- hub/src/fcm/fcmService.test.ts | 23 +++++++++++++++++++++++ hub/src/fcm/fcmService.ts | 24 ++++++++++++++++++------ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/hub/src/fcm/fcmService.test.ts b/hub/src/fcm/fcmService.test.ts index de03903ad5..d2526a47d0 100644 --- a/hub/src/fcm/fcmService.test.ts +++ b/hub/src/fcm/fcmService.test.ts @@ -93,6 +93,29 @@ describe('FcmService.sendToNamespace', () => { expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() }) + it('removes the device row on canonical 404 NOT_FOUND + FcmError UNREGISTERED', async () => { + const store = makeStore([ + { namespace: 'default', token: 'dead-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response(JSON.stringify({ + error: { + status: 'NOT_FOUND', + details: [{ + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + errorCode: 'UNREGISTERED' + }] + } + }), { status: 404 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.invalidTokens).toEqual(['dead-token']) + expect(store.fcm.removeDeviceByToken).toHaveBeenCalledWith('default', 'dead-token') + }) + it('keeps the device row on 400 INVALID_ARGUMENT without token field violation', async () => { const store = makeStore([ { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } diff --git a/hub/src/fcm/fcmService.ts b/hub/src/fcm/fcmService.ts index b0697cec93..fb0cf925e0 100644 --- a/hub/src/fcm/fcmService.ts +++ b/hub/src/fcm/fcmService.ts @@ -230,17 +230,23 @@ export class FcmService { * INVALID_ARGUMENT qualifies. */ private isInvalidFcmTokenResponse(status: number, body: string): boolean { + type FcmErrorDetail = { + '@type'?: string + errorCode?: string + fieldViolations?: Array<{ field?: string }> + } + const parsedError = ((): { error?: { status?: string - details?: Array<{ fieldViolations?: Array<{ field?: string }> }> + details?: FcmErrorDetail[] } } | null => { try { return JSON.parse(body) as { error?: { status?: string - details?: Array<{ fieldViolations?: Array<{ field?: string }> }> + details?: FcmErrorDetail[] } } } catch { @@ -249,16 +255,22 @@ export class FcmService { })() const errorStatus = parsedError?.error?.status ?? '' - const tokenFieldViolation = parsedError?.error?.details?.some((detail) => + const details = parsedError?.error?.details ?? [] + const fcmErrorCode = details.find((detail) => + detail['@type'] === 'type.googleapis.com/google.firebase.fcm.v1.FcmError' + )?.errorCode ?? '' + const tokenFieldViolation = details.some((detail) => detail.fieldViolations?.some((violation) => /message\.token|token/i.test(violation.field ?? '') ) - ) ?? false + ) - const isUnregistered = status === 404 && errorStatus === 'UNREGISTERED' + const isUnregistered = status === 404 && ( + fcmErrorCode === 'UNREGISTERED' || errorStatus === 'UNREGISTERED' + ) const isMalformedToken = status === 400 && errorStatus === 'INVALID_ARGUMENT' - && tokenFieldViolation + && (fcmErrorCode === 'INVALID_ARGUMENT' || tokenFieldViolation) return isUnregistered || isMalformedToken } From b804c6da782a249367b5d6425803f6d149666294 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:11:48 +0100 Subject: [PATCH 29/39] fix(hub): narrow types so manifest soup typecheck passes Soup-local fixup. Files are unchanged in upstream/main and typecheck passes on upstream/main, but typecheck on the layered driver/integration soup produces: hub/src/tunnel/tlsGate.ts(70,37): TS2345 string | string[] -> string hub/src/web/routes/guards.ts(46,46): TS2345 string | undefined -> string Likely cause: a layered merge resolves @types/node (or a peer) to a different transitive version that exposes the wider PeerCertificate.CN type and the Hono c.req.param() string|undefined return. Both call sites were already not-quite-safe at runtime - this layer adds the missing narrowing without changing semantics: tlsGate: typeof guard rejects multi-CN certs (was already the practical behaviour - dnsNameMatchesHost would have stringified the array and never matched any real DNS suffix). guards: returns 400 with explicit message when the route param is missing, instead of passing undefined through to requireSession where it would have produced a confusing 404 cascade. Not appropriate upstream as-is: upstream/main typecheck is clean without these changes, and the fixes are paving over a soup-only type drift, not a real bug in upstream code. Belongs in soup until the underlying transitive dep / lockfile drift is identified and either upstreamed or pinned. Co-authored-by: Cursor --- hub/src/tunnel/tlsGate.ts | 2 +- hub/src/web/routes/guards.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/hub/src/tunnel/tlsGate.ts b/hub/src/tunnel/tlsGate.ts index 8dcb46fe0d..bc7fa8a9b0 100644 --- a/hub/src/tunnel/tlsGate.ts +++ b/hub/src/tunnel/tlsGate.ts @@ -59,7 +59,7 @@ function hostMatchesCertificate(host: string, cert: PeerCertificate): boolean { } const commonName = cert.subject?.CN - if (!commonName) { + if (!commonName || typeof commonName !== 'string') { return false } diff --git a/hub/src/web/routes/guards.ts b/hub/src/web/routes/guards.ts index 9056111d6e..8e51fb51d2 100644 --- a/hub/src/web/routes/guards.ts +++ b/hub/src/web/routes/guards.ts @@ -43,6 +43,9 @@ export function requireSessionFromParam( ): { sessionId: string; session: Session } | Response { const paramName = options?.paramName ?? 'id' const sessionId = c.req.param(paramName) + if (!sessionId) { + return c.json({ error: `Missing required path parameter: ${paramName}` }, 400) + } const result = requireSession(c, engine, sessionId, { requireActive: options?.requireActive }) if (result instanceof Response) { return result From 54a6c56dd12fdfac087843b4ec2598423ad1ac24 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:39:03 +0100 Subject: [PATCH 30/39] fix(cli): Pi parse schemas for Zod 4 optional field output Soup verify: mark optional Pi model/command fields .optional() so transformed undefined does not fail object parse under Zod 4. --- cli/src/pi/schemas.ts | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/cli/src/pi/schemas.ts b/cli/src/pi/schemas.ts index 3532265af2..daae54de08 100644 --- a/cli/src/pi/schemas.ts +++ b/cli/src/pi/schemas.ts @@ -17,20 +17,30 @@ import type { PiModelSummary } from '@hapi/protocol/apiTypes'; // 字段级容错 schema // ============================================================================ -/** 提取 string 值,非 string 返回 undefined */ -const asOptStr = z.unknown().transform(v => typeof v === 'string' ? v : undefined); +/** 提取 string 值,非 string 返回 undefined(字段 optional 时省略) */ +const asOptStr = z.preprocess( + (v) => (typeof v === 'string' ? v : undefined), + z.union([z.string(), z.undefined()]), +); /** 提取 number 值,非 number 返回 undefined */ -const asOptNum = z.unknown().transform(v => typeof v === 'number' ? v : undefined); +const asOptNum = z.preprocess( + (v) => (typeof v === 'number' ? v : undefined), + z.union([z.number(), z.undefined()]), +); /** 提取 boolean 值,非 boolean 返回 undefined */ -const asOptBool = z.unknown().transform(v => typeof v === 'boolean' ? v : undefined); +const asOptBool = z.preprocess( + (v) => (typeof v === 'boolean' ? v : undefined), + z.union([z.boolean(), z.undefined()]), +); /** 提取 string 值,非 string 返回指定默认值 */ -const asStrOrDef = (def: string) => z.unknown().transform(v => typeof v === 'string' ? v : def); +const asStrOrDef = (def: string) => + z.union([z.string(), z.undefined(), z.null()]).transform(v => typeof v === 'string' ? v : def); /** 提取合法的 thinkingLevelMap,非法结构返回 undefined */ -const asOptThinkingLevelMap = z.unknown().transform((v): Record | undefined => { +const asOptThinkingLevelMap = z.preprocess((v): Record | undefined => { if (typeof v !== 'object' || v === null) return undefined; const map: Record = {}; for (const [key, val] of Object.entries(v as Record)) { @@ -38,7 +48,7 @@ const asOptThinkingLevelMap = z.unknown().transform((v): Record 0 ? map : undefined; -}); +}, z.union([z.record(z.string(), z.union([z.string(), z.null()])), z.undefined()])); // ============================================================================ // Pi Agent Event (stdin JSONL → event) @@ -78,12 +88,11 @@ const PiCommandSummarySchema = z.object({ /** 单条 command 的容错 schema:非法字段静默修正,空 name 返回 null */ const PiCommandEntrySchema = z.object({ - name: asStrOrDef(''), + name: asStrOrDef('').default(''), description: asOptStr, - source: z.unknown().transform(v => - VALID_COMMAND_SOURCES.includes(v as PiCommandSource) - ? (v as PiCommandSource) - : ('skill' as const), + source: z.preprocess( + (v) => (VALID_COMMAND_SOURCES.includes(v as PiCommandSource) ? v : 'skill'), + z.enum(VALID_COMMAND_SOURCES), ), }).passthrough().transform((c) => { if (!c.name) return null; @@ -110,8 +119,8 @@ const PiCommandsResponseDataSchema = z.object({ /** 单条 model 的容错 schema:非法字段静默丢弃,空 id 返回 null */ const PiModelEntrySchema = z.object({ - id: asStrOrDef(''), - provider: asStrOrDef('unknown'), + id: asStrOrDef('').default(''), + provider: asStrOrDef('unknown').default('unknown'), name: asOptStr, contextWindow: asOptNum, reasoning: asOptBool, @@ -195,9 +204,11 @@ export const PiAssistantMessageEventSchema = z.object({ // ============================================================================ export function parsePiCommands(data: unknown) { - return PiCommandsResponseDataSchema.safeParse(data).data ?? []; + const result = PiCommandsResponseDataSchema.safeParse(data); + return result.success ? result.data : []; } export function parsePiModels(data: unknown) { - return PiModelsResponseDataSchema.safeParse(data).data ?? []; + const result = PiModelsResponseDataSchema.safeParse(data); + return result.success ? result.data : []; } From 012bd90d616e8ab568205a4110ae9239a5b289e0 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:45:21 +0100 Subject: [PATCH 31/39] test(hub): allow same-ms updatedAt in session handler patch tests Soup verify flake: fast in-memory updates can share millisecond with prior updatedAt; contract is monotonic (>=), not strictly greater. --- hub/src/socket/handlers/cli/sessionHandlers.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts index 2ca2b5e37f..7b443ac47c 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.test.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -166,7 +166,7 @@ describe('cli session handlers', () => { lifecycleState: 'archived' }) expect(typeof data?.updatedAt).toBe('number') - expect(data?.updatedAt).toBeGreaterThan(session.updatedAt) + expect(data?.updatedAt).toBeGreaterThanOrEqual(session.updatedAt) }) it('emits a structured agentState patch on update-state RPC (closes second half of #884)', () => { @@ -211,7 +211,7 @@ describe('cli session handlers', () => { expect(data?.agentState?.version).toBe(session.agentStateVersion + 1) expect(data?.agentState?.value).toMatchObject({ controlledByUser: true }) expect(typeof data?.updatedAt).toBe('number') - expect(data?.updatedAt).toBeGreaterThan(session.updatedAt) + expect(data?.updatedAt).toBeGreaterThanOrEqual(session.updatedAt) }) it('update-metadata broadcasts the merged value, not the pre-merge payload', () => { From ea2ae4885b8cb04c06b43aaafc39e687eafacb87 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:45:26 +0100 Subject: [PATCH 32/39] feat(overseer): events substrate from AGENT_NOTIFY_SUMMARY (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist overseer events in SQLite v11 (events, event_links, FTS5), record from assistant notify summaries with hub fallbacks, expose GET /api/system-events, and add a read-only settings debug pane. Includes db-prep v11→v10 downgrade and Playwright fixture smoke. Co-authored-by: Cursor --- e2e/events-debug.spec.ts | 53 +++ hub/src/store/eventStore.ts | 38 ++ hub/src/store/events.ts | 317 +++++++++++++++++ hub/src/store/index.ts | 138 +++++++- hub/src/store/migration-v11.test.ts | 265 ++------------ .../overseerEventRecorder.injection.test.ts | 18 + hub/src/sync/overseerEventRecorder.test.ts | 103 ++++++ hub/src/sync/overseerEventRecorder.ts | 328 ++++++++++++++++++ hub/src/sync/syncEngine.ts | 59 +++- hub/src/web/routes/systemEvents.test.ts | 39 +++ hub/src/web/routes/systemEvents.ts | 48 +++ hub/src/web/server.ts | 2 + scripts/tooling/hapi-driver-db-prep.sh | 144 ++++++++ shared/src/index.ts | 1 + shared/src/overseerEvents.test.ts | 39 +++ shared/src/overseerEvents.ts | 123 +++++++ web/e2e-fixtures/events-debug-fixture.html | 18 + web/e2e-fixtures/events-debug-fixture.tsx | 82 +++++ web/src/api/client.ts | 17 + .../settings/EventsDebugControls.tsx | 123 +++++++ web/src/routes/settings/index.tsx | 2 + 21 files changed, 1724 insertions(+), 233 deletions(-) create mode 100644 e2e/events-debug.spec.ts create mode 100644 hub/src/store/eventStore.ts create mode 100644 hub/src/store/events.ts create mode 100644 hub/src/sync/overseerEventRecorder.injection.test.ts create mode 100644 hub/src/sync/overseerEventRecorder.test.ts create mode 100644 hub/src/sync/overseerEventRecorder.ts create mode 100644 hub/src/web/routes/systemEvents.test.ts create mode 100644 hub/src/web/routes/systemEvents.ts create mode 100755 scripts/tooling/hapi-driver-db-prep.sh create mode 100644 shared/src/overseerEvents.test.ts create mode 100644 shared/src/overseerEvents.ts create mode 100644 web/e2e-fixtures/events-debug-fixture.html create mode 100644 web/e2e-fixtures/events-debug-fixture.tsx create mode 100644 web/src/components/settings/EventsDebugControls.tsx diff --git a/e2e/events-debug.spec.ts b/e2e/events-debug.spec.ts new file mode 100644 index 0000000000..09ecc5596c --- /dev/null +++ b/e2e/events-debug.spec.ts @@ -0,0 +1,53 @@ +/* + * Playwright smoke for EventsDebugControls (#22). Uses the isolated vite + * fixture (no hub auth). After hapi-driver-rebuild, the same panel lives + * at Settings → Voice → Advanced on :3006. + */ + +import { test, expect } from '@playwright/test' + +test.describe('events debug viewer e2e', () => { + test('expands, loads fixture events, refresh works', async ({ page }) => { + await page.goto('/e2e-fixtures/events-debug-fixture.html') + await expect(page.getByTestId('events-debug-fixture')).toBeVisible() + + const toggle = page.getByRole('button', { name: 'Overseer events (debug)' }) + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await toggle.click() + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await expect(page.getByText('Substrate smoke event')).toBeVisible() + await expect(page.getByText('1 rows')).toBeVisible() + + await page.evaluate(() => { + window.__eventsDebugE2E!.setEvents([ + { + id: 2, + ts: Date.UTC(2026, 5, 19, 13, 0, 0), + sourceKind: 'hub', + sourceRef: null, + eventType: 'stale', + attentionCandidate: 1, + summary: 'Refreshed row', + provenance: 'session_end_fallback', + relatedSessionId: 'sess-smoke-02', + payloadJson: null, + severity: null, + }, + ]) + }) + + await page.getByRole('button', { name: 'Refresh' }).click() + await expect(page.getByText('Refreshed row')).toBeVisible() + await expect(page.getByText('attention')).toBeVisible() + }) + + test('shows error state when fetch fails', async ({ page }) => { + await page.goto('/e2e-fixtures/events-debug-fixture.html') + await page.evaluate(() => window.__eventsDebugE2E!.setError('boom')) + + const toggle = page.getByRole('button', { name: 'Overseer events (debug)' }) + await toggle.click() + await expect(page.getByText('fixture fetch failed')).toBeVisible() + }) +}) diff --git a/hub/src/store/eventStore.ts b/hub/src/store/eventStore.ts new file mode 100644 index 0000000000..c849881b2b --- /dev/null +++ b/hub/src/store/eventStore.ts @@ -0,0 +1,38 @@ +import type { Database } from 'bun:sqlite' +import { + countSystemEvents, + insertEventLink, + insertSystemEvent, + listSystemEvents, + type InsertSystemEventInput, + type ListSystemEventsOptions, + type StoredSystemEvent +} from './events' + +export type { InsertSystemEventInput, ListSystemEventsOptions, StoredSystemEvent } + +export class EventStore { + constructor(private readonly db: Database) {} + + insert(input: InsertSystemEventInput): StoredSystemEvent | null { + return insertSystemEvent(this.db, input) + } + + list(options: ListSystemEventsOptions = {}): StoredSystemEvent[] { + return listSystemEvents(this.db, options) + } + + count(): number { + return countSystemEvents(this.db) + } + + linkEvents(input: { + fromEventId: number + toEventId: number + relationType: string + createdAt: number + metadataJson?: string | null + }): string { + return insertEventLink(this.db, input) + } +} diff --git a/hub/src/store/events.ts b/hub/src/store/events.ts new file mode 100644 index 0000000000..6c03bb77fc --- /dev/null +++ b/hub/src/store/events.ts @@ -0,0 +1,317 @@ +import type { Database } from 'bun:sqlite' +import { randomUUID } from 'node:crypto' + +export type InsertSystemEventInput = { + ts: number + sourceKind: 'worker' | 'overseer' | 'operator' | 'system' | 'channel' + sourceRef?: string | null + sinkKind?: string | null + sinkRef?: string | null + eventType: string + attentionCandidate: 0 | 1 + operatorActionRequired?: 0 | 1 + riskDetected?: 0 | 1 + summary: string + payloadJson?: string | null + artifactRefs?: string | null + tags?: string | null + relatedSessionId?: string | null + relatedEventId?: number | null + dedupeKey?: string | null + expiresAt?: number | null + provenance?: string | null + idempotencyKey?: string | null + confidence?: number | null + severity?: number | null +} + +export type StoredSystemEvent = { + id: number + ts: number + sourceKind: string + sourceRef: string | null + sinkKind: string | null + sinkRef: string | null + eventType: string + attentionCandidate: number + operatorActionRequired: number + riskDetected: number + summary: string + payloadJson: string | null + artifactRefs: string | null + tags: string | null + relatedSessionId: string | null + relatedEventId: number | null + dedupeKey: string | null + expiresAt: number | null + provenance: string | null + idempotencyKey: string | null + confidence: number | null + severity: number | null +} + +export type ListSystemEventsOptions = { + limit?: number + beforeId?: number | null + sessionId?: string | null + attentionCandidate?: 0 | 1 | null + eventType?: string | null +} + +type SystemEventRow = { + id: number + ts: number + source_kind: string + source_ref: string | null + sink_kind: string | null + sink_ref: string | null + event_type: string + attention_candidate: number + operator_action_required: number + risk_detected: number + summary: string + payload_json: string | null + artifact_refs: string | null + tags: string | null + related_session_id: string | null + related_event_id: number | null + dedupe_key: string | null + expires_at: number | null + provenance: string | null + idempotency_key: string | null + confidence: number | null + severity: number | null +} + +function mapRow(row: SystemEventRow): StoredSystemEvent { + return { + id: row.id, + ts: row.ts, + sourceKind: row.source_kind, + sourceRef: row.source_ref, + sinkKind: row.sink_kind, + sinkRef: row.sink_ref, + eventType: row.event_type, + attentionCandidate: row.attention_candidate, + operatorActionRequired: row.operator_action_required, + riskDetected: row.risk_detected, + summary: row.summary, + payloadJson: row.payload_json, + artifactRefs: row.artifact_refs, + tags: row.tags, + relatedSessionId: row.related_session_id, + relatedEventId: row.related_event_id, + dedupeKey: row.dedupe_key, + expiresAt: row.expires_at, + provenance: row.provenance, + idempotencyKey: row.idempotency_key, + confidence: row.confidence, + severity: row.severity + } +} + +export function insertSystemEvent(db: Database, input: InsertSystemEventInput): StoredSystemEvent | null { + if (input.idempotencyKey) { + const existing = db.prepare( + 'SELECT id FROM events WHERE idempotency_key = ? LIMIT 1' + ).get(input.idempotencyKey) as { id: number } | undefined + if (existing) { + return getSystemEventById(db, existing.id) + } + } + + const stmt = db.prepare(` + INSERT INTO events ( + ts, source_kind, source_ref, sink_kind, sink_ref, + event_type, attention_candidate, operator_action_required, risk_detected, + summary, payload_json, artifact_refs, tags, + related_session_id, related_event_id, dedupe_key, expires_at, + provenance, idempotency_key, confidence, severity + ) VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ? + ) + `) + + const result = stmt.run( + input.ts, + input.sourceKind, + input.sourceRef ?? null, + input.sinkKind ?? null, + input.sinkRef ?? null, + input.eventType, + input.attentionCandidate, + input.operatorActionRequired ?? 0, + input.riskDetected ?? 0, + input.summary, + input.payloadJson ?? null, + input.artifactRefs ?? null, + input.tags ?? null, + input.relatedSessionId ?? null, + input.relatedEventId ?? null, + input.dedupeKey ?? null, + input.expiresAt ?? null, + input.provenance ?? null, + input.idempotencyKey ?? null, + input.confidence ?? null, + input.severity ?? null + ) + + const id = Number(result.lastInsertRowid) + return getSystemEventById(db, id) +} + +export function getSystemEventById(db: Database, id: number): StoredSystemEvent | null { + const row = db.prepare('SELECT * FROM events WHERE id = ?').get(id) as SystemEventRow | undefined + return row ? mapRow(row) : null +} + +export function listSystemEvents(db: Database, options: ListSystemEventsOptions = {}): StoredSystemEvent[] { + const limit = Math.min(Math.max(options.limit ?? 50, 1), 200) + const clauses: string[] = [] + const params: Array = [] + + if (options.sessionId) { + clauses.push('related_session_id = ?') + params.push(options.sessionId) + } + if (options.attentionCandidate !== undefined && options.attentionCandidate !== null) { + clauses.push('attention_candidate = ?') + params.push(options.attentionCandidate) + } + if (options.eventType) { + clauses.push('event_type = ?') + params.push(options.eventType) + } + if (options.beforeId) { + clauses.push('id < ?') + params.push(options.beforeId) + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + const rows = db.prepare( + `SELECT * FROM events ${where} ORDER BY id DESC LIMIT ?` + ).all(...params, limit) as SystemEventRow[] + + return rows.map(mapRow) +} + +export function insertEventLink( + db: Database, + input: { + fromEventId: number + toEventId: number + relationType: string + createdAt: number + metadataJson?: string | null + } +): string { + const id = randomUUID() + db.prepare(` + INSERT INTO event_links (id, from_event_id, to_event_id, relation_type, created_at, metadata_json) + VALUES (?, ?, ?, ?, ?, ?) + `).run( + id, + input.fromEventId, + input.toEventId, + input.relationType, + input.createdAt, + input.metadataJson ?? null + ) + return id +} + +export function countSystemEvents(db: Database): number { + const row = db.prepare('SELECT COUNT(*) AS count FROM events').get() as { count: number } + return row.count +} + +/** Reverse migration helper for v11 -> v10 (used in tests + fork db-prep). */ +export function downgradeEventsSchemaV11ToV10(db: Database): void { + db.exec(` + DROP TRIGGER IF EXISTS events_fts_delete; + DROP TRIGGER IF EXISTS events_fts_update; + DROP TRIGGER IF EXISTS events_fts_insert; + DROP TABLE IF EXISTS events_fts; + DROP INDEX IF EXISTS idx_events_idempotency_key; + DROP INDEX IF EXISTS idx_event_links_to; + DROP INDEX IF EXISTS idx_event_links_from; + DROP TABLE IF EXISTS event_links; + DROP INDEX IF EXISTS idx_events_dedupe_key; + DROP INDEX IF EXISTS idx_events_type_ts; + DROP INDEX IF EXISTS idx_events_session_ts; + DROP TABLE IF EXISTS events; + PRAGMA user_version = 10; + `) +} + +export function createEventsSchemaV11(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + source_kind TEXT NOT NULL, + source_ref TEXT, + sink_kind TEXT, + sink_ref TEXT, + event_type TEXT NOT NULL, + attention_candidate INTEGER NOT NULL DEFAULT 0, + operator_action_required INTEGER NOT NULL DEFAULT 0, + risk_detected INTEGER NOT NULL DEFAULT 0, + summary TEXT NOT NULL, + payload_json TEXT, + artifact_refs TEXT, + tags TEXT, + related_session_id TEXT REFERENCES sessions(id), + related_event_id INTEGER REFERENCES events(id), + dedupe_key TEXT, + expires_at INTEGER, + provenance TEXT, + idempotency_key TEXT, + confidence REAL, + severity INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_events_session_ts ON events(related_session_id, ts DESC); + CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(event_type, ts DESC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_events_dedupe_key ON events(dedupe_key) WHERE dedupe_key IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_events_idempotency_key ON events(idempotency_key) WHERE idempotency_key IS NOT NULL; + + CREATE TABLE IF NOT EXISTS event_links ( + id TEXT PRIMARY KEY, + from_event_id INTEGER NOT NULL REFERENCES events(id), + to_event_id INTEGER NOT NULL REFERENCES events(id), + relation_type TEXT NOT NULL, + created_at INTEGER NOT NULL, + metadata_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_event_links_from ON event_links(from_event_id); + CREATE INDEX IF NOT EXISTS idx_event_links_to ON event_links(to_event_id); + + CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( + summary, + tags, + payload_json, + tokenize = 'porter' + ); + + CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN + INSERT INTO events_fts(rowid, summary, tags, payload_json) + VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); + END; + + CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) + VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); + END; + + CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) + VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); + INSERT INTO events_fts(rowid, summary, tags, payload_json) + VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); + END; + `) +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index f986d087fc..a398f240aa 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -9,6 +9,8 @@ import { FcmStore } from './fcmStore' import { ScratchlistStore } from './scratchlistStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' +import { EventStore } from './eventStore' +import { createEventsSchemaV11 } from './events' export type { StoredMachine, @@ -28,6 +30,8 @@ export { FcmStore } from './fcmStore' export { ScratchlistStore } from './scratchlistStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' +export { EventStore } from './eventStore' +export type { InsertSystemEventInput, ListSystemEventsOptions, StoredSystemEvent } from './eventStore' const SCHEMA_VERSION: number = 11 const REQUIRED_TABLES = [ @@ -52,6 +56,7 @@ export class Store { readonly push: PushStore readonly fcm: FcmStore readonly scratchlist: ScratchlistStore + readonly events: EventStore /** * Filesystem path of the underlying SQLite database, or ':memory:' for @@ -104,6 +109,7 @@ export class Store { this.push = new PushStore(this.db) this.fcm = new FcmStore(this.db) this.scratchlist = new ScratchlistStore(this.db) + this.events = new EventStore(this.db) } close(): void { @@ -290,6 +296,71 @@ export class Store { ); CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created ON session_scratchlist(session_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + source_kind TEXT NOT NULL, + source_ref TEXT, + sink_kind TEXT, + sink_ref TEXT, + event_type TEXT NOT NULL, + attention_candidate INTEGER NOT NULL DEFAULT 0, + operator_action_required INTEGER NOT NULL DEFAULT 0, + risk_detected INTEGER NOT NULL DEFAULT 0, + summary TEXT NOT NULL, + payload_json TEXT, + artifact_refs TEXT, + tags TEXT, + related_session_id TEXT REFERENCES sessions(id), + related_event_id INTEGER REFERENCES events(id), + dedupe_key TEXT, + expires_at INTEGER, + provenance TEXT, + idempotency_key TEXT, + confidence REAL, + severity INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_events_session_ts ON events(related_session_id, ts DESC); + CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(event_type, ts DESC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_events_dedupe_key ON events(dedupe_key) WHERE dedupe_key IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_events_idempotency_key ON events(idempotency_key) WHERE idempotency_key IS NOT NULL; + + CREATE TABLE IF NOT EXISTS event_links ( + id TEXT PRIMARY KEY, + from_event_id INTEGER NOT NULL REFERENCES events(id), + to_event_id INTEGER NOT NULL REFERENCES events(id), + relation_type TEXT NOT NULL, + created_at INTEGER NOT NULL, + metadata_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_event_links_from ON event_links(from_event_id); + CREATE INDEX IF NOT EXISTS idx_event_links_to ON event_links(to_event_id); + + CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( + summary, + tags, + payload_json, + tokenize = 'porter' + ); + + CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN + INSERT INTO events_fts(rowid, summary, tags, payload_json) + VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); + END; + + CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) + VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); + END; + + CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) + VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); + INSERT INTO events_fts(rowid, summary, tags, payload_json) + VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); + END; + `) } @@ -474,7 +545,6 @@ export class Store { } private migrateFromV10ToV11(): void { - this.db.exec(` CREATE TABLE IF NOT EXISTS fcm_devices ( id INTEGER PRIMARY KEY AUTOINCREMENT, namespace TEXT NOT NULL, @@ -499,7 +569,71 @@ export class Store { ); CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created ON session_scratchlist(session_id, created_at DESC); - `) + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + source_kind TEXT NOT NULL, + source_ref TEXT, + sink_kind TEXT, + sink_ref TEXT, + event_type TEXT NOT NULL, + attention_candidate INTEGER NOT NULL DEFAULT 0, + operator_action_required INTEGER NOT NULL DEFAULT 0, + risk_detected INTEGER NOT NULL DEFAULT 0, + summary TEXT NOT NULL, + payload_json TEXT, + artifact_refs TEXT, + tags TEXT, + related_session_id TEXT REFERENCES sessions(id), + related_event_id INTEGER REFERENCES events(id), + dedupe_key TEXT, + expires_at INTEGER, + provenance TEXT, + idempotency_key TEXT, + confidence REAL, + severity INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_events_session_ts ON events(related_session_id, ts DESC); + CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(event_type, ts DESC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_events_dedupe_key ON events(dedupe_key) WHERE dedupe_key IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_events_idempotency_key ON events(idempotency_key) WHERE idempotency_key IS NOT NULL; + + CREATE TABLE IF NOT EXISTS event_links ( + id TEXT PRIMARY KEY, + from_event_id INTEGER NOT NULL REFERENCES events(id), + to_event_id INTEGER NOT NULL REFERENCES events(id), + relation_type TEXT NOT NULL, + created_at INTEGER NOT NULL, + metadata_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_event_links_from ON event_links(from_event_id); + CREATE INDEX IF NOT EXISTS idx_event_links_to ON event_links(to_event_id); + + CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( + summary, + tags, + payload_json, + tokenize = 'porter' + ); + + CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN + INSERT INTO events_fts(rowid, summary, tags, payload_json) + VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); + END; + + CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) + VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); + END; + + CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) + VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); + INSERT INTO events_fts(rowid, summary, tags, payload_json) + VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); + END; + } private getSessionColumnNames(): Set { diff --git a/hub/src/store/migration-v11.test.ts b/hub/src/store/migration-v11.test.ts index 640ef5db03..24e578925a 100644 --- a/hub/src/store/migration-v11.test.ts +++ b/hub/src/store/migration-v11.test.ts @@ -1,47 +1,25 @@ import { describe, expect, it } from 'bun:test' +import { Store } from './index' +import { downgradeEventsSchemaV11ToV10 } from './events' import { Database } from 'bun:sqlite' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { Store } from './index' - -/** - * Tests for V10→V11 schema migration: introduces the `session_scratchlist` - * typed table for tiann/hapi#893 (scratchlist v2 hub sync). Renumbered - * v10→v11 for soup integration where v10 is the fcm-push-api schema bump. - * Mirrors the pattern in `migration-v9.test.ts` (and `migration-v10.test.ts` - * for fcm). - * - * The new table is independent (no backfill from existing rows) so the - * tests focus on: - * - presence on fresh DBs - * - presence on multi-hop legacy DBs (V6/V7/V8/V9/V10 → V11) - * - idempotency on reopen - * - foreign-key cascade-delete from sessions(id) - * - the supporting (session_id, created_at) index - * - basic CRUD operations through the ScratchlistStore wrapper - */ -describe('Store V10→V11 migration: session_scratchlist table', () => { - it('fresh DB has session_scratchlist table with expected columns', () => { - const store = new Store(':memory:') - const cols = getColumns(store, 'session_scratchlist') - expect(cols).toContain('session_id') - expect(cols).toContain('entry_id') - expect(cols).toContain('text') - expect(cols).toContain('created_at') - expect(cols).toContain('updated_at') - }) - it('fresh DB has the (session_id, created_at) index', () => { +describe('Store V10→V11 migration: events substrate', () => { + it('fresh DB has events, event_links, and events_fts', () => { const store = new Store(':memory:') const db: Database = (store as unknown as { db: Database }).db - const rows = db.prepare( - "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_session_scratchlist_session_created'" + const tables = db.prepare( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table') AND name IN ('events', 'event_links', 'events_fts')" ).all() as Array<{ name: string }> - expect(rows).toHaveLength(1) + const names = new Set(tables.map((row) => row.name)) + expect(names.has('events')).toBe(true) + expect(names.has('event_links')).toBe(true) + expect(names.has('events_fts')).toBe(true) }) - it('V9 DB upgrades through ladder to V11: session_scratchlist created', () => { + it('V10 DB migrates to V11 and can insert events', () => { const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v11-test-')) const dbPath = join(dir, 'test.db') let store: Store | undefined @@ -49,204 +27,45 @@ describe('Store V10→V11 migration: session_scratchlist table', () => { const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) db.exec('PRAGMA journal_mode = WAL') db.exec('PRAGMA foreign_keys = ON') - createV9Schema(db) - db.exec('PRAGMA user_version = 9') + createV10Schema(db) + db.exec('PRAGMA user_version = 10') db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) VALUES ('s1', 'default', 1000, 1000, 0)`) db.close() store = new Store(dbPath) - const cols = getColumns(store, 'session_scratchlist') - expect(cols).toContain('session_id') - expect(cols).toContain('text') - - // Existing session row remains intact through the migration. - const sessions = (store as unknown as { db: Database }).db.prepare( - 'SELECT id FROM sessions' - ).all() as Array<{ id: string }> - expect(sessions.map((r) => r.id)).toEqual(['s1']) - } finally { - store?.close() - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('V8 DB migrates to V11 (multi-hop V8→V9→V10→V11)', () => { - const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v8-to-v11-')) - const dbPath = join(dir, 'test.db') - let store: Store | undefined - try { - const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) - db.exec('PRAGMA journal_mode = WAL') - db.exec('PRAGMA foreign_keys = ON') - createV9Schema(db) // V9 is a superset of V8's tables for our purposes - db.exec('PRAGMA user_version = 8') - db.close() - - store = new Store(dbPath) - // After ladder runs, v8→v9 (scheduled_at) and v10→v11 (scratchlist table) applied. - const messageCols = getColumns(store, 'messages') - expect(messageCols).toContain('scheduled_at') - const scratchCols = getColumns(store, 'session_scratchlist') - expect(scratchCols).toContain('entry_id') + const event = store.events.insert({ + ts: 2000, + sourceKind: 'worker', + sourceRef: 'test-agent', + eventType: 'completed', + attentionCandidate: 0, + summary: 'Migration smoke event', + relatedSessionId: 's1', + provenance: 'test' + }) + expect(event?.summary).toBe('Migration smoke event') + expect(store.events.count()).toBe(1) } finally { store?.close() rmSync(dir, { recursive: true, force: true }) } }) - it('V11 DB reopen is idempotent: schema unchanged', () => { - const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v11-idempotent-')) - const dbPath = join(dir, 'test.db') - let store1: Store | undefined - let store2: Store | undefined - try { - store1 = new Store(dbPath) - const cols1 = getColumns(store1, 'session_scratchlist') - - store2 = new Store(dbPath) - const cols2 = getColumns(store2, 'session_scratchlist') - expect(cols2).toEqual(cols1) - } finally { - store2?.close() - store1?.close() - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('cascade-delete: scratchlist entries are removed when their session is deleted', async () => { - const store = new Store(':memory:') - const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default') - const create1 = store.scratchlist.create(session.id, 'note one') - const create2 = store.scratchlist.create(session.id, 'note two') - expect(create1.outcome).toBe('created') - expect(create2.outcome).toBe('created') - expect(store.scratchlist.count(session.id)).toBe(2) - - await store.sessions.deleteSession(session.id, 'default') - expect(store.scratchlist.count(session.id)).toBe(0) - }) -}) - -describe('ScratchlistStore: CRUD through the typed-table wrapper', () => { - function setup() { - const store = new Store(':memory:') - const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default') - return { store, sessionId: session.id } - } - - it('create returns the canonical row and assigns an entryId when omitted', () => { - const { store, sessionId } = setup() - const result = store.scratchlist.create(sessionId, 'hello') - if (result.outcome !== 'created') { - throw new Error(`Expected created, got ${result.outcome}`) - } - expect(result.entry.text).toBe('hello') - expect(result.entry.entryId).toMatch(/[0-9a-f-]{8,}/) - expect(result.entry.createdAt).toBeGreaterThan(0) - expect(result.entry.updatedAt).toBe(result.entry.createdAt) - }) - - it('create preserves caller-supplied entryId and createdAt for migration path', () => { - const { store, sessionId } = setup() - const result = store.scratchlist.create(sessionId, 'migrated', { - entryId: 'legacy-id-1', - createdAt: 12345, - }) - if (result.outcome !== 'created') throw new Error(`Expected created, got ${result.outcome}`) - expect(result.entry.entryId).toBe('legacy-id-1') - expect(result.entry.createdAt).toBe(12345) - // updatedAt is always Date.now() on insert; differs from createdAt when caller supplies an old timestamp. - expect(result.entry.updatedAt).toBeGreaterThan(12345) - }) - - it('create with an existing entryId is reported as duplicate and returns the existing row', () => { - const { store, sessionId } = setup() - const first = store.scratchlist.create(sessionId, 'first', { entryId: 'dup-id' }) - if (first.outcome !== 'created') throw new Error(`Expected created`) - const second = store.scratchlist.create(sessionId, 'second', { entryId: 'dup-id' }) - if (second.outcome !== 'duplicate') { - throw new Error(`Expected duplicate, got ${second.outcome}`) - } - expect(second.entry.text).toBe('first') - }) - - it('create against a non-existent session reports session-not-found (not a SQLite error)', () => { - const store = new Store(':memory:') - const result = store.scratchlist.create('does-not-exist', 'orphan') - expect(result.outcome).toBe('session-not-found') - }) - - it('list returns entries in createdAt DESC order (newest first)', () => { - const { store, sessionId } = setup() - const a = store.scratchlist.create(sessionId, 'oldest', { entryId: 'a', createdAt: 1000 }) - const b = store.scratchlist.create(sessionId, 'middle', { entryId: 'b', createdAt: 2000 }) - const c = store.scratchlist.create(sessionId, 'newest', { entryId: 'c', createdAt: 3000 }) - expect(a.outcome).toBe('created') - expect(b.outcome).toBe('created') - expect(c.outcome).toBe('created') - const entries = store.scratchlist.list(sessionId) - expect(entries.map((e) => e.entryId)).toEqual(['c', 'b', 'a']) - }) - - it('update bumps updated_at without touching createdAt; returns null for missing entries', () => { - const { store, sessionId } = setup() - const created = store.scratchlist.create(sessionId, 'before', { - entryId: 'u1', - createdAt: 1000, - }) - if (created.outcome !== 'created') throw new Error('Expected created') - - const updated = store.scratchlist.update(sessionId, 'u1', 'after') - expect(updated).not.toBeNull() - expect(updated!.text).toBe('after') - expect(updated!.createdAt).toBe(1000) - expect(updated!.updatedAt).toBeGreaterThan(1000) - - const missing = store.scratchlist.update(sessionId, 'does-not-exist', 'noop') - expect(missing).toBeNull() - }) - - it('delete returns true when the row existed, false otherwise', () => { - const { store, sessionId } = setup() - store.scratchlist.create(sessionId, 'doomed', { entryId: 'd1' }) - expect(store.scratchlist.delete(sessionId, 'd1')).toBe(true) - expect(store.scratchlist.delete(sessionId, 'd1')).toBe(false) - }) - - it('count tracks current rows', () => { - const { store, sessionId } = setup() - expect(store.scratchlist.count(sessionId)).toBe(0) - store.scratchlist.create(sessionId, 'a', { entryId: 'a' }) - store.scratchlist.create(sessionId, 'b', { entryId: 'b' }) - expect(store.scratchlist.count(sessionId)).toBe(2) - store.scratchlist.delete(sessionId, 'a') - expect(store.scratchlist.count(sessionId)).toBe(1) - }) - - it('entries from session A are not visible to session B', () => { + it('downgrade v11 -> v10 removes events tables', () => { const store = new Store(':memory:') - const a = store.sessions.getOrCreateSession('a', { path: '/a' }, null, 'default') - const b = store.sessions.getOrCreateSession('b', { path: '/b' }, null, 'default') - store.scratchlist.create(a.id, 'A note', { entryId: 'shared-id' }) - expect(store.scratchlist.list(b.id)).toEqual([]) - expect(store.scratchlist.get(a.id, 'shared-id')).not.toBeNull() - expect(store.scratchlist.get(b.id, 'shared-id')).toBeNull() + const db: Database = (store as unknown as { db: Database }).db + downgradeEventsSchemaV11ToV10(db) + const version = db.prepare('PRAGMA user_version').get() as { user_version: number } + expect(version.user_version).toBe(10) + const events = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='events'" + ).get() + expect(events).toBeNull() }) }) -function getColumns(store: Store, table: string): string[] { - const db: Database = (store as unknown as { db: Database }).db - const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }> - return rows.map((r) => r.name) -} - -/** - * V9 schema: full pre-V10 shape. Used to seed legacy DBs to verify the - * full migration ladder (V9 → V10 fcm-devices → V11 session_scratchlist) - * preserves rows and adds tables without disturbing existing data. - */ -function createV9Schema(db: Database): void { +function createV10Schema(db: Database): void { db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, @@ -262,6 +81,7 @@ function createV9Schema(db: Database): void { model TEXT, model_reasoning_effort TEXT, effort TEXT, + service_tier TEXT, todos TEXT, todos_updated_at INTEGER, team_state TEXT, @@ -270,8 +90,6 @@ function createV9Schema(db: Database): void { active_at INTEGER, seq INTEGER DEFAULT 0 ); - CREATE INDEX IF NOT EXISTS idx_sessions_tag ON sessions(tag); - CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace); CREATE TABLE IF NOT EXISTS machines ( id TEXT PRIMARY KEY, @@ -286,7 +104,6 @@ function createV9Schema(db: Database): void { active_at INTEGER, seq INTEGER DEFAULT 0 ); - CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, @@ -299,13 +116,6 @@ function createV9Schema(db: Database): void { scheduled_at INTEGER, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq); - CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(session_id, local_id) WHERE local_id IS NOT NULL; - CREATE INDEX IF NOT EXISTS idx_messages_session_position - ON messages(session_id, COALESCE(invoked_at, created_at) DESC, seq DESC); - CREATE INDEX IF NOT EXISTS idx_messages_scheduled_pending - ON messages(scheduled_at) - WHERE scheduled_at IS NOT NULL AND invoked_at IS NULL; CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -315,8 +125,6 @@ function createV9Schema(db: Database): void { created_at INTEGER NOT NULL, UNIQUE(platform, platform_user_id) ); - CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform); - CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace); CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -327,6 +135,5 @@ function createV9Schema(db: Database): void { created_at INTEGER NOT NULL, UNIQUE(namespace, endpoint) ); - CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace); `) } diff --git a/hub/src/sync/overseerEventRecorder.injection.test.ts b/hub/src/sync/overseerEventRecorder.injection.test.ts new file mode 100644 index 0000000000..9a236a7a4f --- /dev/null +++ b/hub/src/sync/overseerEventRecorder.injection.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'bun:test' +import { shouldInjectNotifyContract } from './overseerEventRecorder' +import { AGENT_NOTIFY_CONTRACT_INLINE_PREFIX } from '@hapi/protocol' + +describe('notify contract injection', () => { + it('skips cursor flavor', () => { + expect(shouldInjectNotifyContract('cursor')).toBe(false) + }) + + it('injects for claude and codex', () => { + expect(shouldInjectNotifyContract('claude')).toBe(true) + expect(shouldInjectNotifyContract('codex')).toBe(true) + }) + + it('prefix contains AGENT_NOTIFY_SUMMARY instruction', () => { + expect(AGENT_NOTIFY_CONTRACT_INLINE_PREFIX).toContain('AGENT_NOTIFY_SUMMARY') + }) +}) diff --git a/hub/src/sync/overseerEventRecorder.test.ts b/hub/src/sync/overseerEventRecorder.test.ts new file mode 100644 index 0000000000..faf2240f2b --- /dev/null +++ b/hub/src/sync/overseerEventRecorder.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'bun:test' +import type { Session } from '@hapi/protocol/types' +import { Store } from '../store' +import { OverseerEventRecorder, toSessionSnapshot } from './overseerEventRecorder' + +function makeSession(id: string, flavor: string): Session { + return { + id, + namespace: 'default', + seq: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + active: true, + activeAt: Date.now(), + metadata: { flavor, path: '/tmp', host: 'local' }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 1, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null + } +} + +describe('OverseerEventRecorder', () => { + it('records AGENT_NOTIFY_SUMMARY from codex assistant text', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events) + const session = store.sessions.getOrCreateSession('test', { flavor: 'codex', path: '/tmp' }, null, 'default') + + const content = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'Done.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"agent":"peer","project":"demo","status":"done","action":"Review PR","summary":"Shipped fix"}' + } + } + } + + const event = recorder.onAgentMessage( + toSessionSnapshot(makeSession(session.id, 'codex')), + 'msg-1', + content, + Date.now() + ) + + expect(event?.eventType).toBe('completed') + expect(event?.attentionCandidate).toBe(1) + expect(event?.summary).toBe('Shipped fix') + expect(store.events.count()).toBe(1) + }) + + it('captures done without action as captured-only', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events) + const session = store.sessions.getOrCreateSession('test2', { flavor: 'claude', path: '/tmp' }, null, 'default') + + const content = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'AGENT_NOTIFY_SUMMARY {"version":1,"status":"done","summary":"All good","action":""}' + } + } + } + + const event = recorder.onAgentMessage( + toSessionSnapshot(makeSession(session.id, 'claude')), + 'msg-2', + content, + Date.now() + ) + + expect(event?.attentionCandidate).toBe(0) + }) + + it('synthesizes approval_requested from permission prompts', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events) + const session = store.sessions.getOrCreateSession('perm', { flavor: 'claude', path: '/tmp' }, null, 'default') + + const live = makeSession(session.id, 'claude') + live.agentState = { + requests: { + req1: { tool: 'Bash', arguments: { command: 'git push' } } + } + } + + recorder.onSessionUpdated(live) + + const events = store.events.list({ eventType: 'approval_requested' }) + expect(events).toHaveLength(1) + expect(events[0]?.attentionCandidate).toBe(1) + expect(events[0]?.summary).toContain('Bash') + }) +}) diff --git a/hub/src/sync/overseerEventRecorder.ts b/hub/src/sync/overseerEventRecorder.ts new file mode 100644 index 0000000000..6d58a66cde --- /dev/null +++ b/hub/src/sync/overseerEventRecorder.ts @@ -0,0 +1,328 @@ +import { + buildEventSummaryFromNotify, + deriveAttentionCandidate, + deriveOperatorActionRequired, + deriveSeverity, + detectEmptyHapiEventsSentinel, + detectMalformedNotifySummaryLine, + mapNotifyStatusToEventType, + OVERSEER_STALE_SILENCE_MS, + isObject, + type NotifySummary +} from '@hapi/protocol' +import { + extractAssistantPlainText, + extractNotifySummary, + unwrapRoleWrappedRecordEnvelope +} from '@hapi/protocol/messages' +import type { Session } from '@hapi/protocol/types' +import type { EventStore, InsertSystemEventInput, StoredSystemEvent } from '../store' + +type SessionSnapshot = { + id: string + flavor: string + tag: string | null + machineId: string | null +} + +function asRecord(value: unknown): Record | null { + return isObject(value) ? value as Record : null +} + +function isAgentMessageContent(content: unknown): boolean { + const record = unwrapRoleWrappedRecordEnvelope(content) + return record?.role === 'agent' +} + +function extractToolFailureSummary(content: unknown): string | null { + const record = unwrapRoleWrappedRecordEnvelope(content) + if (record?.role !== 'agent') return null + const body = record.content + if (!isObject(body)) return null + + if (body.type === 'codex') { + const data = asRecord(body.data) + if (data?.type !== 'tool-call-result') return null + const output = asRecord(data.output) + const exitCode = typeof output?.exit_code === 'number' + ? output.exit_code + : typeof output?.exitCode === 'number' + ? output.exitCode + : null + if (exitCode === null || exitCode === 0) return null + const stderr = typeof output?.stderr === 'string' ? output.stderr.trim() : '' + return stderr.length > 0 ? `Tool failed (exit ${exitCode}): ${stderr.slice(0, 160)}` : `Tool failed with exit code ${exitCode}` + } + + return null +} + +function buildTags(notify: NotifySummary | null, flavor: string): string | null { + const parts: string[] = [] + if (notify?.agent) parts.push(`agent:${notify.agent}`) + if (notify?.project) parts.push(`project:${notify.project}`) + parts.push(`flavor:${flavor}`) + return parts.length > 0 ? parts.join(' ') : null +} + +function buildPayloadJson(fields: Record): string { + return JSON.stringify(fields) +} + +export class OverseerEventRecorder { + private readonly lastAgentMessageAt = new Map() + private readonly emittedStaleSessions = new Set() + private readonly knownPermissionRequestIds = new Map>() + + constructor(private readonly events: EventStore) {} + + list(options: Parameters[0] = {}): StoredSystemEvent[] { + return this.events.list(options) + } + + count(): number { + return this.events.count() + } + + onAgentMessage(session: SessionSnapshot, messageId: string, content: unknown, ts: number): StoredSystemEvent | null { + if (!isAgentMessageContent(content)) { + return null + } + + this.lastAgentMessageAt.set(session.id, ts) + this.emittedStaleSessions.delete(session.id) + + const agentBody = unwrapRoleWrappedRecordEnvelope(content) + const agentContent = agentBody?.role === 'agent' ? agentBody.content : content + + const plainText = extractAssistantPlainText(agentContent) + if (plainText) { + if (detectEmptyHapiEventsSentinel(plainText)) { + return this.insertSystemEvent({ + ts, + sourceKind: 'system', + eventType: 'validation_error', + attentionCandidate: 0, + summary: 'Malformed HAPI_EVENTS sentinel block (empty body)', + relatedSessionId: session.id, + provenance: 'hub-inferred from empty HAPI_EVENTS sentinel pair', + idempotencyKey: `session:${session.id}:message:${messageId}:validation_error:empty_hapi_events`, + payloadJson: buildPayloadJson({ messageId, plainTextPreview: plainText.slice(0, 500) }), + severity: 1 + }) + } + + if (detectMalformedNotifySummaryLine(plainText)) { + return this.insertSystemEvent({ + ts, + sourceKind: 'system', + eventType: 'validation_error', + attentionCandidate: 0, + summary: 'Malformed AGENT_NOTIFY_SUMMARY line on last turn', + relatedSessionId: session.id, + provenance: 'hub-inferred from malformed AGENT_NOTIFY_SUMMARY JSON', + idempotencyKey: `session:${session.id}:message:${messageId}:validation_error:malformed_notify`, + payloadJson: buildPayloadJson({ messageId }), + severity: 1 + }) + } + + const notify = extractNotifySummary(plainText) + if (notify) { + return this.recordNotifySummary(session, messageId, notify, ts) + } + } + + const toolFailure = extractToolFailureSummary(agentContent) + if (toolFailure) { + return this.insertSystemEvent({ + ts, + sourceKind: 'system', + sourceRef: session.id, + eventType: 'failed', + attentionCandidate: 1, + operatorActionRequired: 1, + summary: toolFailure, + relatedSessionId: session.id, + provenance: 'hub-inferred from tool-call-result exit code', + idempotencyKey: `session:${session.id}:message:${messageId}:tool_failed`, + payloadJson: buildPayloadJson({ messageId }), + severity: deriveSeverity('failed'), + tags: buildTags(null, session.flavor) + }) + } + + return null + } + + onSessionUpdated(session: Session): void { + this.syncPermissionRequests(session) + } + + onSessionEnd( + session: Session, + ts: number, + reason: string | undefined, + getLastAgentPlainText: () => string | null + ): StoredSystemEvent | null { + this.emittedStaleSessions.delete(session.id) + this.knownPermissionRequestIds.delete(session.id) + + if (reason !== 'completed') { + return null + } + + const lastText = getLastAgentPlainText() + if (lastText && extractNotifySummary(lastText)) { + return null + } + + const flavor = session.metadata?.flavor ?? 'claude' + return this.insertSystemEvent({ + ts, + sourceKind: 'system', + sourceRef: session.id, + eventType: 'completed', + attentionCandidate: 0, + summary: 'Session ended without AGENT_NOTIFY_SUMMARY; hub inferred completion', + relatedSessionId: session.id, + provenance: 'hub-inferred from session-end completed signal', + idempotencyKey: `session:${session.id}:session_end:${ts}:completed_fallback`, + payloadJson: buildPayloadJson({ reason }), + severity: deriveSeverity('completed'), + tags: buildTags(null, flavor) + }) + } + + checkStaleSessions(activeSessions: Session[], now: number = Date.now()): StoredSystemEvent[] { + const emitted: StoredSystemEvent[] = [] + for (const session of activeSessions) { + if (!session.active) continue + if (this.emittedStaleSessions.has(session.id)) continue + + const requests = session.agentState?.requests + if (requests && Object.keys(requests).length > 0) { + continue + } + + const lastAt = this.lastAgentMessageAt.get(session.id) ?? session.activeAt ?? session.updatedAt + if (now - lastAt < OVERSEER_STALE_SILENCE_MS) { + continue + } + + const flavor = session.metadata?.flavor ?? 'claude' + const event = this.insertSystemEvent({ + ts: now, + sourceKind: 'system', + sourceRef: session.id, + eventType: 'stale', + attentionCandidate: 1, + operatorActionRequired: 0, + summary: `No agent output for ${Math.round((now - lastAt) / 60_000)} minutes`, + relatedSessionId: session.id, + provenance: 'hub-inferred from session silence threshold', + idempotencyKey: `session:${session.id}:stale:${Math.floor(lastAt / OVERSEER_STALE_SILENCE_MS)}`, + payloadJson: buildPayloadJson({ lastAgentMessageAt: lastAt, thresholdMs: OVERSEER_STALE_SILENCE_MS }), + severity: deriveSeverity('stale'), + tags: buildTags(null, flavor) + }) + if (event) { + this.emittedStaleSessions.add(session.id) + emitted.push(event) + } + } + return emitted + } + + seedLastAgentMessageAt(sessionId: string, ts: number): void { + this.lastAgentMessageAt.set(sessionId, ts) + } + + private recordNotifySummary( + session: SessionSnapshot, + messageId: string, + notify: NotifySummary, + ts: number + ): StoredSystemEvent | null { + const eventType = mapNotifyStatusToEventType(notify.status) + const attentionCandidate = deriveAttentionCandidate(notify.status, notify.action) + const operatorActionRequired = deriveOperatorActionRequired(notify.status, notify.action) + const sourceRef = notify.agent ?? notify.project ?? session.tag ?? session.id + + return this.insertSystemEvent({ + ts, + sourceKind: 'worker', + sourceRef, + eventType, + attentionCandidate, + operatorActionRequired, + summary: buildEventSummaryFromNotify(notify), + relatedSessionId: session.id, + provenance: 'AGENT_NOTIFY_SUMMARY', + idempotencyKey: `session:${session.id}:message:${messageId}:notify`, + payloadJson: buildPayloadJson({ + messageId, + notify_summary: notify, + suggested_action: notify.action ?? null + }), + severity: deriveSeverity(eventType), + tags: buildTags(notify, session.flavor) + }) + } + + private syncPermissionRequests(session: Session): void { + const requests = session.agentState?.requests ?? null + if (!requests) { + this.knownPermissionRequestIds.delete(session.id) + return + } + + const currentIds = new Set(Object.keys(requests)) + const known = this.knownPermissionRequestIds.get(session.id) ?? new Set() + + for (const requestId of currentIds) { + if (known.has(requestId)) continue + const request = asRecord(requests[requestId]) + const toolName = typeof request?.tool === 'string' ? request.tool : 'tool' + const summary = `Permission requested: ${toolName}` + const flavor = session.metadata?.flavor ?? 'claude' + this.insertSystemEvent({ + ts: Date.now(), + sourceKind: 'system', + sourceRef: session.id, + eventType: 'approval_requested', + attentionCandidate: 1, + operatorActionRequired: 1, + summary, + relatedSessionId: session.id, + provenance: 'hub-inferred from permission prompt', + idempotencyKey: `session:${session.id}:permission:${requestId}`, + payloadJson: buildPayloadJson({ requestId, request }), + severity: deriveSeverity('approval_requested'), + tags: buildTags(null, flavor) + }) + } + + this.knownPermissionRequestIds.set(session.id, currentIds) + } + + private insertSystemEvent(input: Omit & { riskDetected?: 0 | 1 }): StoredSystemEvent | null { + return this.events.insert({ + riskDetected: 0, + ...input + }) + } +} + +export function toSessionSnapshot(session: Session): SessionSnapshot { + return { + id: session.id, + flavor: session.metadata?.flavor ?? 'claude', + tag: session.metadata?.name ?? session.metadata?.path ?? null, + machineId: null + } +} + +export function shouldInjectNotifyContract(flavor: string | undefined | null): boolean { + return flavor !== 'cursor' +} diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index ee0137970d..05c294e615 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -10,7 +10,8 @@ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol' import type { CursorMigrateOutcome, CursorMigrateToAcpRequest, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' -import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' +import { unwrapRoleWrappedRecordEnvelope, extractAssistantPlainText } from '@hapi/protocol/messages' +import { AGENT_NOTIFY_CONTRACT_INLINE_PREFIX } from '@hapi/protocol' import type { Server } from 'socket.io' import type { Store, CancelQueuedMessageResult } from '../store' import type { HapiSessionExportResult } from '@hapi/protocol/sessionExport' @@ -41,6 +42,8 @@ import { type RpcUploadFileResponse } from './rpcGateway' import { SessionCache } from './sessionCache' +import { OverseerEventRecorder, shouldInjectNotifyContract, toSessionSnapshot } from './overseerEventRecorder' +import type { ListSystemEventsOptions, StoredSystemEvent } from '../store' export type { Session, SyncEvent } from '@hapi/protocol/types' export type { Machine } from './machineCache' @@ -135,6 +138,7 @@ export class SyncEngine { private readonly machineCache: MachineCache private readonly messageService: MessageService private readonly rpcGateway: RpcGateway + private readonly overseerEvents: OverseerEventRecorder private inactivityTimer: NodeJS.Timeout | null = null /** Sessions that emitted `session-ready` (Cursor ACP load/newSession complete). */ private readonly sessionReadyIds = new Set() @@ -155,6 +159,7 @@ export class SyncEngine { (sessionId, updatedAt) => this.recordSessionActivity(sessionId, updatedAt) ) this.rpcGateway = new RpcGateway(io, rpcRegistry) + this.overseerEvents = new OverseerEventRecorder(store.events) this.reloadAll() this.inactivityTimer = setInterval(() => this.expireInactive(), 5_000) } @@ -316,14 +321,19 @@ export class SyncEngine { // legacy refresh-from-DB-and-broadcast path. this.sessionCache.refreshSession(event.sessionId) const after = this.sessionCache.getSession(event.sessionId) + if (after) { + this.overseerEvents.onSessionUpdated(after) + } if (after?.metadata && !this.hasSameAgentSessionIds(beforeMetadata, after.metadata)) { if (!this.canRunCursorDedup(after)) { + this.eventPublisher.emit(event) return } void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => { // best-effort: dedup failure is harmless, web-side safety net hides remaining duplicates }) } + this.eventPublisher.emit(event) return } @@ -336,11 +346,37 @@ export class SyncEngine { if (!this.getSession(event.sessionId)) { this.sessionCache.refreshSession(event.sessionId) } + const session = this.getSession(event.sessionId) + if (session && event.message) { + this.overseerEvents.onAgentMessage( + toSessionSnapshot(session), + event.message.id, + event.message.content, + event.message.createdAt + ) + } } this.eventPublisher.emit(event) } + getSystemEvents(options: ListSystemEventsOptions = {}): StoredSystemEvent[] { + return this.overseerEvents.list(options) + } + + getSystemEventCount(): number { + return this.overseerEvents.count() + } + + private getLastAgentPlainText(sessionId: string): string | null { + const messages = this.store.messages.getMessages(sessionId, 80) + for (let i = messages.length - 1; i >= 0; i -= 1) { + const text = extractAssistantPlainText(messages[i].content) + if (text) return text + } + return null + } + handleSessionAlive(payload: { sid: string time: number @@ -373,6 +409,15 @@ export class SyncEngine { const shouldRetryDedup = !isCursorAcp || this.sessionReadyIds.has(payload.sid) this.sessionCache.handleSessionEnd(payload) + const session = this.getSession(payload.sid) + if (session) { + this.overseerEvents.onSessionEnd( + session, + payload.time, + payload.reason, + () => this.getLastAgentPlainText(session.id) + ) + } this.eventPublisher.emit({ type: 'session-ended', sessionId: payload.sid, @@ -518,6 +563,7 @@ export class SyncEngine { this.triggerDedupIfNeeded(session.id) } this.machineCache.expireInactive() + this.overseerEvents.checkStaleSessions(this.sessionCache.getSessions()) // Piggybacked on the inactivity tick; not a logical part of expireInactive // but shares its 5s cadence (avoids a second timer). this.messageService.releaseMatureScheduledMessages(Date.now()) @@ -561,7 +607,16 @@ export class SyncEngine { scheduledAt?: number | null } ): Promise { - await this.messageService.sendMessage(sessionId, payload) + const session = this.getSession(sessionId) + const flavor = session?.metadata?.flavor ?? 'claude' + const text = payload.text && shouldInjectNotifyContract(flavor) + ? `${AGENT_NOTIFY_CONTRACT_INLINE_PREFIX}${payload.text}` + : payload.text + + await this.messageService.sendMessage(sessionId, { + ...payload, + text + }) this.sessionCache.markMessageQueued(sessionId) this.sessionCache.recordSessionActivity(sessionId, Date.now()) } diff --git a/hub/src/web/routes/systemEvents.test.ts b/hub/src/web/routes/systemEvents.test.ts new file mode 100644 index 0000000000..f47711d030 --- /dev/null +++ b/hub/src/web/routes/systemEvents.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { Store } from '../../store' +import { SyncEngine } from '../../sync/syncEngine' +import { RpcRegistry } from '../../socket/rpcRegistry' +import { createSystemEventsRoutes } from './systemEvents' + +describe('systemEvents routes', () => { + it('lists persisted events', async () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('evt-test', { flavor: 'codex', path: '/tmp' }, null, 'default') + store.events.insert({ + ts: Date.now(), + sourceKind: 'worker', + sourceRef: 'peer', + eventType: 'completed', + attentionCandidate: 1, + summary: 'Route smoke event', + relatedSessionId: session.id, + provenance: 'test' + }) + + const io = { of: () => ({ to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }) }) } as never + const engine = new SyncEngine(store, io, new RpcRegistry(), { broadcast: () => {} } as never) + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSystemEventsRoutes(() => engine)) + + const res = await app.request('/api/system-events?limit=5') + expect(res.status).toBe(200) + const body = await res.json() as { total: number; events: Array<{ summary: string }> } + expect(body.total).toBe(1) + expect(body.events[0]?.summary).toBe('Route smoke event') + }) +}) diff --git a/hub/src/web/routes/systemEvents.ts b/hub/src/web/routes/systemEvents.ts new file mode 100644 index 0000000000..7bb4b2bd32 --- /dev/null +++ b/hub/src/web/routes/systemEvents.ts @@ -0,0 +1,48 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSyncEngine } from './guards' + +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + beforeId: z.coerce.number().int().positive().optional(), + sessionId: z.string().min(1).optional(), + attentionCandidate: z.enum(['0', '1']).optional(), + eventType: z.string().min(1).optional() +}) + +export function createSystemEventsRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + app.get('/system-events', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const parsed = querySchema.safeParse(c.req.query()) + if (!parsed.success) { + return c.json({ error: 'Invalid query', issues: parsed.error.flatten() }, 400) + } + + const attentionCandidate = parsed.data.attentionCandidate === undefined + ? null + : parsed.data.attentionCandidate === '1' ? 1 : 0 + + const events = engine.getSystemEvents({ + limit: parsed.data.limit ?? 50, + beforeId: parsed.data.beforeId ?? null, + sessionId: parsed.data.sessionId ?? null, + attentionCandidate, + eventType: parsed.data.eventType ?? null + }) + + return c.json({ + total: engine.getSystemEventCount(), + events + }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 932010dae1..012b54070a 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -26,6 +26,7 @@ import { createCursorImportRoutes } from './routes/cursorImport' import { createPushRoutes } from './routes/push' import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' +import { createSystemEventsRoutes } from './routes/systemEvents' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer, ServerWebSocket } from 'bun' @@ -261,6 +262,7 @@ function createWebApp(options: { app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) app.route('/api', createDevicesRoutes(options.store)) app.route('/api', createVoiceRoutes()) + app.route('/api', createSystemEventsRoutes(options.getSyncEngine)) // Skip static serving in relay mode, show helpful message on root if (options.relayMode) { diff --git a/scripts/tooling/hapi-driver-db-prep.sh b/scripts/tooling/hapi-driver-db-prep.sh new file mode 100755 index 0000000000..b42706444d --- /dev/null +++ b/scripts/tooling/hapi-driver-db-prep.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Prepare ~/.hapi/hapi.db for activation of . +# +# Operator-fork soup helper (not used by upstream CI). See driver-soup.md. +# +# Known downgrade transitions (extend as new schema-bumping layers are added): +# v10 -> v9 : DROP TABLE fcm_devices + its 2 indexes +# v11 -> v10: DROP events/event_links/FTS5 (feat/overseer-events-substrate #22) + +set -euo pipefail + +DRY_RUN=0 +TARGET="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + -h|--help) sed -n '2,12p' "$0"; exit 0 ;; + *) + if [[ -z "$TARGET" ]]; then TARGET="$1"; shift + else echo "Unexpected arg: $1" >&2; exit 2; fi + ;; + esac +done + +[[ -n "$TARGET" ]] || { echo "Usage: hapi-driver-db-prep.sh " >&2; exit 2; } +TARGET="$(realpath "$TARGET")" + +PRIMARY="${HAPI_PRIMARY:-$HOME/coding/hapi}" +DB_PATH="${HAPI_DB_PATH:-$HOME/.hapi/hapi.db}" + +[[ -f "$DB_PATH" ]] || { echo "ERROR: DB not found at $DB_PATH" >&2; exit 1; } +[[ -f "$TARGET/hub/src/store/index.ts" ]] || { + echo "ERROR: target $TARGET missing hub/src/store/index.ts" >&2 + exit 1 +} + +extract_schema_version() { + grep -E "^const SCHEMA_VERSION:\s*number\s*=\s*[0-9]+" "$1" \ + | head -1 \ + | grep -oE "[0-9]+$" +} + +target_schema="$(extract_schema_version "$TARGET/hub/src/store/index.ts" || true)" +[[ -n "$target_schema" ]] || { echo "ERROR: could not parse SCHEMA_VERSION in $TARGET" >&2; exit 1; } + +base_schema="$(git -C "$PRIMARY" show upstream/main:hub/src/store/index.ts 2>/dev/null \ + | grep -E "^const SCHEMA_VERSION:\s*number\s*=\s*[0-9]+" | head -1 | grep -oE "[0-9]+$" || true)" +base_schema="${base_schema:-?}" + +live_schema="$(sqlite3 "$DB_PATH" "PRAGMA user_version;" 2>/dev/null || true)" +[[ -n "$live_schema" ]] || { echo "ERROR: could not read PRAGMA user_version from $DB_PATH" >&2; exit 1; } + +echo "hapi-driver-db-prep:" +echo " target_schema = $target_schema (from $TARGET/hub/src/store/index.ts)" +echo " base_schema = $base_schema (from upstream/main)" +echo " live_schema = $live_schema (from $DB_PATH)" + +if [[ "$live_schema" -eq "$target_schema" ]]; then + decision="match -- no migration needed" +elif [[ "$live_schema" -lt "$target_schema" ]]; then + decision="forward -- hub will auto-migrate $live_schema -> $target_schema via stepMigrations on boot" +else + decision="downgrade -- need to step DB back from $live_schema down to $target_schema" +fi +echo " decision: $decision" + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo " (dry-run; exiting)" + exit 0 +fi + +if systemctl is-active --quiet hapi-hub.service; then + echo "ERROR: hapi-hub.service is active. Stop it first:" >&2 + echo " sudo systemctl stop hapi-hub.service" >&2 + exit 1 +fi + +if [[ "${HAPI_DB_PREP_NO_BACKUP:-}" != "1" ]]; then + BACKUP="${DB_PATH}.bak.pre-activate-$(date -u +%Y%m%d-%H%M%SZ)" + echo " backup: $DB_PATH -> $BACKUP" + cp -a "$DB_PATH" "$BACKUP" +fi + +apply_downgrade_step() { + local from="$1" to="$2" + case "${from}_to_${to}" in + 10_to_9) + echo " applying v10 -> v9 downgrade: DROP fcm_devices + indexes" + sqlite3 "$DB_PATH" <<'SQL' +BEGIN IMMEDIATE; +DROP INDEX IF EXISTS idx_fcm_devices_token; +DROP INDEX IF EXISTS idx_fcm_devices_namespace; +DROP TABLE IF EXISTS fcm_devices; +PRAGMA user_version = 9; +COMMIT; +SQL + ;; + 11_to_10) + echo " applying v11 -> v10 downgrade: DROP events substrate + FTS5" + sqlite3 "$DB_PATH" <<'SQL' +BEGIN IMMEDIATE; +DROP TRIGGER IF EXISTS events_fts_delete; +DROP TRIGGER IF EXISTS events_fts_update; +DROP TRIGGER IF EXISTS events_fts_insert; +DROP TABLE IF EXISTS events_fts; +DROP INDEX IF EXISTS idx_events_idempotency_key; +DROP INDEX IF EXISTS idx_event_links_to; +DROP INDEX IF EXISTS idx_event_links_from; +DROP TABLE IF EXISTS event_links; +DROP INDEX IF EXISTS idx_events_dedupe_key; +DROP INDEX IF EXISTS idx_events_type_ts; +DROP INDEX IF EXISTS idx_events_session_ts; +DROP TABLE IF EXISTS events; +PRAGMA user_version = 10; +COMMIT; +SQL + ;; + *) + echo "ERROR: no known downgrade for v${from} -> v${to}" >&2 + echo " Add a case to apply_downgrade_step() in $0" >&2 + echo " OR restore from a v${to} backup manually" >&2 + return 1 + ;; + esac +} + +if [[ "$live_schema" -gt "$target_schema" ]]; then + cur="$live_schema" + while [[ "$cur" -gt "$target_schema" ]]; do + prev=$((cur - 1)) + apply_downgrade_step "$cur" "$prev" || exit 1 + cur="$prev" + done + new_live="$(sqlite3 "$DB_PATH" "PRAGMA user_version;")" + if [[ "$new_live" -ne "$target_schema" ]]; then + echo "ERROR: downgrade left DB at v${new_live}, expected v${target_schema}" >&2 + exit 1 + fi + echo " downgrade done: DB now at v${new_live}" + sqlite3 "$DB_PATH" "VACUUM;" +fi + +echo " db-prep complete; safe to start hub on $TARGET" diff --git a/shared/src/index.ts b/shared/src/index.ts index e68f237c0a..6e47f664e4 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -2,6 +2,7 @@ export * from './agentBudget' export * from './apiTypes' export * from './cursorCliSku' export * from './messages' +export * from './overseerEvents' export * from './buildInfo' export * from './effort' export * from './flavors' diff --git a/shared/src/overseerEvents.test.ts b/shared/src/overseerEvents.test.ts new file mode 100644 index 0000000000..42f79f9013 --- /dev/null +++ b/shared/src/overseerEvents.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test' +import { + AGENT_NOTIFY_CONTRACT_INLINE_PREFIX, + deriveAttentionCandidate, + mapNotifyStatusToEventType, + buildEventSummaryFromNotify, + detectEmptyHapiEventsSentinel, + HAPI_EVENTS_BEGIN, + HAPI_EVENTS_END +} from './overseerEvents' + +describe('overseerEvents mapping', () => { + test('maps notify status to event_type', () => { + expect(mapNotifyStatusToEventType('done')).toBe('completed') + expect(mapNotifyStatusToEventType('stalled')).toBe('stale') + expect(mapNotifyStatusToEventType('blocked')).toBe('blocked') + }) + + test('derives attention_candidate from status and action', () => { + expect(deriveAttentionCandidate('blocked')).toBe(1) + expect(deriveAttentionCandidate('done', '')).toBe(0) + expect(deriveAttentionCandidate('done', 'Merge PR')).toBe(1) + expect(deriveAttentionCandidate('needs_decision')).toBe(1) + }) + + test('buildEventSummaryFromNotify prefers summary', () => { + expect(buildEventSummaryFromNotify({ summary: 'Shipped fix', action: 'Review' })).toBe('Shipped fix') + }) + + test('detectEmptyHapiEventsSentinel finds empty sentinel pair', () => { + const text = `prose\n${HAPI_EVENTS_BEGIN}${HAPI_EVENTS_END}` + expect(detectEmptyHapiEventsSentinel(text)).toBe(true) + }) + + test('contract prefix is non-empty', () => { + expect(AGENT_NOTIFY_CONTRACT_INLINE_PREFIX.length).toBeGreaterThan(40) + expect(AGENT_NOTIFY_CONTRACT_INLINE_PREFIX).toContain('AGENT_NOTIFY_SUMMARY') + }) +}) diff --git a/shared/src/overseerEvents.ts b/shared/src/overseerEvents.ts new file mode 100644 index 0000000000..e5c439805c --- /dev/null +++ b/shared/src/overseerEvents.ts @@ -0,0 +1,123 @@ +import type { NotifySummary } from './messages' + +export const NOTIFY_SUMMARY_STATUSES = [ + 'done', + 'blocked', + 'needs_review', + 'needs_decision', + 'failed', + 'stalled' +] as const + +export type NotifySummaryStatus = typeof NOTIFY_SUMMARY_STATUSES[number] + +/** Inline prefix injected for non-Cursor flavors on outbound user messages (#20). */ +export const AGENT_NOTIFY_CONTRACT_INLINE_PREFIX = [ + 'End every response with one machine-parseable line (no backticks):', + 'AGENT_NOTIFY_SUMMARY {"version":1,"agent":"","project":"","status":"done|blocked|needs_review|needs_decision|failed|stalled","action":"<=12 words","summary":"one-line triage"}', + 'Use blocked if unsure. action must be concrete when status is done and follow-up is needed.', + '', + '---', + '' +].join('\n') + +export const HAPI_EVENTS_BEGIN = '' +export const HAPI_EVENTS_END = '' + +export const OVERSEER_STALE_SILENCE_MS = 30 * 60 * 1000 + +export function mapNotifyStatusToEventType(status: string | undefined): string { + switch (status) { + case 'done': + return 'completed' + case 'blocked': + return 'blocked' + case 'needs_review': + return 'needs_review' + case 'needs_decision': + return 'needs_decision' + case 'failed': + return 'failed' + case 'stalled': + return 'stale' + default: + return 'progress' + } +} + +export function deriveAttentionCandidate(status: string | undefined, action?: string): 0 | 1 { + switch (status) { + case 'needs_decision': + case 'blocked': + case 'failed': + case 'needs_review': + case 'stalled': + return 1 + case 'done': + return action && action.trim().length > 0 ? 1 : 0 + default: + return 0 + } +} + +export function deriveOperatorActionRequired(status: string | undefined, action?: string): 0 | 1 { + return deriveAttentionCandidate(status, action) +} + +export function deriveSeverity(eventType: string): number { + switch (eventType) { + case 'approval_requested': + case 'permission_request': + case 'needs_decision': + return 5 + case 'blocked': + case 'failed': + return 4 + case 'needs_review': + case 'stale': + return 3 + case 'completed': + return 2 + default: + return 1 + } +} + +export function buildEventSummaryFromNotify(notify: NotifySummary): string { + const summary = notify.summary?.trim() + if (summary) return summary + const action = notify.action?.trim() + if (action) return action + const status = notify.status?.trim() + if (status) return `Agent status: ${status}` + return 'Agent turn summary' +} + +export function detectEmptyHapiEventsSentinel(text: string): boolean { + const pattern = new RegExp( + `${escapeRegExp(HAPI_EVENTS_BEGIN)}\\s*${escapeRegExp(HAPI_EVENTS_END)}`, + 'm' + ) + return pattern.test(text) +} + +export function detectMalformedNotifySummaryLine(text: string): boolean { + const lines = text.split('\n') + let lastIdx = lines.length - 1 + while (lastIdx >= 0 && lines[lastIdx].trim() === '') lastIdx -= 1 + if (lastIdx < 0) return false + const lastLine = lines[lastIdx].trim() + if (!lastLine.startsWith('AGENT_NOTIFY_SUMMARY ')) return false + const jsonPart = lastLine.slice('AGENT_NOTIFY_SUMMARY '.length).trim() + if (!jsonPart.startsWith('{')) return true + try { + JSON.parse(jsonPart) + return false + } catch { + return true + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/web/e2e-fixtures/events-debug-fixture.html b/web/e2e-fixtures/events-debug-fixture.html new file mode 100644 index 0000000000..bfe0bce75e --- /dev/null +++ b/web/e2e-fixtures/events-debug-fixture.html @@ -0,0 +1,18 @@ + + + + + + HAPI events debug e2e fixture + + + +
+ + + diff --git a/web/e2e-fixtures/events-debug-fixture.tsx b/web/e2e-fixtures/events-debug-fixture.tsx new file mode 100644 index 0000000000..dfe3110b59 --- /dev/null +++ b/web/e2e-fixtures/events-debug-fixture.tsx @@ -0,0 +1,82 @@ +/* + * Standalone fixture for EventsDebugControls Playwright smoke (#22). + * Mocks ApiClient.fetchSystemEvents so the panel renders without hub auth. + */ + +import React from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { AppContextProvider } from '../src/lib/app-context' +import { EventsDebugControls } from '../src/components/settings/EventsDebugControls' +import type { ApiClient } from '../src/api/client' + +declare global { + interface Window { + __eventsDebugE2E?: { + setEvents(events: Array<{ + id: number + ts: number + sourceKind: string + sourceRef: string | null + eventType: string + attentionCandidate: number + summary: string + provenance: string | null + relatedSessionId: string | null + payloadJson: string | null + severity: number | null + }>): void + setError(message: string | null): void + } + } +} + +const sampleEvents = [ + { + id: 1, + ts: Date.UTC(2026, 5, 19, 12, 0, 0), + sourceKind: 'agent', + sourceRef: 'cursor', + eventType: 'completed', + attentionCandidate: 0, + summary: 'Substrate smoke event', + provenance: 'notify_summary', + relatedSessionId: 'sess-smoke-01', + payloadJson: null, + severity: null, + }, +] + +function createMockApi(): ApiClient { + let events = [...sampleEvents] + let shouldFail = false + + const api = { + async fetchSystemEvents() { + if (shouldFail) { + throw new Error('fixture fetch failed') + } + return { total: events.length, events } + }, + } as unknown as ApiClient + + window.__eventsDebugE2E = { + setEvents(next) { + events = next + }, + setError(message) { + shouldFail = message !== null + }, + } + + return api +} + +const root = ReactDOM.createRoot(document.getElementById('root')!) +root.render( + +
+ +
+
+) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 797c9ff605..ab8618b6af 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -857,4 +857,21 @@ export class ApiClient { body: JSON.stringify({}) }) } + + async fetchSystemEvents(params: { + limit?: number + beforeId?: number + sessionId?: string + attentionCandidate?: 0 | 1 + eventType?: string + } = {}): Promise<{ total: number; events: unknown[] }> { + const query = new URLSearchParams() + if (params.limit !== undefined) query.set('limit', String(params.limit)) + if (params.beforeId !== undefined) query.set('beforeId', String(params.beforeId)) + if (params.sessionId) query.set('sessionId', params.sessionId) + if (params.attentionCandidate !== undefined) query.set('attentionCandidate', String(params.attentionCandidate)) + if (params.eventType) query.set('eventType', params.eventType) + const suffix = query.toString() + return await this.request(`/api/system-events${suffix ? `?${suffix}` : ''}`) + } } diff --git a/web/src/components/settings/EventsDebugControls.tsx b/web/src/components/settings/EventsDebugControls.tsx new file mode 100644 index 0000000000..3ec1619717 --- /dev/null +++ b/web/src/components/settings/EventsDebugControls.tsx @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAppContext } from '@/lib/app-context' + +export type SystemEventRow = { + id: number + ts: number + sourceKind: string + sourceRef: string | null + eventType: string + attentionCandidate: number + summary: string + provenance: string | null + relatedSessionId: string | null + payloadJson: string | null + severity: number | null +} + +type SystemEventsResponse = { + total: number + events: SystemEventRow[] +} + +function formatTs(ts: number): string { + return new Date(ts).toLocaleString() +} + +export function EventsDebugControls() { + const { api } = useAppContext() + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [total, setTotal] = useState(0) + const [events, setEvents] = useState([]) + + const refresh = useCallback(async () => { + if (!api) { + setError('Not authenticated') + return + } + setLoading(true) + setError(null) + try { + const data = await api.fetchSystemEvents({ limit: 80 }) as SystemEventsResponse + setTotal(data.total) + setEvents(data.events) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load events') + } finally { + setLoading(false) + } + }, [api]) + + useEffect(() => { + if (open) { + void refresh() + } + }, [open, refresh]) + + return ( +
+ + {open && ( +
+
+

+ Read-only substrate feed (#22). Populated from AGENT_NOTIFY_SUMMARY + hub fallback. +

+ +
+ {error ? ( +

{error}

+ ) : null} +
+ {events.length === 0 ? ( +

No events yet.

+ ) : ( +
    + {events.map((event) => ( +
  • +
    + #{event.id} + + {event.eventType} + + {event.attentionCandidate ? ( + + attention + + ) : null} + {formatTs(event.ts)} +
    +

    {event.summary}

    +

    + {event.sourceKind} + {event.sourceRef ? ` · ${event.sourceRef}` : ''} + {event.relatedSessionId ? ` · session ${event.relatedSessionId.slice(0, 8)}…` : ''} + {event.provenance ? ` · ${event.provenance}` : ''} +

    +
  • + ))} +
+ )} +
+
+ )} +
+ ) +} diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 14d0abb85e..ae88dba0fb 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -40,6 +40,7 @@ import { useAppearance, getAppearanceOptions, type AppearancePreference } from ' import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors' import { PROTOCOL_VERSION } from '@hapi/protocol' import { VoiceRespondsControls, VoiceSoundsControls, VoicePersonaControls, VoiceDiagnosticsControls } from '@/components/settings/VoiceAdvancedControls' +import { EventsDebugControls } from '@/components/settings/EventsDebugControls' const locales: { value: Locale; nativeLabel: string }[] = [ { value: 'en', nativeLabel: 'English' }, @@ -1255,6 +1256,7 @@ export default function SettingsPage() { {t('settings.voice.advanced.section.title')}
+
From fcbca54a947fa8ccc143a77fb80679b13db6d3b6 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:47:07 +0100 Subject: [PATCH 33/39] fix(hub): type WebAppEnv in systemEvents route test Co-authored-by: Cursor --- hub/src/web/routes/systemEvents.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hub/src/web/routes/systemEvents.test.ts b/hub/src/web/routes/systemEvents.test.ts index f47711d030..3f704a9949 100644 --- a/hub/src/web/routes/systemEvents.test.ts +++ b/hub/src/web/routes/systemEvents.test.ts @@ -4,6 +4,7 @@ import { Store } from '../../store' import { SyncEngine } from '../../sync/syncEngine' import { RpcRegistry } from '../../socket/rpcRegistry' import { createSystemEventsRoutes } from './systemEvents' +import type { WebAppEnv } from '../middleware/auth' describe('systemEvents routes', () => { it('lists persisted events', async () => { @@ -23,7 +24,7 @@ describe('systemEvents routes', () => { const io = { of: () => ({ to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }) }) } as never const engine = new SyncEngine(store, io, new RpcRegistry(), { broadcast: () => {} } as never) - const app = new Hono() + const app = new Hono() app.use('*', async (c, next) => { c.set('namespace', 'default') await next() From 8684388720304bf0733d4c35664b86dda4ecc696 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:49:46 +0100 Subject: [PATCH 34/39] test(e2e): allow PLAYWRIGHT_WEB_PORT for events debug smoke Co-authored-by: Cursor --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 779efd169c..7ff2c2884b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test' -const PORT = 5179 +const PORT = Number(process.env.PLAYWRIGHT_WEB_PORT ?? 5179) const BASE_URL = `http://localhost:${PORT}` export default defineConfig({ From 86d69aed331e81d9e4012a797b82763bbf9a6f89 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:29:57 +0100 Subject: [PATCH 35/39] fix(hub): combine v11 FCM, scratchlist, and events migrations Soup stacking: scratchlist and FCM layers also claim v11. Idempotent CREATE IF NOT EXISTS for all three substrates avoids rerere dropping tables when overseer merges last. --- hub/src/store/index.ts | 72 +++++------------------------------------- 1 file changed, 8 insertions(+), 64 deletions(-) diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index a398f240aa..23184ca4b7 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -545,6 +545,7 @@ export class Store { } private migrateFromV10ToV11(): void { + this.db.exec(` CREATE TABLE IF NOT EXISTS fcm_devices ( id INTEGER PRIMARY KEY AUTOINCREMENT, namespace TEXT NOT NULL, @@ -569,71 +570,14 @@ export class Store { ); CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created ON session_scratchlist(session_id, created_at DESC); + `) - CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY, - ts INTEGER NOT NULL, - source_kind TEXT NOT NULL, - source_ref TEXT, - sink_kind TEXT, - sink_ref TEXT, - event_type TEXT NOT NULL, - attention_candidate INTEGER NOT NULL DEFAULT 0, - operator_action_required INTEGER NOT NULL DEFAULT 0, - risk_detected INTEGER NOT NULL DEFAULT 0, - summary TEXT NOT NULL, - payload_json TEXT, - artifact_refs TEXT, - tags TEXT, - related_session_id TEXT REFERENCES sessions(id), - related_event_id INTEGER REFERENCES events(id), - dedupe_key TEXT, - expires_at INTEGER, - provenance TEXT, - idempotency_key TEXT, - confidence REAL, - severity INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_events_session_ts ON events(related_session_id, ts DESC); - CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(event_type, ts DESC); - CREATE UNIQUE INDEX IF NOT EXISTS idx_events_dedupe_key ON events(dedupe_key) WHERE dedupe_key IS NOT NULL; - CREATE UNIQUE INDEX IF NOT EXISTS idx_events_idempotency_key ON events(idempotency_key) WHERE idempotency_key IS NOT NULL; - - CREATE TABLE IF NOT EXISTS event_links ( - id TEXT PRIMARY KEY, - from_event_id INTEGER NOT NULL REFERENCES events(id), - to_event_id INTEGER NOT NULL REFERENCES events(id), - relation_type TEXT NOT NULL, - created_at INTEGER NOT NULL, - metadata_json TEXT - ); - CREATE INDEX IF NOT EXISTS idx_event_links_from ON event_links(from_event_id); - CREATE INDEX IF NOT EXISTS idx_event_links_to ON event_links(to_event_id); - - CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( - summary, - tags, - payload_json, - tokenize = 'porter' - ); - - CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN - INSERT INTO events_fts(rowid, summary, tags, payload_json) - VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); - END; - - CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN - INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) - VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); - END; - - CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN - INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) - VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); - INSERT INTO events_fts(rowid, summary, tags, payload_json) - VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); - END; - + const hasEvents = this.db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'events' LIMIT 1" + ).get() as { name?: string } | undefined + if (!hasEvents?.name) { + createEventsSchemaV11(this.db) + } } private getSessionColumnNames(): Set { From 627c7ad4c0a0a63405aed0a6c7fb378b44df03d7 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:23:25 +0100 Subject: [PATCH 36/39] fix(overseer): init-gate events schema; repair FTS triggers (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events tables must not own SCHEMA_VERSION — ensureOverseerEventsSchema runs on every Store boot so v11-stamped soup DBs self-heal. Add events/event_links to REQUIRED_TABLES, fix content-storing FTS delete/update triggers, and extend db-prep with full soup v11 downgrade plus --drop-overseer-events. Co-authored-by: Cursor --- hub/src/store/events.ts | 70 ++++++---- hub/src/store/index.ts | 86 ++---------- .../store/migration-v11-soup-combined.test.ts | 132 ++++++++++++++++++ hub/src/store/migration-v11.test.ts | 94 +++++++++++-- hub/src/store/schemaV11Soup.ts | 44 ++++++ scripts/tooling/hapi-driver-db-prep.sh | 55 +++++++- 6 files changed, 364 insertions(+), 117 deletions(-) create mode 100644 hub/src/store/migration-v11-soup-combined.test.ts create mode 100644 hub/src/store/schemaV11Soup.ts diff --git a/hub/src/store/events.ts b/hub/src/store/events.ts index 6c03bb77fc..39767b50e3 100644 --- a/hub/src/store/events.ts +++ b/hub/src/store/events.ts @@ -229,26 +229,11 @@ export function countSystemEvents(db: Database): number { return row.count } -/** Reverse migration helper for v11 -> v10 (used in tests + fork db-prep). */ -export function downgradeEventsSchemaV11ToV10(db: Database): void { - db.exec(` - DROP TRIGGER IF EXISTS events_fts_delete; - DROP TRIGGER IF EXISTS events_fts_update; - DROP TRIGGER IF EXISTS events_fts_insert; - DROP TABLE IF EXISTS events_fts; - DROP INDEX IF EXISTS idx_events_idempotency_key; - DROP INDEX IF EXISTS idx_event_links_to; - DROP INDEX IF EXISTS idx_event_links_from; - DROP TABLE IF EXISTS event_links; - DROP INDEX IF EXISTS idx_events_dedupe_key; - DROP INDEX IF EXISTS idx_events_type_ts; - DROP INDEX IF EXISTS idx_events_session_ts; - DROP TABLE IF EXISTS events; - PRAGMA user_version = 10; - `) -} - -export function createEventsSchemaV11(db: Database): void { +/** + * Idempotent Overseer events DDL — runs on every Store init, NOT gated on SCHEMA_VERSION. + * Additive Overseer tables must never own a version step (soup composability). + */ +export function ensureOverseerEventsSchema(db: Database): void { db.exec(` CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY, @@ -296,22 +281,53 @@ export function createEventsSchemaV11(db: Database): void { payload_json, tokenize = 'porter' ); + `) - CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN + // Recreate triggers every boot so a live DB with dropped/broken triggers self-heals. + db.exec(` + DROP TRIGGER IF EXISTS events_fts_insert; + DROP TRIGGER IF EXISTS events_fts_delete; + DROP TRIGGER IF EXISTS events_fts_update; + + CREATE TRIGGER events_fts_insert AFTER INSERT ON events BEGIN INSERT INTO events_fts(rowid, summary, tags, payload_json) VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); END; - CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN - INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) - VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); + CREATE TRIGGER events_fts_delete AFTER DELETE ON events BEGIN + DELETE FROM events_fts WHERE rowid = old.id; END; - CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN - INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) - VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); + CREATE TRIGGER events_fts_update AFTER UPDATE ON events BEGIN + DELETE FROM events_fts WHERE rowid = old.id; INSERT INTO events_fts(rowid, summary, tags, payload_json) VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); END; `) } + +/** @deprecated use ensureOverseerEventsSchema */ +export const createEventsSchemaV11 = ensureOverseerEventsSchema + +/** Drop Overseer events tables (db-prep / layer removal). Does NOT change user_version. */ +export function dropOverseerEventsSchema(db: Database): void { + db.exec(` + DROP TRIGGER IF EXISTS events_fts_delete; + DROP TRIGGER IF EXISTS events_fts_update; + DROP TRIGGER IF EXISTS events_fts_insert; + DROP TABLE IF EXISTS events_fts; + DROP INDEX IF EXISTS idx_events_idempotency_key; + DROP INDEX IF EXISTS idx_event_links_to; + DROP INDEX IF EXISTS idx_event_links_from; + DROP TABLE IF EXISTS event_links; + DROP INDEX IF EXISTS idx_events_dedupe_key; + DROP INDEX IF EXISTS idx_events_type_ts; + DROP INDEX IF EXISTS idx_events_session_ts; + DROP TABLE IF EXISTS events; + `) +} + +/** @deprecated use dropOverseerEventsSchema — events no longer own SCHEMA_VERSION */ +export function downgradeEventsSchemaV11ToV10(db: Database): void { + dropOverseerEventsSchema(db) +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 23184ca4b7..55db7508b7 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -10,7 +10,7 @@ import { ScratchlistStore } from './scratchlistStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' import { EventStore } from './eventStore' -import { createEventsSchemaV11 } from './events' +import { ensureOverseerEventsSchema } from './events' export type { StoredMachine, @@ -41,7 +41,9 @@ const REQUIRED_TABLES = [ 'users', 'push_subscriptions', 'fcm_devices', - 'session_scratchlist' + 'session_scratchlist', + 'events', + 'event_links' ] as const export class Store { @@ -162,11 +164,13 @@ export class Store { // a partially-built legacy DB may not have yet. this.createSchema() this.setUserVersion(SCHEMA_VERSION) + this.finishSchemaInit() return } this.createSchema() this.setUserVersion(SCHEMA_VERSION) + this.finishSchemaInit() return } @@ -178,6 +182,7 @@ export class Store { step() } this.setUserVersion(SCHEMA_VERSION) + this.finishSchemaInit() return } @@ -185,6 +190,12 @@ export class Store { throw this.buildSchemaMismatchError(currentVersion) } + this.finishSchemaInit() + } + + /** Idempotent Overseer self-heal + loud missing-table check on every boot path. */ + private finishSchemaInit(): void { + ensureOverseerEventsSchema(this.db) this.assertRequiredTablesPresent() } @@ -297,70 +308,6 @@ export class Store { CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created ON session_scratchlist(session_id, created_at DESC); - CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY, - ts INTEGER NOT NULL, - source_kind TEXT NOT NULL, - source_ref TEXT, - sink_kind TEXT, - sink_ref TEXT, - event_type TEXT NOT NULL, - attention_candidate INTEGER NOT NULL DEFAULT 0, - operator_action_required INTEGER NOT NULL DEFAULT 0, - risk_detected INTEGER NOT NULL DEFAULT 0, - summary TEXT NOT NULL, - payload_json TEXT, - artifact_refs TEXT, - tags TEXT, - related_session_id TEXT REFERENCES sessions(id), - related_event_id INTEGER REFERENCES events(id), - dedupe_key TEXT, - expires_at INTEGER, - provenance TEXT, - idempotency_key TEXT, - confidence REAL, - severity INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_events_session_ts ON events(related_session_id, ts DESC); - CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(event_type, ts DESC); - CREATE UNIQUE INDEX IF NOT EXISTS idx_events_dedupe_key ON events(dedupe_key) WHERE dedupe_key IS NOT NULL; - CREATE UNIQUE INDEX IF NOT EXISTS idx_events_idempotency_key ON events(idempotency_key) WHERE idempotency_key IS NOT NULL; - - CREATE TABLE IF NOT EXISTS event_links ( - id TEXT PRIMARY KEY, - from_event_id INTEGER NOT NULL REFERENCES events(id), - to_event_id INTEGER NOT NULL REFERENCES events(id), - relation_type TEXT NOT NULL, - created_at INTEGER NOT NULL, - metadata_json TEXT - ); - CREATE INDEX IF NOT EXISTS idx_event_links_from ON event_links(from_event_id); - CREATE INDEX IF NOT EXISTS idx_event_links_to ON event_links(to_event_id); - - CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( - summary, - tags, - payload_json, - tokenize = 'porter' - ); - - CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN - INSERT INTO events_fts(rowid, summary, tags, payload_json) - VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); - END; - - CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN - INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) - VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); - END; - - CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN - INSERT INTO events_fts(events_fts, rowid, summary, tags, payload_json) - VALUES ('delete', old.id, old.summary, COALESCE(old.tags, ''), COALESCE(old.payload_json, '')); - INSERT INTO events_fts(rowid, summary, tags, payload_json) - VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); - END; - `) } @@ -571,13 +518,6 @@ export class Store { CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created ON session_scratchlist(session_id, created_at DESC); `) - - const hasEvents = this.db.prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'events' LIMIT 1" - ).get() as { name?: string } | undefined - if (!hasEvents?.name) { - createEventsSchemaV11(this.db) - } } private getSessionColumnNames(): Set { diff --git a/hub/src/store/migration-v11-soup-combined.test.ts b/hub/src/store/migration-v11-soup-combined.test.ts new file mode 100644 index 0000000000..d89b1b1cd5 --- /dev/null +++ b/hub/src/store/migration-v11-soup-combined.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Store } from './index' +import { applySoupV10ToV11Migration, SOUP_V11_TABLES } from './schemaV11Soup' + +describe('Store soup combined V10→V11 migration', () => { + it('applySoupV10ToV11Migration plus Store init creates full soup v11 surface', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v11-soup-')) + const dbPath = join(dir, 'test.db') + let store: Store | undefined + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA journal_mode = WAL') + db.exec('PRAGMA foreign_keys = ON') + createV10Schema(db) + db.exec('PRAGMA user_version = 10') + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s1', 'default', 1000, 1000, 0)`) + applySoupV10ToV11Migration(db) + db.exec('PRAGMA user_version = 11') + db.close() + + store = new Store(dbPath) + const dbAfter: Database = (store as unknown as { db: Database }).db + for (const table of SOUP_V11_TABLES) { + expect(tableExists(dbAfter, table), `missing ${table}`).toBe(true) + } + + dbAfter.exec(`INSERT INTO fcm_devices (namespace, token, platform, device_id, created_at, updated_at) + VALUES ('default', 'tok', 'phone', 'dev-1', 1000, 1000)`) + dbAfter.exec(`INSERT INTO session_scratchlist (session_id, entry_id, text, created_at, updated_at) + VALUES ('s1', 'e1', 'note', 1000, 1000)`) + const event = store.events.insert({ + ts: 2000, + sourceKind: 'worker', + sourceRef: 'test', + eventType: 'completed', + attentionCandidate: 0, + summary: 'soup smoke', + relatedSessionId: 's1', + provenance: 'test' + }) + expect(event?.summary).toBe('soup smoke') + } finally { + store?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +function tableExists(db: Database, name: string): boolean { + const row = db.prepare( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table') AND name = ? LIMIT 1" + ).get(name) as { name: string } | null + return row !== null +} + +function createV10Schema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + tag TEXT, + namespace TEXT NOT NULL DEFAULT 'default', + machine_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + agent_state TEXT, + agent_state_version INTEGER DEFAULT 1, + model TEXT, + model_reasoning_effort TEXT, + effort TEXT, + service_tier TEXT, + todos TEXT, + todos_updated_at INTEGER, + team_state TEXT, + team_state_updated_at INTEGER, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS machines ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + runner_state TEXT, + runner_state_version INTEGER DEFAULT 1, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + seq INTEGER NOT NULL, + local_id TEXT, + invoked_at INTEGER, + scheduled_at INTEGER, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_user_id TEXT NOT NULL, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + UNIQUE(platform, platform_user_id) + ); + + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(namespace, endpoint) + ); + `) +} diff --git a/hub/src/store/migration-v11.test.ts b/hub/src/store/migration-v11.test.ts index 24e578925a..78a241c136 100644 --- a/hub/src/store/migration-v11.test.ts +++ b/hub/src/store/migration-v11.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from 'bun:test' import { Store } from './index' -import { downgradeEventsSchemaV11ToV10 } from './events' +import { dropOverseerEventsSchema, ensureOverseerEventsSchema } from './events' +import { applySoupV10ToV11Migration } from './schemaV11Soup' import { Database } from 'bun:sqlite' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -describe('Store V10→V11 migration: events substrate', () => { - it('fresh DB has events, event_links, and events_fts', () => { +describe('Overseer events schema (init-gated, not SCHEMA_VERSION)', () => { + it('fresh DB has events, event_links, and events_fts after Store init', () => { const store = new Store(':memory:') const db: Database = (store as unknown as { db: Database }).db const tables = db.prepare( @@ -19,8 +20,41 @@ describe('Store V10→V11 migration: events substrate', () => { expect(names.has('events_fts')).toBe(true) }) - it('V10 DB migrates to V11 and can insert events', () => { - const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v11-test-')) + it('v11 DB stamped without events self-heals on Store open (incident regression)', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-incident-v11-no-events-')) + const dbPath = join(dir, 'test.db') + let store: Store | undefined + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA journal_mode = WAL') + db.exec('PRAGMA foreign_keys = ON') + createV10Schema(db) + applySoupV10ToV11Migration(db) + db.exec('PRAGMA user_version = 11') + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s1', 'default', 1000, 1000, 0)`) + db.close() + + store = new Store(dbPath) + const event = store.events.insert({ + ts: 2000, + sourceKind: 'worker', + sourceRef: 'test-agent', + eventType: 'completed', + attentionCandidate: 0, + summary: 'Self-healed after v11 stamp', + relatedSessionId: 's1', + provenance: 'test' + }) + expect(event?.summary).toBe('Self-healed after v11 stamp') + } finally { + store?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('V10 DB gets events on Store open without running v11 migration step', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v10-events-init-')) const dbPath = join(dir, 'test.db') let store: Store | undefined try { @@ -40,29 +74,67 @@ describe('Store V10→V11 migration: events substrate', () => { sourceRef: 'test-agent', eventType: 'completed', attentionCandidate: 0, - summary: 'Migration smoke event', + summary: 'Init-gated event', relatedSessionId: 's1', provenance: 'test' }) - expect(event?.summary).toBe('Migration smoke event') - expect(store.events.count()).toBe(1) + expect(event?.summary).toBe('Init-gated event') } finally { store?.close() rmSync(dir, { recursive: true, force: true }) } }) - it('downgrade v11 -> v10 removes events tables', () => { + it('dropOverseerEventsSchema removes events tables without changing user_version', () => { const store = new Store(':memory:') const db: Database = (store as unknown as { db: Database }).db - downgradeEventsSchemaV11ToV10(db) + db.exec('PRAGMA user_version = 11') + dropOverseerEventsSchema(db) const version = db.prepare('PRAGMA user_version').get() as { user_version: number } - expect(version.user_version).toBe(10) + expect(version.user_version).toBe(11) const events = db.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='events'" ).get() expect(events).toBeNull() }) + + it('events_fts delete and update triggers use content-storing form', () => { + const db = new Database(':memory:') + createV10Schema(db) + ensureOverseerEventsSchema(db) + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s1', 'default', 1000, 1000, 0)`) + db.exec(`INSERT INTO events (ts, source_kind, event_type, attention_candidate, summary, related_session_id) + VALUES (2000, 'worker', 'completed', 0, 'fts probe', 's1')`) + + db.exec(`UPDATE events SET summary = 'fts updated' WHERE id = 1`) + const updated = db.prepare( + "SELECT summary FROM events_fts WHERE rowid = 1" + ).get() as { summary: string } | undefined + expect(updated?.summary).toBe('fts updated') + + db.exec('DELETE FROM events WHERE id = 1') + const deleted = db.prepare( + "SELECT rowid FROM events_fts WHERE rowid = 1" + ).get() + expect(deleted).toBeNull() + }) + + it('ensureOverseerEventsSchema recreates broken delete/update triggers', () => { + const db = new Database(':memory:') + createV10Schema(db) + ensureOverseerEventsSchema(db) + db.exec('DROP TRIGGER IF EXISTS events_fts_delete') + db.exec('DROP TRIGGER IF EXISTS events_fts_update') + + ensureOverseerEventsSchema(db) + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s1', 'default', 1000, 1000, 0)`) + db.exec(`INSERT INTO events (ts, source_kind, event_type, attention_candidate, summary) + VALUES (2000, 'worker', 'completed', 0, 'before delete')`) + + expect(() => db.exec('DELETE FROM events WHERE id = 1')).not.toThrow() + }) }) function createV10Schema(db: Database): void { diff --git a/hub/src/store/schemaV11Soup.ts b/hub/src/store/schemaV11Soup.ts new file mode 100644 index 0000000000..5012a786a0 --- /dev/null +++ b/hub/src/store/schemaV11Soup.ts @@ -0,0 +1,44 @@ +import type { Database } from 'bun:sqlite' + +/** + * Soup-only combined v10→v11 step: fcm_devices + session_scratchlist. + * + * Overseer events tables are NOT version-gated — Store.init calls + * ensureOverseerEventsSchema() on every boot regardless of user_version. + */ +export function applySoupV10ToV11Migration(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS fcm_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + token TEXT NOT NULL, + platform TEXT NOT NULL, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(namespace, device_id, platform) + ); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_namespace ON fcm_devices(namespace); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_token ON fcm_devices(token); + + CREATE TABLE IF NOT EXISTS session_scratchlist ( + session_id TEXT NOT NULL, + entry_id TEXT NOT NULL, + text TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, entry_id), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created + ON session_scratchlist(session_id, created_at DESC); + `) +} + +export const SOUP_V11_TABLES = [ + 'fcm_devices', + 'session_scratchlist', + 'events', + 'event_links', + 'events_fts' +] as const diff --git a/scripts/tooling/hapi-driver-db-prep.sh b/scripts/tooling/hapi-driver-db-prep.sh index b42706444d..185669b2bf 100755 --- a/scripts/tooling/hapi-driver-db-prep.sh +++ b/scripts/tooling/hapi-driver-db-prep.sh @@ -4,18 +4,27 @@ # Operator-fork soup helper (not used by upstream CI). See driver-soup.md. # # Known downgrade transitions (extend as new schema-bumping layers are added): -# v10 -> v9 : DROP TABLE fcm_devices + its 2 indexes -# v11 -> v10: DROP events/event_links/FTS5 (feat/overseer-events-substrate #22) +# v10 -> v9 : DROP fcm_devices + indexes +# v11 -> v10 : DROP soup v11 tables (fcm_devices, session_scratchlist) + PRAGMA 10 +# +# Overseer events tables are NOT SCHEMA_VERSION-gated. When swinging away from +# feat/overseer-events-substrate only (staying on soup v11), run drop-overseer-events. set -euo pipefail DRY_RUN=0 TARGET="" +DROP_OVERSEER_EVENTS=0 while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; - -h|--help) sed -n '2,12p' "$0"; exit 0 ;; + --drop-overseer-events) DROP_OVERSEER_EVENTS=1; shift ;; + -h|--help) + sed -n '2,14p' "$0" + echo " --drop-overseer-events drop events/event_links/FTS only (no user_version change)" + exit 0 + ;; *) if [[ -z "$TARGET" ]]; then TARGET="$1"; shift else echo "Unexpected arg: $1" >&2; exit 2; fi @@ -23,7 +32,7 @@ while [[ $# -gt 0 ]]; do esac done -[[ -n "$TARGET" ]] || { echo "Usage: hapi-driver-db-prep.sh " >&2; exit 2; } +[[ -n "$TARGET" ]] || { echo "Usage: hapi-driver-db-prep.sh [--drop-overseer-events] " >&2; exit 2; } TARGET="$(realpath "$TARGET")" PRIMARY="${HAPI_PRIMARY:-$HOME/coding/hapi}" @@ -56,7 +65,9 @@ echo " target_schema = $target_schema (from $TARGET/hub/src/store/index.ts)" echo " base_schema = $base_schema (from upstream/main)" echo " live_schema = $live_schema (from $DB_PATH)" -if [[ "$live_schema" -eq "$target_schema" ]]; then +if [[ "$DROP_OVERSEER_EVENTS" -eq 1 ]]; then + decision="drop-overseer-events -- remove events substrate only (user_version unchanged)" +elif [[ "$live_schema" -eq "$target_schema" ]]; then decision="match -- no migration needed" elif [[ "$live_schema" -lt "$target_schema" ]]; then decision="forward -- hub will auto-migrate $live_schema -> $target_schema via stepMigrations on boot" @@ -82,6 +93,26 @@ if [[ "${HAPI_DB_PREP_NO_BACKUP:-}" != "1" ]]; then cp -a "$DB_PATH" "$BACKUP" fi +apply_drop_overseer_events() { + echo " dropping Overseer events tables (no user_version change)" + sqlite3 "$DB_PATH" <<'SQL' +BEGIN IMMEDIATE; +DROP TRIGGER IF EXISTS events_fts_delete; +DROP TRIGGER IF EXISTS events_fts_update; +DROP TRIGGER IF EXISTS events_fts_insert; +DROP TABLE IF EXISTS events_fts; +DROP INDEX IF EXISTS idx_events_idempotency_key; +DROP INDEX IF EXISTS idx_event_links_to; +DROP INDEX IF EXISTS idx_event_links_from; +DROP TABLE IF EXISTS event_links; +DROP INDEX IF EXISTS idx_events_dedupe_key; +DROP INDEX IF EXISTS idx_events_type_ts; +DROP INDEX IF EXISTS idx_events_session_ts; +DROP TABLE IF EXISTS events; +COMMIT; +SQL +} + apply_downgrade_step() { local from="$1" to="$2" case "${from}_to_${to}" in @@ -97,7 +128,7 @@ COMMIT; SQL ;; 11_to_10) - echo " applying v11 -> v10 downgrade: DROP events substrate + FTS5" + echo " applying v11 -> v10 downgrade: DROP soup v11 (fcm + scratchlist + overseer events)" sqlite3 "$DB_PATH" <<'SQL' BEGIN IMMEDIATE; DROP TRIGGER IF EXISTS events_fts_delete; @@ -112,6 +143,11 @@ DROP INDEX IF EXISTS idx_events_dedupe_key; DROP INDEX IF EXISTS idx_events_type_ts; DROP INDEX IF EXISTS idx_events_session_ts; DROP TABLE IF EXISTS events; +DROP INDEX IF EXISTS idx_session_scratchlist_session_created; +DROP TABLE IF EXISTS session_scratchlist; +DROP INDEX IF EXISTS idx_fcm_devices_token; +DROP INDEX IF EXISTS idx_fcm_devices_namespace; +DROP TABLE IF EXISTS fcm_devices; PRAGMA user_version = 10; COMMIT; SQL @@ -125,6 +161,13 @@ SQL esac } +if [[ "$DROP_OVERSEER_EVENTS" -eq 1 ]]; then + apply_drop_overseer_events || exit 1 + echo " overseer events dropped; user_version still $(sqlite3 "$DB_PATH" "PRAGMA user_version;")" + echo " db-prep complete; safe to start hub on $TARGET" + exit 0 +fi + if [[ "$live_schema" -gt "$target_schema" ]]; then cur="$live_schema" while [[ "$cur" -gt "$target_schema" ]]; do From 32c86ad045619b734b982f22f09c3e9bb8a52cfa Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:37:18 +0100 Subject: [PATCH 37/39] fix(overseer): detach/repoint events on session delete and merge events.related_session_id FK blocked DELETE /sessions and reopen merge (delete old row). Detach on intentional delete; repoint to new session id on mergeSessions so overseer audit trail survives reopen/resume id swap. Co-authored-by: Cursor --- hub/src/store/eventStore.ts | 5 ++++ hub/src/store/events.ts | 26 ++++++++++++++++++ hub/src/store/migration-v11.test.ts | 42 ++++++++++++++++++++++++++++- hub/src/store/sessions.ts | 2 ++ hub/src/sync/sessionCache.ts | 1 + 5 files changed, 75 insertions(+), 1 deletion(-) diff --git a/hub/src/store/eventStore.ts b/hub/src/store/eventStore.ts index c849881b2b..c0829c84e0 100644 --- a/hub/src/store/eventStore.ts +++ b/hub/src/store/eventStore.ts @@ -4,6 +4,7 @@ import { insertEventLink, insertSystemEvent, listSystemEvents, + repointSessionEvents, type InsertSystemEventInput, type ListSystemEventsOptions, type StoredSystemEvent @@ -26,6 +27,10 @@ export class EventStore { return countSystemEvents(this.db) } + repointSession(fromSessionId: string, toSessionId: string): number { + return repointSessionEvents(this.db, fromSessionId, toSessionId) + } + linkEvents(input: { fromEventId: number toEventId: number diff --git a/hub/src/store/events.ts b/hub/src/store/events.ts index 39767b50e3..0e66ec4d59 100644 --- a/hub/src/store/events.ts +++ b/hub/src/store/events.ts @@ -110,6 +110,32 @@ function mapRow(row: SystemEventRow): StoredSystemEvent { } } +/** Clear session FK refs so DELETE FROM sessions succeeds (events are audit-retained). */ +export function detachSessionEvents(db: Database, sessionId: string): number { + const result = db.prepare( + 'UPDATE events SET related_session_id = NULL WHERE related_session_id = ?' + ).run(sessionId) + return result.changes +} + +/** Move overseer event refs when session rows merge (reopen/resume id swap). */ +export function repointSessionEvents(db: Database, fromSessionId: string, toSessionId: string): number { + if (fromSessionId === toSessionId) { + return 0 + } + const countRow = db.prepare( + 'SELECT COUNT(*) as count FROM events WHERE related_session_id = ?' + ).get(fromSessionId) as { count: number } + const pending = countRow.count + if (pending === 0) { + return 0 + } + db.prepare( + 'UPDATE events SET related_session_id = ? WHERE related_session_id = ?' + ).run(toSessionId, fromSessionId) + return pending +} + export function insertSystemEvent(db: Database, input: InsertSystemEventInput): StoredSystemEvent | null { if (input.idempotencyKey) { const existing = db.prepare( diff --git a/hub/src/store/migration-v11.test.ts b/hub/src/store/migration-v11.test.ts index 78a241c136..08204a0b0a 100644 --- a/hub/src/store/migration-v11.test.ts +++ b/hub/src/store/migration-v11.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test' import { Store } from './index' -import { dropOverseerEventsSchema, ensureOverseerEventsSchema } from './events' +import { dropOverseerEventsSchema, ensureOverseerEventsSchema, repointSessionEvents } from './events' +import { deleteSession } from './sessions' import { applySoupV10ToV11Migration } from './schemaV11Soup' import { Database } from 'bun:sqlite' import { mkdtempSync, rmSync } from 'node:fs' @@ -135,6 +136,45 @@ describe('Overseer events schema (init-gated, not SCHEMA_VERSION)', () => { expect(() => db.exec('DELETE FROM events WHERE id = 1')).not.toThrow() }) + + it('deleteSession detaches overseer events instead of FK-failing', () => { + const db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = ON') + createV10Schema(db) + ensureOverseerEventsSchema(db) + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s-del', 'default', 1000, 1000, 0)`) + db.exec(`INSERT INTO events (ts, source_kind, event_type, attention_candidate, summary, related_session_id) + VALUES (2000, 'system', 'stale', 0, 'No agent output', 's-del')`) + + expect(deleteSession(db, 's-del', 'default')).toBe(true) + const event = db.prepare('SELECT related_session_id FROM events WHERE id = 1').get() as { + related_session_id: string | null + } + expect(event.related_session_id).toBeNull() + expect(db.prepare("SELECT id FROM sessions WHERE id = 's-del'").get()).toBeNull() + }) + + it('repointSessionEvents moves FK refs for merge/reopen id swap', () => { + const db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = ON') + createV10Schema(db) + ensureOverseerEventsSchema(db) + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s-old', 'default', 1000, 1000, 0), + ('s-new', 'default', 1000, 1000, 0)`) + db.exec(`INSERT INTO events (ts, source_kind, event_type, attention_candidate, summary, related_session_id) + VALUES (2000, 'system', 'stale', 0, 'stale probe', 's-old'), + (3000, 'worker', 'completed', 0, 'done probe', 's-old')`) + + expect(repointSessionEvents(db, 's-old', 's-new')).toBe(2) + expect(deleteSession(db, 's-old', 'default')).toBe(true) + + const rows = db.prepare( + 'SELECT related_session_id FROM events ORDER BY id' + ).all() as Array<{ related_session_id: string | null }> + expect(rows).toEqual([{ related_session_id: 's-new' }, { related_session_id: 's-new' }]) + }) }) function createV10Schema(db: Database): void { diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 2a362ba626..2080a2ee92 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -1,6 +1,7 @@ import type { Database } from 'bun:sqlite' import { randomUUID } from 'node:crypto' +import { detachSessionEvents } from './events' import type { StoredSession, VersionedUpdateResult } from './types' import { safeJsonParse } from './json' import { updateVersionedField } from './versionedUpdates' @@ -597,6 +598,7 @@ export function getSessionsByNamespace(db: Database, namespace: string): StoredS } export function deleteSession(db: Database, id: string, namespace: string): boolean { + detachSessionEvents(db, id) const result = db.prepare( 'DELETE FROM sessions WHERE id = ? AND namespace = ?' ).run(id, namespace) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index f93f13c715..9a37636adc 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1044,6 +1044,7 @@ export class SessionCache { } if (options.deleteOldSession) { + this.store.events.repointSession(oldSessionId, newSessionId) const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) if (!deleted) { throw new Error('Failed to delete old session during merge') From a4c9592526b42183468b83ae3f021d8b909b0c79 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:50:16 +0100 Subject: [PATCH 38/39] feat(overseer): denormalize session identity + deleted_sessions tombstone (#22) Events embed payload.session (id, tag, name, project, flavor) at write time so audit rows stay self-describing after hard-delete. deleteSession snapshots identity into init-gated deleted_sessions before detach. Co-authored-by: Cursor --- hub/src/store/events.ts | 41 ++++++++ hub/src/store/index.ts | 6 +- hub/src/store/migration-v11.test.ts | 95 +++++++++++++++++- hub/src/store/schemaV11Soup.ts | 3 +- hub/src/store/sessions.ts | 20 +++- hub/src/sync/overseerEventRecorder.test.ts | 70 ++++++++++++-- hub/src/sync/overseerEventRecorder.ts | 106 +++++++++++++-------- hub/src/sync/syncEngine.ts | 9 +- scripts/tooling/hapi-driver-db-prep.sh | 4 + shared/src/overseerEvents.test.ts | 42 ++++++++ shared/src/overseerEvents.ts | 66 +++++++++++++ 11 files changed, 401 insertions(+), 61 deletions(-) diff --git a/hub/src/store/events.ts b/hub/src/store/events.ts index 0e66ec4d59..31ec0cddfc 100644 --- a/hub/src/store/events.ts +++ b/hub/src/store/events.ts @@ -1,5 +1,6 @@ import type { Database } from 'bun:sqlite' import { randomUUID } from 'node:crypto' +import type { OverseerSessionIdentity } from '@hapi/protocol' export type InsertSystemEventInput = { ts: number @@ -335,6 +336,45 @@ export function ensureOverseerEventsSchema(db: Database): void { /** @deprecated use ensureOverseerEventsSchema */ export const createEventsSchemaV11 = ensureOverseerEventsSchema +export function ensureDeletedSessionsSchema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS deleted_sessions ( + id TEXT PRIMARY KEY, + tag TEXT, + name TEXT, + project TEXT, + flavor TEXT, + deleted_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_deleted_sessions_deleted_at + ON deleted_sessions(deleted_at DESC); + `) +} + +export function tombstoneDeletedSession( + db: Database, + identity: OverseerSessionIdentity, + deletedAt: number +): void { + db.prepare(` + INSERT INTO deleted_sessions (id, tag, name, project, flavor, deleted_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + tag = excluded.tag, + name = excluded.name, + project = excluded.project, + flavor = excluded.flavor, + deleted_at = excluded.deleted_at + `).run( + identity.id, + identity.tag, + identity.name, + identity.project, + identity.flavor, + deletedAt + ) +} + /** Drop Overseer events tables (db-prep / layer removal). Does NOT change user_version. */ export function dropOverseerEventsSchema(db: Database): void { db.exec(` @@ -350,6 +390,7 @@ export function dropOverseerEventsSchema(db: Database): void { DROP INDEX IF EXISTS idx_events_type_ts; DROP INDEX IF EXISTS idx_events_session_ts; DROP TABLE IF EXISTS events; + DROP TABLE IF EXISTS deleted_sessions; `) } diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 55db7508b7..5e2162aaf6 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -10,7 +10,7 @@ import { ScratchlistStore } from './scratchlistStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' import { EventStore } from './eventStore' -import { ensureOverseerEventsSchema } from './events' +import { ensureOverseerEventsSchema, ensureDeletedSessionsSchema } from './events' export type { StoredMachine, @@ -43,7 +43,8 @@ const REQUIRED_TABLES = [ 'fcm_devices', 'session_scratchlist', 'events', - 'event_links' + 'event_links', + 'deleted_sessions' ] as const export class Store { @@ -196,6 +197,7 @@ export class Store { /** Idempotent Overseer self-heal + loud missing-table check on every boot path. */ private finishSchemaInit(): void { ensureOverseerEventsSchema(this.db) + ensureDeletedSessionsSchema(this.db) this.assertRequiredTablesPresent() } diff --git a/hub/src/store/migration-v11.test.ts b/hub/src/store/migration-v11.test.ts index 08204a0b0a..8a79fb851b 100644 --- a/hub/src/store/migration-v11.test.ts +++ b/hub/src/store/migration-v11.test.ts @@ -1,24 +1,27 @@ import { describe, expect, it } from 'bun:test' +import type { Session } from '@hapi/protocol/types' import { Store } from './index' -import { dropOverseerEventsSchema, ensureOverseerEventsSchema, repointSessionEvents } from './events' +import { dropOverseerEventsSchema, ensureDeletedSessionsSchema, ensureOverseerEventsSchema, repointSessionEvents } from './events' import { deleteSession } from './sessions' import { applySoupV10ToV11Migration } from './schemaV11Soup' +import { OverseerEventRecorder, toSessionSnapshot } from '../sync/overseerEventRecorder' import { Database } from 'bun:sqlite' import { mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' describe('Overseer events schema (init-gated, not SCHEMA_VERSION)', () => { - it('fresh DB has events, event_links, and events_fts after Store init', () => { + it('fresh DB has events, event_links, events_fts, and deleted_sessions after Store init', () => { const store = new Store(':memory:') const db: Database = (store as unknown as { db: Database }).db const tables = db.prepare( - "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table') AND name IN ('events', 'event_links', 'events_fts')" + "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table') AND name IN ('events', 'event_links', 'events_fts', 'deleted_sessions')" ).all() as Array<{ name: string }> const names = new Set(tables.map((row) => row.name)) expect(names.has('events')).toBe(true) expect(names.has('event_links')).toBe(true) expect(names.has('events_fts')).toBe(true) + expect(names.has('deleted_sessions')).toBe(true) }) it('v11 DB stamped without events self-heals on Store open (incident regression)', () => { @@ -142,8 +145,10 @@ describe('Overseer events schema (init-gated, not SCHEMA_VERSION)', () => { db.exec('PRAGMA foreign_keys = ON') createV10Schema(db) ensureOverseerEventsSchema(db) - db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) - VALUES ('s-del', 'default', 1000, 1000, 0)`) + ensureDeletedSessionsSchema(db) + db.exec(`INSERT INTO sessions (id, tag, namespace, created_at, updated_at, seq, metadata) + VALUES ('s-del', 'del-tag', 'default', 1000, 1000, 0, + '{"flavor":"codex","path":"/coding/hapi","name":"meta HAPI triage","host":"local"}')`) db.exec(`INSERT INTO events (ts, source_kind, event_type, attention_candidate, summary, related_session_id) VALUES (2000, 'system', 'stale', 0, 'No agent output', 's-del')`) @@ -153,6 +158,85 @@ describe('Overseer events schema (init-gated, not SCHEMA_VERSION)', () => { } expect(event.related_session_id).toBeNull() expect(db.prepare("SELECT id FROM sessions WHERE id = 's-del'").get()).toBeNull() + + const tombstone = db.prepare( + 'SELECT id, tag, name, project, flavor FROM deleted_sessions WHERE id = ?' + ).get('s-del') as { id: string; tag: string; name: string; project: string; flavor: string } + expect(tombstone.tag).toBe('del-tag') + expect(tombstone.name).toBe('meta HAPI triage') + expect(tombstone.project).toBe('hapi') + expect(tombstone.flavor).toBe('codex') + }) + + it('event retains session identity in payload after deleteSession', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events) + const stored = store.sessions.getOrCreateSession( + 'meta-triage', + { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' }, + null, + 'default' + ) + + const content = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'AGENT_NOTIFY_SUMMARY {"version":1,"agent":"overseer","project":"hapi","status":"done","action":"","summary":"Triage complete"}' + } + } + } + + recorder.onAgentMessage( + toSessionSnapshot( + { + id: stored.id, + namespace: 'default', + seq: 0, + createdAt: stored.createdAt, + updatedAt: stored.updatedAt, + active: true, + activeAt: stored.activeAt ?? Date.now(), + metadata: stored.metadata as Session['metadata'], + metadataVersion: 1, + agentState: null, + agentStateVersion: 1, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null + }, + stored.tag + ), + 'msg-del', + content, + Date.now() + ) + + expect(store.sessions.deleteSession(stored.id, 'default')).toBe(true) + + const events = store.events.list() + expect(events).toHaveLength(1) + expect(events[0]?.relatedSessionId).toBeNull() + + const payload = JSON.parse(events[0]!.payloadJson!) as { + session: { name: string | null; id: string; project: string | null; flavor: string } + } + expect(payload.session.name).toBe('meta HAPI triage') + expect(payload.session.id).toBe(stored.id) + expect(payload.session.project).toBe('hapi') + expect(payload.session.flavor).toBe('codex') + + const db: Database = (store as unknown as { db: Database }).db + const tombstone = db.prepare('SELECT name FROM deleted_sessions WHERE id = ?').get(stored.id) as { + name: string + } + expect(tombstone.name).toBe('meta HAPI triage') + expect(store.sessions.getSession(stored.id)).toBeNull() }) it('repointSessionEvents moves FK refs for merge/reopen id swap', () => { @@ -160,6 +244,7 @@ describe('Overseer events schema (init-gated, not SCHEMA_VERSION)', () => { db.exec('PRAGMA foreign_keys = ON') createV10Schema(db) ensureOverseerEventsSchema(db) + ensureDeletedSessionsSchema(db) db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) VALUES ('s-old', 'default', 1000, 1000, 0), ('s-new', 'default', 1000, 1000, 0)`) diff --git a/hub/src/store/schemaV11Soup.ts b/hub/src/store/schemaV11Soup.ts index 5012a786a0..eb44fd2854 100644 --- a/hub/src/store/schemaV11Soup.ts +++ b/hub/src/store/schemaV11Soup.ts @@ -40,5 +40,6 @@ export const SOUP_V11_TABLES = [ 'session_scratchlist', 'events', 'event_links', - 'events_fts' + 'events_fts', + 'deleted_sessions' ] as const diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 2080a2ee92..8f0325e8fb 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -1,7 +1,9 @@ import type { Database } from 'bun:sqlite' import { randomUUID } from 'node:crypto' -import { detachSessionEvents } from './events' +import { detachSessionEvents, tombstoneDeletedSession } from './events' +import { buildOverseerSessionIdentity } from '@hapi/protocol' +import type { Metadata } from '@hapi/protocol/types' import type { StoredSession, VersionedUpdateResult } from './types' import { safeJsonParse } from './json' import { updateVersionedField } from './versionedUpdates' @@ -598,6 +600,22 @@ export function getSessionsByNamespace(db: Database, namespace: string): StoredS } export function deleteSession(db: Database, id: string, namespace: string): boolean { + const row = getSessionByNamespace(db, id, namespace) + if (!row) { + return false + } + + const metadata = row.metadata as Metadata | null + tombstoneDeletedSession( + db, + buildOverseerSessionIdentity({ + id: row.id, + flavor: metadata?.flavor ?? 'claude', + tag: row.tag, + metadata + }), + Date.now() + ) detachSessionEvents(db, id) const result = db.prepare( 'DELETE FROM sessions WHERE id = ? AND namespace = ?' diff --git a/hub/src/sync/overseerEventRecorder.test.ts b/hub/src/sync/overseerEventRecorder.test.ts index faf2240f2b..0e328f3457 100644 --- a/hub/src/sync/overseerEventRecorder.test.ts +++ b/hub/src/sync/overseerEventRecorder.test.ts @@ -3,7 +3,7 @@ import type { Session } from '@hapi/protocol/types' import { Store } from '../store' import { OverseerEventRecorder, toSessionSnapshot } from './overseerEventRecorder' -function makeSession(id: string, flavor: string): Session { +function makeSession(id: string, flavor: string, overrides?: Partial): Session { return { id, namespace: 'default', @@ -21,7 +21,8 @@ function makeSession(id: string, flavor: string): Session { model: null, modelReasoningEffort: null, effort: null, - serviceTier: null + serviceTier: null, + ...overrides } } @@ -29,7 +30,7 @@ describe('OverseerEventRecorder', () => { it('records AGENT_NOTIFY_SUMMARY from codex assistant text', () => { const store = new Store(':memory:') const recorder = new OverseerEventRecorder(store.events) - const session = store.sessions.getOrCreateSession('test', { flavor: 'codex', path: '/tmp' }, null, 'default') + const session = store.sessions.getOrCreateSession('test', { flavor: 'codex', path: '/tmp', host: 'local' }, null, 'default') const content = { role: 'agent', @@ -43,7 +44,7 @@ describe('OverseerEventRecorder', () => { } const event = recorder.onAgentMessage( - toSessionSnapshot(makeSession(session.id, 'codex')), + toSessionSnapshot(makeSession(session.id, 'codex'), session.tag), 'msg-1', content, Date.now() @@ -53,12 +54,21 @@ describe('OverseerEventRecorder', () => { expect(event?.attentionCandidate).toBe(1) expect(event?.summary).toBe('Shipped fix') expect(store.events.count()).toBe(1) + + const payload = JSON.parse(event!.payloadJson!) as { + session: { id: string; tag: string | null; name: string | null; project: string | null; flavor: string } + notify_summary: { project?: string } + } + expect(payload.session.id).toBe(session.id) + expect(payload.session.tag).toBe('test') + expect(payload.session.project).toBe('demo') + expect(payload.session.flavor).toBe('codex') }) it('captures done without action as captured-only', () => { const store = new Store(':memory:') const recorder = new OverseerEventRecorder(store.events) - const session = store.sessions.getOrCreateSession('test2', { flavor: 'claude', path: '/tmp' }, null, 'default') + const session = store.sessions.getOrCreateSession('test2', { flavor: 'claude', path: '/tmp', host: 'local' }, null, 'default') const content = { role: 'agent', @@ -72,7 +82,7 @@ describe('OverseerEventRecorder', () => { } const event = recorder.onAgentMessage( - toSessionSnapshot(makeSession(session.id, 'claude')), + toSessionSnapshot(makeSession(session.id, 'claude'), session.tag), 'msg-2', content, Date.now() @@ -84,7 +94,7 @@ describe('OverseerEventRecorder', () => { it('synthesizes approval_requested from permission prompts', () => { const store = new Store(':memory:') const recorder = new OverseerEventRecorder(store.events) - const session = store.sessions.getOrCreateSession('perm', { flavor: 'claude', path: '/tmp' }, null, 'default') + const session = store.sessions.getOrCreateSession('perm', { flavor: 'claude', path: '/tmp', host: 'local' }, null, 'default') const live = makeSession(session.id, 'claude') live.agentState = { @@ -93,11 +103,55 @@ describe('OverseerEventRecorder', () => { } } - recorder.onSessionUpdated(live) + recorder.onSessionUpdated(live, session.tag) const events = store.events.list({ eventType: 'approval_requested' }) expect(events).toHaveLength(1) expect(events[0]?.attentionCandidate).toBe(1) expect(events[0]?.summary).toContain('Bash') + + const payload = JSON.parse(events[0]!.payloadJson!) as { session: { name: string | null } } + expect(payload.session.name).toBe('perm') + }) + + it('denormalizes session display name and project into payload.session', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events) + const stored = store.sessions.getOrCreateSession( + 'meta-triage', + { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' }, + null, + 'default' + ) + const live = makeSession(stored.id, 'codex', { + metadata: { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' } + }) + + const content = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'AGENT_NOTIFY_SUMMARY {"version":1,"agent":"overseer","project":"hapi","status":"done","action":"","summary":"Triage complete"}' + } + } + } + + const event = recorder.onAgentMessage( + toSessionSnapshot(live, stored.tag), + 'msg-meta', + content, + Date.now() + ) + + const payload = JSON.parse(event!.payloadJson!) as { + session: { id: string; tag: string | null; name: string | null; project: string | null; flavor: string } + } + expect(payload.session.name).toBe('meta HAPI triage') + expect(payload.session.tag).toBe('meta-triage') + expect(payload.session.project).toBe('hapi') + expect(payload.session.flavor).toBe('codex') + expect(payload.session.id).toBe(stored.id) }) }) diff --git a/hub/src/sync/overseerEventRecorder.ts b/hub/src/sync/overseerEventRecorder.ts index 6d58a66cde..ebf38aa09c 100644 --- a/hub/src/sync/overseerEventRecorder.ts +++ b/hub/src/sync/overseerEventRecorder.ts @@ -1,14 +1,17 @@ import { buildEventSummaryFromNotify, + buildOverseerSessionIdentity, deriveAttentionCandidate, deriveOperatorActionRequired, deriveSeverity, detectEmptyHapiEventsSentinel, detectMalformedNotifySummaryLine, mapNotifyStatusToEventType, + mergeEventPayloadWithSession, OVERSEER_STALE_SILENCE_MS, isObject, - type NotifySummary + type NotifySummary, + type OverseerSessionIdentity } from '@hapi/protocol' import { extractAssistantPlainText, @@ -18,17 +21,29 @@ import { import type { Session } from '@hapi/protocol/types' import type { EventStore, InsertSystemEventInput, StoredSystemEvent } from '../store' -type SessionSnapshot = { - id: string - flavor: string - tag: string | null - machineId: string | null -} +export type SessionSnapshot = OverseerSessionIdentity function asRecord(value: unknown): Record | null { return isObject(value) ? value as Record : null } +function buildPayload( + session: SessionSnapshot, + fields: Record, + notifyProject?: string | null +): string { + const identity = notifyProject + ? buildOverseerSessionIdentity({ + id: session.id, + flavor: session.flavor, + tag: session.tag, + metadata: { name: session.name ?? undefined }, + notifyProject + }) + : session + return mergeEventPayloadWithSession(fields, identity) +} + function isAgentMessageContent(content: unknown): boolean { const record = unwrapRoleWrappedRecordEnvelope(content) return record?.role === 'agent' @@ -65,10 +80,6 @@ function buildTags(notify: NotifySummary | null, flavor: string): string | null return parts.length > 0 ? parts.join(' ') : null } -function buildPayloadJson(fields: Record): string { - return JSON.stringify(fields) -} - export class OverseerEventRecorder { private readonly lastAgentMessageAt = new Map() private readonly emittedStaleSessions = new Set() @@ -98,7 +109,7 @@ export class OverseerEventRecorder { const plainText = extractAssistantPlainText(agentContent) if (plainText) { if (detectEmptyHapiEventsSentinel(plainText)) { - return this.insertSystemEvent({ + return this.insertSystemEvent(session, { ts, sourceKind: 'system', eventType: 'validation_error', @@ -107,13 +118,13 @@ export class OverseerEventRecorder { relatedSessionId: session.id, provenance: 'hub-inferred from empty HAPI_EVENTS sentinel pair', idempotencyKey: `session:${session.id}:message:${messageId}:validation_error:empty_hapi_events`, - payloadJson: buildPayloadJson({ messageId, plainTextPreview: plainText.slice(0, 500) }), + payloadFields: { messageId, plainTextPreview: plainText.slice(0, 500) }, severity: 1 }) } if (detectMalformedNotifySummaryLine(plainText)) { - return this.insertSystemEvent({ + return this.insertSystemEvent(session, { ts, sourceKind: 'system', eventType: 'validation_error', @@ -122,7 +133,7 @@ export class OverseerEventRecorder { relatedSessionId: session.id, provenance: 'hub-inferred from malformed AGENT_NOTIFY_SUMMARY JSON', idempotencyKey: `session:${session.id}:message:${messageId}:validation_error:malformed_notify`, - payloadJson: buildPayloadJson({ messageId }), + payloadFields: { messageId }, severity: 1 }) } @@ -135,7 +146,7 @@ export class OverseerEventRecorder { const toolFailure = extractToolFailureSummary(agentContent) if (toolFailure) { - return this.insertSystemEvent({ + return this.insertSystemEvent(session, { ts, sourceKind: 'system', sourceRef: session.id, @@ -146,7 +157,7 @@ export class OverseerEventRecorder { relatedSessionId: session.id, provenance: 'hub-inferred from tool-call-result exit code', idempotencyKey: `session:${session.id}:message:${messageId}:tool_failed`, - payloadJson: buildPayloadJson({ messageId }), + payloadFields: { messageId }, severity: deriveSeverity('failed'), tags: buildTags(null, session.flavor) }) @@ -155,12 +166,13 @@ export class OverseerEventRecorder { return null } - onSessionUpdated(session: Session): void { - this.syncPermissionRequests(session) + onSessionUpdated(session: Session, tag?: string | null): void { + this.syncPermissionRequests(session, tag ?? null) } onSessionEnd( session: Session, + tag: string | null, ts: number, reason: string | undefined, getLastAgentPlainText: () => string | null @@ -177,8 +189,8 @@ export class OverseerEventRecorder { return null } - const flavor = session.metadata?.flavor ?? 'claude' - return this.insertSystemEvent({ + const snapshot = toSessionSnapshot(session, tag) + return this.insertSystemEvent(snapshot, { ts, sourceKind: 'system', sourceRef: session.id, @@ -188,9 +200,9 @@ export class OverseerEventRecorder { relatedSessionId: session.id, provenance: 'hub-inferred from session-end completed signal', idempotencyKey: `session:${session.id}:session_end:${ts}:completed_fallback`, - payloadJson: buildPayloadJson({ reason }), + payloadFields: { reason }, severity: deriveSeverity('completed'), - tags: buildTags(null, flavor) + tags: buildTags(null, snapshot.flavor) }) } @@ -210,8 +222,8 @@ export class OverseerEventRecorder { continue } - const flavor = session.metadata?.flavor ?? 'claude' - const event = this.insertSystemEvent({ + const snapshot = toSessionSnapshot(session) + const event = this.insertSystemEvent(snapshot, { ts: now, sourceKind: 'system', sourceRef: session.id, @@ -222,9 +234,9 @@ export class OverseerEventRecorder { relatedSessionId: session.id, provenance: 'hub-inferred from session silence threshold', idempotencyKey: `session:${session.id}:stale:${Math.floor(lastAt / OVERSEER_STALE_SILENCE_MS)}`, - payloadJson: buildPayloadJson({ lastAgentMessageAt: lastAt, thresholdMs: OVERSEER_STALE_SILENCE_MS }), + payloadFields: { lastAgentMessageAt: lastAt, thresholdMs: OVERSEER_STALE_SILENCE_MS }, severity: deriveSeverity('stale'), - tags: buildTags(null, flavor) + tags: buildTags(null, snapshot.flavor) }) if (event) { this.emittedStaleSessions.add(session.id) @@ -249,7 +261,7 @@ export class OverseerEventRecorder { const operatorActionRequired = deriveOperatorActionRequired(notify.status, notify.action) const sourceRef = notify.agent ?? notify.project ?? session.tag ?? session.id - return this.insertSystemEvent({ + return this.insertSystemEvent(session, { ts, sourceKind: 'worker', sourceRef, @@ -260,23 +272,25 @@ export class OverseerEventRecorder { relatedSessionId: session.id, provenance: 'AGENT_NOTIFY_SUMMARY', idempotencyKey: `session:${session.id}:message:${messageId}:notify`, - payloadJson: buildPayloadJson({ + payloadFields: { messageId, notify_summary: notify, suggested_action: notify.action ?? null - }), + }, + notifyProject: notify.project ?? null, severity: deriveSeverity(eventType), tags: buildTags(notify, session.flavor) }) } - private syncPermissionRequests(session: Session): void { + private syncPermissionRequests(session: Session, tag: string | null): void { const requests = session.agentState?.requests ?? null if (!requests) { this.knownPermissionRequestIds.delete(session.id) return } + const snapshot = toSessionSnapshot(session, tag) const currentIds = new Set(Object.keys(requests)) const known = this.knownPermissionRequestIds.get(session.id) ?? new Set() @@ -285,8 +299,7 @@ export class OverseerEventRecorder { const request = asRecord(requests[requestId]) const toolName = typeof request?.tool === 'string' ? request.tool : 'tool' const summary = `Permission requested: ${toolName}` - const flavor = session.metadata?.flavor ?? 'claude' - this.insertSystemEvent({ + this.insertSystemEvent(snapshot, { ts: Date.now(), sourceKind: 'system', sourceRef: session.id, @@ -297,30 +310,39 @@ export class OverseerEventRecorder { relatedSessionId: session.id, provenance: 'hub-inferred from permission prompt', idempotencyKey: `session:${session.id}:permission:${requestId}`, - payloadJson: buildPayloadJson({ requestId, request }), + payloadFields: { requestId, request }, severity: deriveSeverity('approval_requested'), - tags: buildTags(null, flavor) + tags: buildTags(null, snapshot.flavor) }) } this.knownPermissionRequestIds.set(session.id, currentIds) } - private insertSystemEvent(input: Omit & { riskDetected?: 0 | 1 }): StoredSystemEvent | null { + private insertSystemEvent( + session: SessionSnapshot, + input: Omit & { + riskDetected?: 0 | 1 + payloadFields?: Record + notifyProject?: string | null + } + ): StoredSystemEvent | null { + const { payloadFields = {}, notifyProject, ...rest } = input return this.events.insert({ riskDetected: 0, - ...input + ...rest, + payloadJson: buildPayload(session, payloadFields, notifyProject) }) } } -export function toSessionSnapshot(session: Session): SessionSnapshot { - return { +export function toSessionSnapshot(session: Session, tag?: string | null): SessionSnapshot { + return buildOverseerSessionIdentity({ id: session.id, flavor: session.metadata?.flavor ?? 'claude', - tag: session.metadata?.name ?? session.metadata?.path ?? null, - machineId: null - } + tag: tag ?? null, + metadata: session.metadata + }) } export function shouldInjectNotifyContract(flavor: string | undefined | null): boolean { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 05c294e615..d19fc223fa 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -322,7 +322,10 @@ export class SyncEngine { this.sessionCache.refreshSession(event.sessionId) const after = this.sessionCache.getSession(event.sessionId) if (after) { - this.overseerEvents.onSessionUpdated(after) + this.overseerEvents.onSessionUpdated( + after, + this.store.sessions.getSession(after.id)?.tag ?? null + ) } if (after?.metadata && !this.hasSameAgentSessionIds(beforeMetadata, after.metadata)) { if (!this.canRunCursorDedup(after)) { @@ -348,8 +351,9 @@ export class SyncEngine { } const session = this.getSession(event.sessionId) if (session && event.message) { + const storedSession = this.store.sessions.getSession(event.sessionId) this.overseerEvents.onAgentMessage( - toSessionSnapshot(session), + toSessionSnapshot(session, storedSession?.tag ?? null), event.message.id, event.message.content, event.message.createdAt @@ -413,6 +417,7 @@ export class SyncEngine { if (session) { this.overseerEvents.onSessionEnd( session, + this.store.sessions.getSession(session.id)?.tag ?? null, payload.time, payload.reason, () => this.getLastAgentPlainText(session.id) diff --git a/scripts/tooling/hapi-driver-db-prep.sh b/scripts/tooling/hapi-driver-db-prep.sh index 185669b2bf..40f491cead 100755 --- a/scripts/tooling/hapi-driver-db-prep.sh +++ b/scripts/tooling/hapi-driver-db-prep.sh @@ -109,6 +109,8 @@ DROP INDEX IF EXISTS idx_events_dedupe_key; DROP INDEX IF EXISTS idx_events_type_ts; DROP INDEX IF EXISTS idx_events_session_ts; DROP TABLE IF EXISTS events; +DROP INDEX IF EXISTS idx_deleted_sessions_deleted_at; +DROP TABLE IF EXISTS deleted_sessions; COMMIT; SQL } @@ -143,6 +145,8 @@ DROP INDEX IF EXISTS idx_events_dedupe_key; DROP INDEX IF EXISTS idx_events_type_ts; DROP INDEX IF EXISTS idx_events_session_ts; DROP TABLE IF EXISTS events; +DROP INDEX IF EXISTS idx_deleted_sessions_deleted_at; +DROP TABLE IF EXISTS deleted_sessions; DROP INDEX IF EXISTS idx_session_scratchlist_session_created; DROP TABLE IF EXISTS session_scratchlist; DROP INDEX IF EXISTS idx_fcm_devices_token; diff --git a/shared/src/overseerEvents.test.ts b/shared/src/overseerEvents.test.ts index 42f79f9013..c5b55e7e8a 100644 --- a/shared/src/overseerEvents.test.ts +++ b/shared/src/overseerEvents.test.ts @@ -1,10 +1,14 @@ import { describe, expect, test } from 'bun:test' import { AGENT_NOTIFY_CONTRACT_INLINE_PREFIX, + buildOverseerSessionIdentity, deriveAttentionCandidate, + deriveSessionDisplayName, + deriveSessionProject, mapNotifyStatusToEventType, buildEventSummaryFromNotify, detectEmptyHapiEventsSentinel, + mergeEventPayloadWithSession, HAPI_EVENTS_BEGIN, HAPI_EVENTS_END } from './overseerEvents' @@ -36,4 +40,42 @@ describe('overseerEvents mapping', () => { expect(AGENT_NOTIFY_CONTRACT_INLINE_PREFIX.length).toBeGreaterThan(40) expect(AGENT_NOTIFY_CONTRACT_INLINE_PREFIX).toContain('AGENT_NOTIFY_SUMMARY') }) + + test('buildOverseerSessionIdentity captures name tag project flavor', () => { + const identity = buildOverseerSessionIdentity({ + id: 'sess-1', + flavor: 'codex', + tag: 'meta-triage', + metadata: { name: 'meta HAPI triage', path: '/coding/hapi' }, + notifyProject: 'hapi' + }) + expect(identity.name).toBe('meta HAPI triage') + expect(identity.tag).toBe('meta-triage') + expect(identity.project).toBe('hapi') + expect(identity.flavor).toBe('codex') + }) + + test('mergeEventPayloadWithSession embeds session snapshot', () => { + const json = mergeEventPayloadWithSession( + { notify_summary: { summary: 'ok' } }, + { + id: 'sess-1', + tag: 'meta-triage', + name: 'meta HAPI triage', + project: 'hapi', + flavor: 'codex' + } + ) + const payload = JSON.parse(json) as { session: { name: string } } + expect(payload.session.name).toBe('meta HAPI triage') + }) + + test('deriveSessionDisplayName prefers metadata name over tag', () => { + expect(deriveSessionDisplayName({ name: 'Display' }, 'tag-only')).toBe('Display') + expect(deriveSessionDisplayName(null, 'tag-only')).toBe('tag-only') + }) + + test('deriveSessionProject uses path basename', () => { + expect(deriveSessionProject({ path: '/coding/hapi' })).toBe('hapi') + }) }) diff --git a/shared/src/overseerEvents.ts b/shared/src/overseerEvents.ts index e5c439805c..38dd8d98b9 100644 --- a/shared/src/overseerEvents.ts +++ b/shared/src/overseerEvents.ts @@ -26,6 +26,72 @@ export const HAPI_EVENTS_END = '' export const OVERSEER_STALE_SILENCE_MS = 30 * 60 * 1000 +/** Denormalized session identity written into every overseer event payload. */ +export type OverseerSessionIdentity = { + id: string + tag: string | null + name: string | null + project: string | null + flavor: string +} + +export function deriveSessionDisplayName( + metadata: { name?: string } | null | undefined, + tag?: string | null +): string | null { + const name = metadata?.name?.trim() + if (name) return name + const tagValue = tag?.trim() + return tagValue || null +} + +export function deriveSessionProject( + metadata: { path?: string; worktree?: { name?: string } } | null | undefined +): string | null { + if (!metadata) return null + const worktreeName = metadata.worktree?.name?.trim() + if (worktreeName) return worktreeName + const path = metadata.path?.trim() + if (!path) return null + const parts = path.split('/').filter(Boolean) + return parts.length > 0 ? parts[parts.length - 1]! : null +} + +export function buildOverseerSessionIdentity(input: { + id: string + flavor: string + tag?: string | null + metadata?: { name?: string; path?: string; worktree?: { name?: string } } | null + notifyProject?: string | null +}): OverseerSessionIdentity { + const tag = input.tag ?? null + const derivedProject = deriveSessionProject(input.metadata) + const notifyProject = input.notifyProject?.trim() + return { + id: input.id, + tag, + name: deriveSessionDisplayName(input.metadata, tag), + project: notifyProject || derivedProject, + flavor: input.flavor + } +} + +export function mergeEventPayloadWithSession( + payloadFields: Record, + session: OverseerSessionIdentity +): string { + return JSON.stringify({ + ...payloadFields, + session: { + id: session.id, + tag: session.tag, + name: session.name, + project: session.project, + flavor: session.flavor + } + }) +} + export function mapNotifyStatusToEventType(status: string | undefined): string { switch (status) { case 'done': From 20efb036769abbd3f17fab3e4c46dbeedd058d85 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:27:59 +0100 Subject: [PATCH 39/39] feat(overseer): inbox substrate with dumb v1 ordering (#23) Add init-gated inbox_items schema, per-session promotion from attention events, coarse-rank/oldest-within ordering, operator-action logging, REST + settings debug pane stacked on the #22 events substrate. Co-authored-by: Cursor --- e2e/inbox-debug.spec.ts | 35 ++ hub/src/store/inboxItems.test.ts | 230 ++++++++++ hub/src/store/inboxItems.ts | 405 ++++++++++++++++++ hub/src/store/inboxStore.ts | 53 +++ hub/src/store/index.ts | 12 +- hub/src/store/migration-v11.test.ts | 10 +- hub/src/store/schemaV11Soup.ts | 5 +- hub/src/store/sessions.ts | 2 + hub/src/sync/overseerEventRecorder.test.ts | 55 ++- hub/src/sync/overseerEventRecorder.ts | 12 +- hub/src/sync/sessionCache.ts | 1 + hub/src/sync/syncEngine.ts | 21 +- hub/src/web/routes/inboxItems.test.ts | 92 ++++ hub/src/web/routes/inboxItems.ts | 84 ++++ hub/src/web/server.ts | 2 + shared/src/index.ts | 1 + shared/src/overseerInbox.test.ts | 49 +++ shared/src/overseerInbox.ts | 197 +++++++++ web/e2e-fixtures/inbox-debug-fixture.html | 12 + web/e2e-fixtures/inbox-debug-fixture.tsx | 93 ++++ web/src/api/client.ts | 29 ++ .../settings/InboxDebugControls.tsx | 149 +++++++ web/src/routes/settings/index.tsx | 2 + 23 files changed, 1540 insertions(+), 11 deletions(-) create mode 100644 e2e/inbox-debug.spec.ts create mode 100644 hub/src/store/inboxItems.test.ts create mode 100644 hub/src/store/inboxItems.ts create mode 100644 hub/src/store/inboxStore.ts create mode 100644 hub/src/web/routes/inboxItems.test.ts create mode 100644 hub/src/web/routes/inboxItems.ts create mode 100644 shared/src/overseerInbox.test.ts create mode 100644 shared/src/overseerInbox.ts create mode 100644 web/e2e-fixtures/inbox-debug-fixture.html create mode 100644 web/e2e-fixtures/inbox-debug-fixture.tsx create mode 100644 web/src/components/settings/InboxDebugControls.tsx diff --git a/e2e/inbox-debug.spec.ts b/e2e/inbox-debug.spec.ts new file mode 100644 index 0000000000..e0154981bb --- /dev/null +++ b/e2e/inbox-debug.spec.ts @@ -0,0 +1,35 @@ +/* + * Playwright smoke for InboxDebugControls (#23). + */ + +import { test, expect } from '@playwright/test' + +test.describe('inbox debug viewer e2e', () => { + test('expands, loads fixture items, action buttons work', async ({ page }) => { + await page.goto('/e2e-fixtures/inbox-debug-fixture.html') + await expect(page.getByTestId('inbox-debug-fixture')).toBeVisible() + + const toggle = page.getByRole('button', { name: 'Attention inbox (debug)' }) + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await toggle.click() + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await expect(page.getByText('CI auth failed on push')).toBeVisible() + await expect(page.getByText('feat: inbox substrate')).toBeVisible() + await expect(page.getByText('1 items')).toBeVisible() + await expect(page.getByText('BLOCKED tier')).toBeVisible() + + await page.getByRole('button', { name: 'done', exact: true }).click() + await page.getByRole('button', { name: 'Refresh' }).click() + await expect(page.getByText('1 items')).toBeVisible() + }) + + test('shows error state when fetch fails', async ({ page }) => { + await page.goto('/e2e-fixtures/inbox-debug-fixture.html') + await page.evaluate(() => window.__inboxDebugE2E!.setError('boom')) + + const toggle = page.getByRole('button', { name: 'Attention inbox (debug)' }) + await toggle.click() + await expect(page.getByText('fixture fetch failed')).toBeVisible() + }) +}) diff --git a/hub/src/store/inboxItems.test.ts b/hub/src/store/inboxItems.test.ts new file mode 100644 index 0000000000..b01933ed86 --- /dev/null +++ b/hub/src/store/inboxItems.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'bun:test' +import { buildOverseerSessionIdentity, mergeEventPayloadWithSession } from '@hapi/protocol' +import { Store } from './index' +import type { StoredSession } from './types' +import { deleteSession } from './sessions' +import { Database } from 'bun:sqlite' + +function payloadForSession(session: StoredSession, extra: Record = {}): string { + const metadata = session.metadata as { flavor?: string; name?: string; path?: string } | null + return mergeEventPayloadWithSession(extra, buildOverseerSessionIdentity({ + id: session.id, + flavor: metadata?.flavor ?? 'codex', + tag: session.tag, + metadata + })) +} + +describe('Overseer inbox schema (init-gated, not SCHEMA_VERSION)', () => { + it('fresh DB has inbox tables after Store init', () => { + const store = new Store(':memory:') + const db: Database = (store as unknown as { db: Database }).db + const tables = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('inbox_items', 'inbox_item_source_events', 'inbox_operator_actions')" + ).all() as Array<{ name: string }> + const names = new Set(tables.map((row) => row.name)) + expect(names.has('inbox_items')).toBe(true) + expect(names.has('inbox_item_source_events')).toBe(true) + expect(names.has('inbox_operator_actions')).toBe(true) + }) + + it('promotes attention events into one active item per session', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('inbox-promo', { flavor: 'codex', name: 'peer-x' }, null, 'default') + + const blocked = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + sourceRef: 'peer', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'CI failed', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + expect(blocked).not.toBeNull() + store.inbox.promoteAttentionEvent(blocked!) + + let items = store.inbox.list({ activeOnly: true }) + expect(items).toHaveLength(1) + expect(items[0]?.category).toBe('BLOCKED') + expect(items[0]?.title).toBe('peer-x') + expect(items[0]?.sourceEventIds).toEqual([blocked!.id]) + + const review = store.events.insert({ + ts: 2000, + sourceKind: 'worker', + sourceRef: 'peer', + eventType: 'needs_review', + attentionCandidate: 1, + summary: 'Please review diff', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + store.inbox.promoteAttentionEvent(review!) + + items = store.inbox.list({ activeOnly: true }) + expect(items).toHaveLength(1) + expect(items[0]?.sourceEventIds.sort()).toEqual([blocked!.id, review!.id].sort()) + expect(items[0]?.category).toBe('REVIEW') + }) + + it('orders active items by coarse rank then oldest-first within tier', () => { + const store = new Store(':memory:') + const sessionA = store.sessions.getOrCreateSession('sess-a', { name: 'a' }, null, 'default') + const sessionB = store.sessions.getOrCreateSession('sess-b', { name: 'b' }, null, 'default') + const sessionC = store.sessions.getOrCreateSession('sess-c', { name: 'c' }, null, 'default') + + const oldBlocked = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'old blocked', + relatedSessionId: sessionA.id, + payloadJson: payloadForSession(sessionA), + provenance: 'test' + }) + const newBlocked = store.events.insert({ + ts: 5000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'new blocked', + relatedSessionId: sessionB.id, + payloadJson: payloadForSession(sessionB), + provenance: 'test' + }) + const approval = store.events.insert({ + ts: 3000, + sourceKind: 'worker', + eventType: 'approval_requested', + attentionCandidate: 1, + summary: 'approve push', + relatedSessionId: sessionC.id, + payloadJson: payloadForSession(sessionC), + provenance: 'test' + }) + + store.inbox.promoteAttentionEvent(oldBlocked!) + store.inbox.promoteAttentionEvent(newBlocked!) + store.inbox.promoteAttentionEvent(approval!) + + const items = store.inbox.list({ activeOnly: true }) + expect(items.map((item) => item.title)).toEqual(['c', 'a', 'b']) + }) + + it('uses artifact ref as title when present', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('artifact', { name: 'fallback-name' }, null, 'default') + const refs = JSON.stringify([{ kind: 'github_pr', title: 'feat: inbox substrate' }]) + const event = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'needs_decision', + attentionCandidate: 1, + summary: 'Need merge approval', + artifactRefs: refs, + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + store.inbox.promoteAttentionEvent(event!) + const item = store.inbox.list()[0] + expect(item?.title).toBe('feat: inbox substrate') + expect(item?.reasonForPriority).toContain('QUESTION tier') + }) + + it('records operator actions as training labels', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('actions', { name: 'actions' }, null, 'default') + const event = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'blocked', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + store.inbox.promoteAttentionEvent(event!) + const itemId = store.inbox.list()[0]!.id + + const updated = store.inbox.recordOperatorAction(itemId, 'done', 'handled offline') + expect(updated?.status).toBe('resolved') + expect(updated?.operatorFeedback).toBe('handled offline') + + const db: Database = (store as unknown as { db: Database }).db + const actions = db.prepare( + 'SELECT action, status_after FROM inbox_operator_actions WHERE inbox_item_id = ?' + ).all(itemId) as Array<{ action: string; status_after: string }> + expect(actions).toEqual([{ action: 'done', status_after: 'resolved' }]) + }) + + it('deleteSession detaches inbox_items instead of FK-failing', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('inbox-del-tag', {}, null, 'default') + const db: Database = (store as unknown as { db: Database }).db + db.prepare(` + INSERT INTO inbox_items ( + status, priority, base_priority, source_event_ids, related_inbox_ids, + attention_class, created_at, updated_at, related_session_id, title, category, summary + ) VALUES ( + 'new', 10, 10, '[]', '[]', 'live', 1, 1, ?, 't', 'BLOCKED', 's' + ) + `).run(session.id) + + expect(deleteSession(db, session.id, 'default')).toBe(true) + const row = db.prepare( + 'SELECT related_session_id FROM inbox_items LIMIT 1' + ).get() as { related_session_id: string | null } + expect(row.related_session_id).toBeNull() + }) + + it('source_event junction supports lookup by event id', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('join', { name: 'join' }, null, 'default') + const event = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'failed', + attentionCandidate: 1, + summary: 'failed', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + const item = store.inbox.promoteAttentionEvent(event!) + const db: Database = (store as unknown as { db: Database }).db + const link = db.prepare( + 'SELECT inbox_item_id FROM inbox_item_source_events WHERE event_id = ?' + ).get(event!.id) as { inbox_item_id: number } + expect(link.inbox_item_id).toBe(item!.id) + }) + + it('keeps denormalized title after session delete', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('gone', { name: 'meta HAPI triage' }, null, 'default') + const event = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'blocked', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + const item = store.inbox.promoteAttentionEvent(event!) + expect(item?.title).toBe('meta HAPI triage') + + expect(store.sessions.deleteSession(session.id, 'default')).toBe(true) + + const after = store.inbox.getById(item!.id) + expect(after?.title).toBe('meta HAPI triage') + expect(after?.relatedSessionId).toBeNull() + }) +}) diff --git a/hub/src/store/inboxItems.ts b/hub/src/store/inboxItems.ts new file mode 100644 index 0000000000..e59b4f1ec4 --- /dev/null +++ b/hub/src/store/inboxItems.ts @@ -0,0 +1,405 @@ +import type { Database } from 'bun:sqlite' +import { + buildExplainPriority, + buildInboxTitleFromEvent, + computeCoarseBasePriority, + isActiveInboxStatus, + mapEventTypeToInboxCategory, + mapOperatorActionToStatus, + type InboxOperatorAction +} from '@hapi/protocol' +import type { StoredSystemEvent } from './events' +import { getSystemEventById, listSystemEvents } from './events' + +export type StoredInboxItem = { + id: number + status: string + priority: number + basePriority: number + agingFactor: number | null + timeCriticality: number | null + decayAfter: number | null + reasonForPriority: string | null + sourceEventIds: number[] + relatedInboxIds: number[] + artifactRefs: string | null + suggestedAction: string | null + deadline: number | null + operatorFeedback: string | null + surfacedAt: number | null + resolvedAt: number | null + snoozedUntil: number | null + attentionClass: string + breakpointClass: string | null + createdAt: number + updatedAt: number + relatedSessionId: string | null + title: string + category: string + summary: string +} + +export type StoredInboxOperatorAction = { + id: number + inboxItemId: number + action: InboxOperatorAction + statusAfter: string + feedback: string | null + createdAt: number +} + +export type ListInboxItemsOptions = { + limit?: number + activeOnly?: boolean + sessionId?: string | null +} + +type InboxItemRow = { + id: number + status: string + priority: number + base_priority: number + aging_factor: number | null + time_criticality: number | null + decay_after: number | null + reason_for_priority: string | null + source_event_ids: string | null + related_inbox_ids: string | null + artifact_refs: string | null + suggested_action: string | null + deadline: number | null + operator_feedback: string | null + surfaced_at: number | null + resolved_at: number | null + snoozed_until: number | null + attention_class: string + breakpoint_class: string | null + created_at: number + updated_at: number + related_session_id: string | null + title: string + category: string + summary: string +} + +function parseIdArray(raw: string | null | undefined): number[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.filter((value): value is number => typeof value === 'number') + } catch { + return [] + } +} + +function mapRow(row: InboxItemRow): StoredInboxItem { + return { + id: row.id, + status: row.status, + priority: row.priority, + basePriority: row.base_priority, + agingFactor: row.aging_factor, + timeCriticality: row.time_criticality, + decayAfter: row.decay_after, + reasonForPriority: row.reason_for_priority, + sourceEventIds: parseIdArray(row.source_event_ids), + relatedInboxIds: parseIdArray(row.related_inbox_ids), + artifactRefs: row.artifact_refs, + suggestedAction: row.suggested_action, + deadline: row.deadline, + operatorFeedback: row.operator_feedback, + surfacedAt: row.surfaced_at, + resolvedAt: row.resolved_at, + snoozedUntil: row.snoozed_until, + attentionClass: row.attention_class, + breakpointClass: row.breakpoint_class, + createdAt: row.created_at, + updatedAt: row.updated_at, + relatedSessionId: row.related_session_id, + title: row.title, + category: row.category, + summary: row.summary + } +} + +function syncSourceEventLinks(db: Database, inboxItemId: number, eventIds: number[]): void { + db.prepare('DELETE FROM inbox_item_source_events WHERE inbox_item_id = ?').run(inboxItemId) + const insert = db.prepare( + 'INSERT OR IGNORE INTO inbox_item_source_events (inbox_item_id, event_id) VALUES (?, ?)' + ) + for (const eventId of eventIds) { + insert.run(inboxItemId, eventId) + } +} + +/** Clear session FK refs so DELETE FROM sessions succeeds (items are audit-retained). */ +export function detachSessionInboxItems(db: Database, sessionId: string): number { + const result = db.prepare( + 'UPDATE inbox_items SET related_session_id = NULL WHERE related_session_id = ?' + ).run(sessionId) + return result.changes +} + +export function repointSessionInboxItems(db: Database, fromSessionId: string, toSessionId: string): number { + if (fromSessionId === toSessionId) return 0 + const countRow = db.prepare( + 'SELECT COUNT(*) as count FROM inbox_items WHERE related_session_id = ?' + ).get(fromSessionId) as { count: number } + const pending = countRow.count + if (pending === 0) return 0 + db.prepare( + 'UPDATE inbox_items SET related_session_id = ? WHERE related_session_id = ?' + ).run(toSessionId, fromSessionId) + return pending +} + +export function getInboxItemById(db: Database, id: number): StoredInboxItem | null { + const row = db.prepare('SELECT * FROM inbox_items WHERE id = ?').get(id) as InboxItemRow | undefined + return row ? mapRow(row) : null +} + +export function countInboxItems(db: Database): number { + const row = db.prepare('SELECT COUNT(*) AS count FROM inbox_items').get() as { count: number } + return row.count +} + +export function listInboxItems(db: Database, options: ListInboxItemsOptions = {}): StoredInboxItem[] { + const limit = Math.min(Math.max(options.limit ?? 50, 1), 200) + const clauses: string[] = [] + const params: Array = [] + + if (options.activeOnly) { + clauses.push("status IN ('new', 'surfaced', 'deferred', 'snoozed')") + } + if (options.sessionId) { + clauses.push('related_session_id = ?') + params.push(options.sessionId) + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + const rows = db.prepare( + `SELECT * FROM inbox_items ${where} + ORDER BY base_priority ASC, created_at ASC + LIMIT ?` + ).all(...params, limit) as InboxItemRow[] + + return rows.map(mapRow) +} + +export function findActiveInboxItemForSession(db: Database, sessionId: string): StoredInboxItem | null { + const row = db.prepare(` + SELECT * FROM inbox_items + WHERE related_session_id = ? + AND status IN ('new', 'surfaced', 'deferred', 'snoozed') + ORDER BY updated_at DESC + LIMIT 1 + `).get(sessionId) as InboxItemRow | undefined + return row ? mapRow(row) : null +} + +export function promoteAttentionEvent( + db: Database, + event: StoredSystemEvent +): StoredInboxItem | null { + if (event.attentionCandidate !== 1) return null + if (!event.relatedSessionId) return null + + const now = Date.now() + const category = mapEventTypeToInboxCategory(event.eventType) + const basePriority = computeCoarseBasePriority(event.eventType) + const title = buildInboxTitleFromEvent(event.artifactRefs, event.payloadJson, event.summary) + const suggestedAction = extractSuggestedAction(event.payloadJson) + const existing = findActiveInboxItemForSession(db, event.relatedSessionId) + + const sourceEventIds = existing + ? Array.from(new Set([...existing.sourceEventIds, event.id])) + : [event.id] + + const reasonForPriority = buildExplainPriority(category, existing?.createdAt ?? event.ts, sourceEventIds, now) + + if (existing) { + db.prepare(` + UPDATE inbox_items SET + status = 'surfaced', + priority = ?, + base_priority = ?, + reason_for_priority = ?, + source_event_ids = ?, + artifact_refs = COALESCE(?, artifact_refs), + suggested_action = COALESCE(?, suggested_action), + title = ?, + category = ?, + summary = ?, + updated_at = ?, + surfaced_at = COALESCE(surfaced_at, ?) + WHERE id = ? + `).run( + basePriority, + basePriority, + reasonForPriority, + JSON.stringify(sourceEventIds), + event.artifactRefs, + suggestedAction, + title, + category, + event.summary, + now, + now, + existing.id + ) + syncSourceEventLinks(db, existing.id, sourceEventIds) + return getInboxItemById(db, existing.id) + } + + const insert = db.prepare(` + INSERT INTO inbox_items ( + status, priority, base_priority, aging_factor, time_criticality, decay_after, + reason_for_priority, source_event_ids, related_inbox_ids, artifact_refs, + suggested_action, deadline, operator_feedback, surfaced_at, resolved_at, + snoozed_until, attention_class, breakpoint_class, created_at, updated_at, + related_session_id, title, category, summary + ) VALUES ( + 'new', ?, ?, NULL, NULL, NULL, + ?, ?, '[]', ?, + ?, NULL, NULL, ?, NULL, + NULL, 'live', NULL, ?, ?, + ?, ?, ?, ? + ) + `) + + const result = insert.run( + basePriority, + basePriority, + reasonForPriority, + JSON.stringify(sourceEventIds), + event.artifactRefs, + suggestedAction, + now, + event.ts, + now, + event.relatedSessionId, + title, + category, + event.summary + ) + + const id = Number(result.lastInsertRowid) + syncSourceEventLinks(db, id, sourceEventIds) + return getInboxItemById(db, id) +} + +export function recordInboxOperatorAction( + db: Database, + inboxItemId: number, + action: InboxOperatorAction, + feedback: string | null = null, + snoozedUntil: number | null = null +): StoredInboxItem | null { + const item = getInboxItemById(db, inboxItemId) + if (!item) return null + + const now = Date.now() + const statusAfter = mapOperatorActionToStatus(action) + db.prepare(` + INSERT INTO inbox_operator_actions (inbox_item_id, action, status_after, feedback, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(inboxItemId, action, statusAfter, feedback, now) + + const resolvedAt = statusAfter === 'resolved' || statusAfter === 'obsoleted' ? now : null + db.prepare(` + UPDATE inbox_items SET + status = ?, + operator_feedback = ?, + updated_at = ?, + snoozed_until = ?, + resolved_at = COALESCE(?, resolved_at) + WHERE id = ? + `).run(statusAfter, feedback, now, snoozedUntil, resolvedAt, inboxItemId) + + return getInboxItemById(db, inboxItemId) +} + +function extractSuggestedAction(payloadJson: string | null): string | null { + if (!payloadJson) return null + try { + const payload = JSON.parse(payloadJson) as { suggested_action?: unknown; notify_summary?: { action?: unknown } } + if (typeof payload.suggested_action === 'string' && payload.suggested_action.trim()) { + return payload.suggested_action.trim() + } + if (typeof payload.notify_summary?.action === 'string' && payload.notify_summary.action.trim()) { + return payload.notify_summary.action.trim() + } + } catch { + return null + } + return null +} + +/** + * Idempotent Overseer inbox DDL — runs on every Store init, NOT gated on SCHEMA_VERSION. + */ +export function ensureOverseerInboxSchema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS inbox_items ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + priority REAL NOT NULL, + base_priority REAL NOT NULL, + aging_factor REAL, + time_criticality REAL, + decay_after INTEGER, + reason_for_priority TEXT, + source_event_ids TEXT, + related_inbox_ids TEXT, + artifact_refs TEXT, + suggested_action TEXT, + deadline INTEGER, + operator_feedback TEXT, + surfaced_at INTEGER, + resolved_at INTEGER, + snoozed_until INTEGER, + attention_class TEXT NOT NULL DEFAULT 'live', + breakpoint_class TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + related_session_id TEXT REFERENCES sessions(id), + title TEXT NOT NULL, + category TEXT NOT NULL, + summary TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_inbox_items_session ON inbox_items(related_session_id); + CREATE INDEX IF NOT EXISTS idx_inbox_items_queue ON inbox_items(status, base_priority, created_at); + + CREATE TABLE IF NOT EXISTS inbox_item_source_events ( + inbox_item_id INTEGER NOT NULL REFERENCES inbox_items(id) ON DELETE CASCADE, + event_id INTEGER NOT NULL REFERENCES events(id), + PRIMARY KEY (inbox_item_id, event_id) + ); + CREATE INDEX IF NOT EXISTS idx_inbox_item_source_events_event ON inbox_item_source_events(event_id); + + CREATE TABLE IF NOT EXISTS inbox_operator_actions ( + id INTEGER PRIMARY KEY, + inbox_item_id INTEGER NOT NULL REFERENCES inbox_items(id) ON DELETE CASCADE, + action TEXT NOT NULL, + status_after TEXT NOT NULL, + feedback TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_inbox_operator_actions_item ON inbox_operator_actions(inbox_item_id, created_at DESC); + `) +} + +export function dropOverseerInboxSchema(db: Database): void { + db.exec(` + DROP INDEX IF EXISTS idx_inbox_operator_actions_item; + DROP TABLE IF EXISTS inbox_operator_actions; + DROP INDEX IF EXISTS idx_inbox_item_source_events_event; + DROP TABLE IF EXISTS inbox_item_source_events; + DROP INDEX IF EXISTS idx_inbox_items_queue; + DROP INDEX IF EXISTS idx_inbox_items_session; + DROP TABLE IF EXISTS inbox_items; + `) +} + +export { isActiveInboxStatus, listSystemEvents, getSystemEventById } diff --git a/hub/src/store/inboxStore.ts b/hub/src/store/inboxStore.ts new file mode 100644 index 0000000000..35e6d3ce7f --- /dev/null +++ b/hub/src/store/inboxStore.ts @@ -0,0 +1,53 @@ +import type { Database } from 'bun:sqlite' +import type { InboxOperatorAction } from '@hapi/protocol' +import type { StoredSystemEvent } from './events' +import { + countInboxItems, + findActiveInboxItemForSession, + getInboxItemById, + listInboxItems, + promoteAttentionEvent, + recordInboxOperatorAction, + repointSessionInboxItems, + type ListInboxItemsOptions, + type StoredInboxItem +} from './inboxItems' + +export type { ListInboxItemsOptions, StoredInboxItem } + +export class InboxStore { + constructor(private readonly db: Database) {} + + promoteAttentionEvent(event: StoredSystemEvent): StoredInboxItem | null { + return promoteAttentionEvent(this.db, event) + } + + list(options: ListInboxItemsOptions = {}): StoredInboxItem[] { + return listInboxItems(this.db, options) + } + + count(): number { + return countInboxItems(this.db) + } + + getById(id: number): StoredInboxItem | null { + return getInboxItemById(this.db, id) + } + + findActiveForSession(sessionId: string): StoredInboxItem | null { + return findActiveInboxItemForSession(this.db, sessionId) + } + + recordOperatorAction( + inboxItemId: number, + action: InboxOperatorAction, + feedback: string | null = null, + snoozedUntil: number | null = null + ): StoredInboxItem | null { + return recordInboxOperatorAction(this.db, inboxItemId, action, feedback, snoozedUntil) + } + + repointSession(fromSessionId: string, toSessionId: string): number { + return repointSessionInboxItems(this.db, fromSessionId, toSessionId) + } +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 5e2162aaf6..366b9b3fac 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -10,7 +10,9 @@ import { ScratchlistStore } from './scratchlistStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' import { EventStore } from './eventStore' +import { InboxStore } from './inboxStore' import { ensureOverseerEventsSchema, ensureDeletedSessionsSchema } from './events' +import { ensureOverseerInboxSchema } from './inboxItems' export type { StoredMachine, @@ -31,7 +33,9 @@ export { ScratchlistStore } from './scratchlistStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' export { EventStore } from './eventStore' +export { InboxStore } from './inboxStore' export type { InsertSystemEventInput, ListSystemEventsOptions, StoredSystemEvent } from './eventStore' +export type { ListInboxItemsOptions, StoredInboxItem } from './inboxStore' const SCHEMA_VERSION: number = 11 const REQUIRED_TABLES = [ @@ -44,7 +48,10 @@ const REQUIRED_TABLES = [ 'session_scratchlist', 'events', 'event_links', - 'deleted_sessions' + 'deleted_sessions', + 'inbox_items', + 'inbox_item_source_events', + 'inbox_operator_actions' ] as const export class Store { @@ -60,6 +67,7 @@ export class Store { readonly fcm: FcmStore readonly scratchlist: ScratchlistStore readonly events: EventStore + readonly inbox: InboxStore /** * Filesystem path of the underlying SQLite database, or ':memory:' for @@ -113,6 +121,7 @@ export class Store { this.fcm = new FcmStore(this.db) this.scratchlist = new ScratchlistStore(this.db) this.events = new EventStore(this.db) + this.inbox = new InboxStore(this.db) } close(): void { @@ -198,6 +207,7 @@ export class Store { private finishSchemaInit(): void { ensureOverseerEventsSchema(this.db) ensureDeletedSessionsSchema(this.db) + ensureOverseerInboxSchema(this.db) this.assertRequiredTablesPresent() } diff --git a/hub/src/store/migration-v11.test.ts b/hub/src/store/migration-v11.test.ts index 8a79fb851b..46a77d64e1 100644 --- a/hub/src/store/migration-v11.test.ts +++ b/hub/src/store/migration-v11.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test' import type { Session } from '@hapi/protocol/types' import { Store } from './index' import { dropOverseerEventsSchema, ensureDeletedSessionsSchema, ensureOverseerEventsSchema, repointSessionEvents } from './events' +import { ensureOverseerInboxSchema } from './inboxItems' import { deleteSession } from './sessions' import { applySoupV10ToV11Migration } from './schemaV11Soup' import { OverseerEventRecorder, toSessionSnapshot } from '../sync/overseerEventRecorder' @@ -11,17 +12,20 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' describe('Overseer events schema (init-gated, not SCHEMA_VERSION)', () => { - it('fresh DB has events, event_links, events_fts, and deleted_sessions after Store init', () => { + it('fresh DB has events, inbox, and deleted_sessions tables after Store init', () => { const store = new Store(':memory:') const db: Database = (store as unknown as { db: Database }).db const tables = db.prepare( - "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table') AND name IN ('events', 'event_links', 'events_fts', 'deleted_sessions')" + "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table') AND name IN ('events', 'event_links', 'events_fts', 'deleted_sessions', 'inbox_items', 'inbox_item_source_events', 'inbox_operator_actions')" ).all() as Array<{ name: string }> const names = new Set(tables.map((row) => row.name)) expect(names.has('events')).toBe(true) expect(names.has('event_links')).toBe(true) expect(names.has('events_fts')).toBe(true) expect(names.has('deleted_sessions')).toBe(true) + expect(names.has('inbox_items')).toBe(true) + expect(names.has('inbox_item_source_events')).toBe(true) + expect(names.has('inbox_operator_actions')).toBe(true) }) it('v11 DB stamped without events self-heals on Store open (incident regression)', () => { @@ -146,6 +150,7 @@ describe('Overseer events schema (init-gated, not SCHEMA_VERSION)', () => { createV10Schema(db) ensureOverseerEventsSchema(db) ensureDeletedSessionsSchema(db) + ensureOverseerInboxSchema(db) db.exec(`INSERT INTO sessions (id, tag, namespace, created_at, updated_at, seq, metadata) VALUES ('s-del', 'del-tag', 'default', 1000, 1000, 0, '{"flavor":"codex","path":"/coding/hapi","name":"meta HAPI triage","host":"local"}')`) @@ -245,6 +250,7 @@ describe('Overseer events schema (init-gated, not SCHEMA_VERSION)', () => { createV10Schema(db) ensureOverseerEventsSchema(db) ensureDeletedSessionsSchema(db) + ensureOverseerInboxSchema(db) db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) VALUES ('s-old', 'default', 1000, 1000, 0), ('s-new', 'default', 1000, 1000, 0)`) diff --git a/hub/src/store/schemaV11Soup.ts b/hub/src/store/schemaV11Soup.ts index eb44fd2854..dd314bed74 100644 --- a/hub/src/store/schemaV11Soup.ts +++ b/hub/src/store/schemaV11Soup.ts @@ -41,5 +41,8 @@ export const SOUP_V11_TABLES = [ 'events', 'event_links', 'events_fts', - 'deleted_sessions' + 'deleted_sessions', + 'inbox_items', + 'inbox_item_source_events', + 'inbox_operator_actions' ] as const diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 8f0325e8fb..e90f68de94 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -2,6 +2,7 @@ import type { Database } from 'bun:sqlite' import { randomUUID } from 'node:crypto' import { detachSessionEvents, tombstoneDeletedSession } from './events' +import { detachSessionInboxItems } from './inboxItems' import { buildOverseerSessionIdentity } from '@hapi/protocol' import type { Metadata } from '@hapi/protocol/types' import type { StoredSession, VersionedUpdateResult } from './types' @@ -617,6 +618,7 @@ export function deleteSession(db: Database, id: string, namespace: string): bool Date.now() ) detachSessionEvents(db, id) + detachSessionInboxItems(db, id) const result = db.prepare( 'DELETE FROM sessions WHERE id = ? AND namespace = ?' ).run(id, namespace) diff --git a/hub/src/sync/overseerEventRecorder.test.ts b/hub/src/sync/overseerEventRecorder.test.ts index 0e328f3457..cccf1f2902 100644 --- a/hub/src/sync/overseerEventRecorder.test.ts +++ b/hub/src/sync/overseerEventRecorder.test.ts @@ -29,7 +29,7 @@ function makeSession(id: string, flavor: string, overrides?: Partial): describe('OverseerEventRecorder', () => { it('records AGENT_NOTIFY_SUMMARY from codex assistant text', () => { const store = new Store(':memory:') - const recorder = new OverseerEventRecorder(store.events) + const recorder = new OverseerEventRecorder(store.events, store.inbox) const session = store.sessions.getOrCreateSession('test', { flavor: 'codex', path: '/tmp', host: 'local' }, null, 'default') const content = { @@ -63,11 +63,17 @@ describe('OverseerEventRecorder', () => { expect(payload.session.tag).toBe('test') expect(payload.session.project).toBe('demo') expect(payload.session.flavor).toBe('codex') + + expect(store.inbox.count()).toBe(1) + const item = store.inbox.list({ activeOnly: true })[0] + expect(item?.category).toBe('FINALE') + expect(item?.sourceEventIds).toEqual([event!.id]) + expect(item?.title).toBe('test') }) it('captures done without action as captured-only', () => { const store = new Store(':memory:') - const recorder = new OverseerEventRecorder(store.events) + const recorder = new OverseerEventRecorder(store.events, store.inbox) const session = store.sessions.getOrCreateSession('test2', { flavor: 'claude', path: '/tmp', host: 'local' }, null, 'default') const content = { @@ -93,7 +99,7 @@ describe('OverseerEventRecorder', () => { it('synthesizes approval_requested from permission prompts', () => { const store = new Store(':memory:') - const recorder = new OverseerEventRecorder(store.events) + const recorder = new OverseerEventRecorder(store.events, store.inbox) const session = store.sessions.getOrCreateSession('perm', { flavor: 'claude', path: '/tmp', host: 'local' }, null, 'default') const live = makeSession(session.id, 'claude') @@ -112,11 +118,14 @@ describe('OverseerEventRecorder', () => { const payload = JSON.parse(events[0]!.payloadJson!) as { session: { name: string | null } } expect(payload.session.name).toBe('perm') + expect(store.inbox.count()).toBe(1) + expect(store.inbox.list()[0]?.category).toBe('APPROVAL') + expect(store.inbox.list()[0]?.title).toBe('perm') }) it('denormalizes session display name and project into payload.session', () => { const store = new Store(':memory:') - const recorder = new OverseerEventRecorder(store.events) + const recorder = new OverseerEventRecorder(store.events, store.inbox) const stored = store.sessions.getOrCreateSession( 'meta-triage', { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' }, @@ -154,4 +163,42 @@ describe('OverseerEventRecorder', () => { expect(payload.session.flavor).toBe('codex') expect(payload.session.id).toBe(stored.id) }) + + it('titles inbox items from payload.session.name after session delete', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const stored = store.sessions.getOrCreateSession( + 'meta-triage', + { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' }, + null, + 'default' + ) + + recorder.onAgentMessage( + toSessionSnapshot(makeSession(stored.id, 'codex', { + metadata: { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' } + }), stored.tag), + 'msg-attn', + { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'AGENT_NOTIFY_SUMMARY {"version":1,"status":"blocked","action":"Fix CI","summary":"CI failed"}' + } + } + }, + Date.now() + ) + + const itemBefore = store.inbox.list()[0] + expect(itemBefore?.title).toBe('meta HAPI triage') + + expect(store.sessions.deleteSession(stored.id, 'default')).toBe(true) + + const itemAfter = store.inbox.getById(itemBefore!.id) + expect(itemAfter?.title).toBe('meta HAPI triage') + expect(itemAfter?.relatedSessionId).toBeNull() + }) }) diff --git a/hub/src/sync/overseerEventRecorder.ts b/hub/src/sync/overseerEventRecorder.ts index ebf38aa09c..0c0e4583f1 100644 --- a/hub/src/sync/overseerEventRecorder.ts +++ b/hub/src/sync/overseerEventRecorder.ts @@ -20,6 +20,7 @@ import { } from '@hapi/protocol/messages' import type { Session } from '@hapi/protocol/types' import type { EventStore, InsertSystemEventInput, StoredSystemEvent } from '../store' +import type { InboxStore } from '../store/inboxStore' export type SessionSnapshot = OverseerSessionIdentity @@ -85,7 +86,10 @@ export class OverseerEventRecorder { private readonly emittedStaleSessions = new Set() private readonly knownPermissionRequestIds = new Map>() - constructor(private readonly events: EventStore) {} + constructor( + private readonly events: EventStore, + private readonly inbox?: InboxStore + ) {} list(options: Parameters[0] = {}): StoredSystemEvent[] { return this.events.list(options) @@ -328,11 +332,15 @@ export class OverseerEventRecorder { } ): StoredSystemEvent | null { const { payloadFields = {}, notifyProject, ...rest } = input - return this.events.insert({ + const stored = this.events.insert({ riskDetected: 0, ...rest, payloadJson: buildPayload(session, payloadFields, notifyProject) }) + if (stored && stored.attentionCandidate === 1 && this.inbox) { + this.inbox.promoteAttentionEvent(stored) + } + return stored } } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 9a37636adc..50c9db15b3 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1045,6 +1045,7 @@ export class SessionCache { if (options.deleteOldSession) { this.store.events.repointSession(oldSessionId, newSessionId) + this.store.inbox.repointSession(oldSessionId, newSessionId) const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) if (!deleted) { throw new Error('Failed to delete old session during merge') diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d19fc223fa..16d95411ed 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -44,6 +44,8 @@ import { import { SessionCache } from './sessionCache' import { OverseerEventRecorder, shouldInjectNotifyContract, toSessionSnapshot } from './overseerEventRecorder' import type { ListSystemEventsOptions, StoredSystemEvent } from '../store' +import type { InboxOperatorAction } from '@hapi/protocol' +import type { ListInboxItemsOptions, StoredInboxItem } from '../store/inboxItems' export type { Session, SyncEvent } from '@hapi/protocol/types' export type { Machine } from './machineCache' @@ -159,7 +161,7 @@ export class SyncEngine { (sessionId, updatedAt) => this.recordSessionActivity(sessionId, updatedAt) ) this.rpcGateway = new RpcGateway(io, rpcRegistry) - this.overseerEvents = new OverseerEventRecorder(store.events) + this.overseerEvents = new OverseerEventRecorder(store.events, store.inbox) this.reloadAll() this.inactivityTimer = setInterval(() => this.expireInactive(), 5_000) } @@ -372,6 +374,23 @@ export class SyncEngine { return this.overseerEvents.count() } + getInboxItems(options: ListInboxItemsOptions = {}): StoredInboxItem[] { + return this.store.inbox.list(options) + } + + getInboxItemCount(): number { + return this.store.inbox.count() + } + + recordInboxOperatorAction( + inboxItemId: number, + action: InboxOperatorAction, + feedback: string | null = null, + snoozedUntil: number | null = null + ): StoredInboxItem | null { + return this.store.inbox.recordOperatorAction(inboxItemId, action, feedback, snoozedUntil) + } + private getLastAgentPlainText(sessionId: string): string | null { const messages = this.store.messages.getMessages(sessionId, 80) for (let i = messages.length - 1; i >= 0; i -= 1) { diff --git a/hub/src/web/routes/inboxItems.test.ts b/hub/src/web/routes/inboxItems.test.ts new file mode 100644 index 0000000000..2d484149c4 --- /dev/null +++ b/hub/src/web/routes/inboxItems.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { buildOverseerSessionIdentity, mergeEventPayloadWithSession } from '@hapi/protocol' +import { Store } from '../../store' +import { SyncEngine } from '../../sync/syncEngine' +import { RpcRegistry } from '../../socket/rpcRegistry' +import { createInboxItemsRoutes } from './inboxItems' +import type { WebAppEnv } from '../middleware/auth' + +describe('inboxItems routes', () => { + it('lists promoted inbox items in coarse-rank order', async () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('route-inbox', { name: 'route-session' }, null, 'default') + const payloadJson = mergeEventPayloadWithSession({}, buildOverseerSessionIdentity({ + id: session.id, + flavor: 'codex', + tag: session.tag, + metadata: session.metadata as { name?: string } | null + })) + const event = store.events.insert({ + ts: Date.now(), + sourceKind: 'worker', + sourceRef: 'peer', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'Route smoke inbox item', + relatedSessionId: session.id, + payloadJson, + provenance: 'test' + }) + store.inbox.promoteAttentionEvent(event!) + + const io = { of: () => ({ to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }) }) } as never + const engine = new SyncEngine(store, io, new RpcRegistry(), { broadcast: () => {} } as never) + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createInboxItemsRoutes(() => engine)) + + const listRes = await app.request('/api/inbox-items?activeOnly=1') + expect(listRes.status).toBe(200) + const body = await listRes.json() as { total: number; items: Array<{ summary: string; title: string }> } + expect(body.total).toBe(1) + expect(body.items[0]?.summary).toBe('Route smoke inbox item') + expect(body.items[0]?.title).toBe('route-session') + }) + + it('records operator actions', async () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('route-action', { name: 'x' }, null, 'default') + const payloadJson = mergeEventPayloadWithSession({}, buildOverseerSessionIdentity({ + id: session.id, + flavor: 'codex', + tag: session.tag, + metadata: session.metadata as { name?: string } | null + })) + const event = store.events.insert({ + ts: Date.now(), + sourceKind: 'worker', + eventType: 'needs_decision', + attentionCandidate: 1, + summary: 'decide', + relatedSessionId: session.id, + payloadJson, + provenance: 'test' + }) + const item = store.inbox.promoteAttentionEvent(event!) + + const io = { of: () => ({ to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }) }) } as never + const engine = new SyncEngine(store, io, new RpcRegistry(), { broadcast: () => {} } as never) + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createInboxItemsRoutes(() => engine)) + + const res = await app.request(`/api/inbox-items/${item!.id}/actions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'dismiss', feedback: 'noise' }) + }) + expect(res.status).toBe(200) + const body = await res.json() as { item: { status: string; operatorFeedback: string | null } } + expect(body.item.status).toBe('obsoleted') + expect(body.item.operatorFeedback).toBe('noise') + }) +}) diff --git a/hub/src/web/routes/inboxItems.ts b/hub/src/web/routes/inboxItems.ts new file mode 100644 index 0000000000..a6b33c2d3e --- /dev/null +++ b/hub/src/web/routes/inboxItems.ts @@ -0,0 +1,84 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { INBOX_OPERATOR_ACTIONS } from '@hapi/protocol' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSyncEngine } from './guards' + +const listQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + activeOnly: z.enum(['0', '1']).optional(), + sessionId: z.string().min(1).optional() +}) + +const actionBodySchema = z.object({ + action: z.enum(INBOX_OPERATOR_ACTIONS), + feedback: z.string().max(500).optional(), + snoozedUntil: z.number().int().positive().optional() +}) + +export function createInboxItemsRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + app.get('/inbox-items', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const parsed = listQuerySchema.safeParse(c.req.query()) + if (!parsed.success) { + return c.json({ error: 'Invalid query', issues: parsed.error.flatten() }, 400) + } + + const activeOnly = parsed.data.activeOnly === '1' + const items = engine.getInboxItems({ + limit: parsed.data.limit ?? 50, + activeOnly, + sessionId: parsed.data.sessionId ?? null + }) + + return c.json({ + total: engine.getInboxItemCount(), + items + }) + }) + + app.post('/inbox-items/:id/actions', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const inboxItemId = Number(c.req.param('id')) + if (!Number.isInteger(inboxItemId) || inboxItemId <= 0) { + return c.json({ error: 'Invalid inbox item id' }, 400) + } + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON body' }, 400) + } + + const parsed = actionBodySchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + + const item = engine.recordInboxOperatorAction( + inboxItemId, + parsed.data.action, + parsed.data.feedback ?? null, + parsed.data.snoozedUntil ?? null + ) + if (!item) { + return c.json({ error: 'Inbox item not found' }, 404) + } + + return c.json({ item }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 012b54070a..7d5a6f09f4 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -27,6 +27,7 @@ import { createPushRoutes } from './routes/push' import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' import { createSystemEventsRoutes } from './routes/systemEvents' +import { createInboxItemsRoutes } from './routes/inboxItems' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer, ServerWebSocket } from 'bun' @@ -263,6 +264,7 @@ function createWebApp(options: { app.route('/api', createDevicesRoutes(options.store)) app.route('/api', createVoiceRoutes()) app.route('/api', createSystemEventsRoutes(options.getSyncEngine)) + app.route('/api', createInboxItemsRoutes(options.getSyncEngine)) // Skip static serving in relay mode, show helpful message on root if (options.relayMode) { diff --git a/shared/src/index.ts b/shared/src/index.ts index 6e47f664e4..c3e18f5816 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -3,6 +3,7 @@ export * from './apiTypes' export * from './cursorCliSku' export * from './messages' export * from './overseerEvents' +export * from './overseerInbox' export * from './buildInfo' export * from './effort' export * from './flavors' diff --git a/shared/src/overseerInbox.test.ts b/shared/src/overseerInbox.test.ts new file mode 100644 index 0000000000..7714146bd5 --- /dev/null +++ b/shared/src/overseerInbox.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test' +import { + buildExplainPriority, + buildInboxTitle, + buildInboxTitleFromEvent, + computeCoarseBasePriority, + mapEventTypeToInboxCategory, + mapOperatorActionToStatus +} from './overseerInbox' + +describe('overseerInbox', () => { + it('orders coarse rank permission > blocked > needs_decision > completed > stale', () => { + expect(computeCoarseBasePriority('approval_requested')).toBeLessThan(computeCoarseBasePriority('blocked')) + expect(computeCoarseBasePriority('blocked')).toBeLessThan(computeCoarseBasePriority('needs_decision')) + expect(computeCoarseBasePriority('needs_decision')).toBeLessThan(computeCoarseBasePriority('completed')) + expect(computeCoarseBasePriority('completed')).toBeLessThan(computeCoarseBasePriority('stale')) + }) + + it('maps event types to inbox category badges', () => { + expect(mapEventTypeToInboxCategory('approval_requested')).toBe('APPROVAL') + expect(mapEventTypeToInboxCategory('blocked')).toBe('BLOCKED') + expect(mapEventTypeToInboxCategory('needs_decision')).toBe('QUESTION') + expect(mapEventTypeToInboxCategory('completed')).toBe('FINALE') + expect(mapEventTypeToInboxCategory('stale')).toBe('STALE') + }) + + it('prefers artifact title over denormalized session name', () => { + const refs = JSON.stringify([{ kind: 'github_pr', title: 'fix: inbox substrate' }]) + const payload = JSON.stringify({ session: { name: 'my-session' } }) + expect(buildInboxTitleFromEvent(refs, payload, 'summary body')).toBe('fix: inbox substrate') + expect(buildInboxTitleFromEvent(null, payload, 'summary body')).toBe('my-session') + expect(buildInboxTitleFromEvent(null, null, 'summary body')).toBe('summary body') + }) + + it('builds explain_priority lite from category age and source ids', () => { + const now = Date.UTC(2026, 5, 20, 12, 0, 0) + const createdAt = now - 15 * 60_000 + const text = buildExplainPriority('BLOCKED', createdAt, [42, 43], now) + expect(text).toContain('BLOCKED tier') + expect(text).toContain('15m ago') + expect(text).toContain('2 events') + }) + + it('maps operator actions to inbox status transitions', () => { + expect(mapOperatorActionToStatus('done')).toBe('resolved') + expect(mapOperatorActionToStatus('dismiss')).toBe('obsoleted') + expect(mapOperatorActionToStatus('snooze')).toBe('snoozed') + }) +}) diff --git a/shared/src/overseerInbox.ts b/shared/src/overseerInbox.ts new file mode 100644 index 0000000000..b8a115548e --- /dev/null +++ b/shared/src/overseerInbox.ts @@ -0,0 +1,197 @@ +export const INBOX_ITEM_STATUSES = [ + 'new', + 'surfaced', + 'deferred', + 'snoozed', + 'resolved', + 'obsoleted', + 'held' +] as const + +export type InboxItemStatus = typeof INBOX_ITEM_STATUSES[number] + +export const INBOX_OPERATOR_ACTIONS = [ + 'open', + 'snooze', + 'done', + 'dismiss', + 'route', + 'retry' +] as const + +export type InboxOperatorAction = typeof INBOX_OPERATOR_ACTIONS[number] + +export const INBOX_CATEGORIES = [ + 'APPROVAL', + 'BLOCKED', + 'QUESTION', + 'REVIEW', + 'ERROR', + 'FINALE', + 'STALE' +] as const + +export type InboxCategory = typeof INBOX_CATEGORIES[number] + +export type ArtifactRef = { + kind?: string + url?: string + title?: string + ref?: string +} + +const TITLE_PRIORITY_KINDS = [ + 'github_pr', + 'github_issue', + 'branch', + 'commit', + 'deploy_id' +] as const + +/** Fixed coarse rank — lower number = higher priority (v1, not learned). */ +export function computeCoarseBasePriority(eventType: string): number { + switch (eventType) { + case 'approval_requested': + case 'permission_request': + return 10 + case 'blocked': + return 20 + case 'needs_decision': + return 30 + case 'failed': + return 35 + case 'needs_review': + return 40 + case 'completed': + return 50 + case 'stale': + return 60 + default: + return 70 + } +} + +export function mapEventTypeToInboxCategory(eventType: string): InboxCategory { + switch (eventType) { + case 'approval_requested': + case 'permission_request': + return 'APPROVAL' + case 'blocked': + return 'BLOCKED' + case 'needs_decision': + return 'QUESTION' + case 'needs_review': + return 'REVIEW' + case 'failed': + return 'ERROR' + case 'completed': + return 'FINALE' + case 'stale': + return 'STALE' + default: + return 'QUESTION' + } +} + +export function parseArtifactRefs(raw: string | null | undefined): ArtifactRef[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.filter((entry): entry is ArtifactRef => typeof entry === 'object' && entry !== null) + } catch { + return [] + } +} + +export function pickPrimaryArtifactTitle(artifactRefs: ArtifactRef[]): string | null { + for (const kind of TITLE_PRIORITY_KINDS) { + const match = artifactRefs.find((ref) => ref.kind === kind) + if (!match) continue + if (match.title?.trim()) return match.title.trim() + if (match.ref?.trim()) return match.ref.trim() + if (match.url?.trim()) return match.url.trim() + } + for (const ref of artifactRefs) { + if (ref.title?.trim()) return ref.title.trim() + if (ref.ref?.trim()) return ref.ref.trim() + } + return null +} + +export function buildInboxTitle( + artifactRefsJson: string | null | undefined, + sessionName: string | null | undefined, + summary: string +): string { + const artifactTitle = pickPrimaryArtifactTitle(parseArtifactRefs(artifactRefsJson)) + if (artifactTitle) return artifactTitle + const sessionLabel = sessionName?.trim() + if (sessionLabel) return sessionLabel + const trimmed = summary.trim() + return trimmed.length > 0 ? trimmed.slice(0, 120) : 'Attention item' +} + +/** Read denormalized session.name from event payload (#22) — no live sessions lookup. */ +export function extractDenormalizedSessionName(payloadJson: string | null | undefined): string | null { + if (!payloadJson) return null + try { + const payload = JSON.parse(payloadJson) as { session?: { name?: unknown } } + const name = payload.session?.name + return typeof name === 'string' && name.trim().length > 0 ? name.trim() : null + } catch { + return null + } +} + +export function buildInboxTitleFromEvent( + artifactRefsJson: string | null | undefined, + payloadJson: string | null | undefined, + summary: string +): string { + return buildInboxTitle(artifactRefsJson, extractDenormalizedSessionName(payloadJson), summary) +} + +export function formatAgeMinutes(ageMs: number): string { + const minutes = Math.max(1, Math.round(ageMs / 60_000)) + if (minutes < 60) return `${minutes}m` + const hours = Math.round(minutes / 60) + if (hours < 48) return `${hours}h` + const days = Math.round(hours / 24) + return `${days}d` +} + +export function buildExplainPriority( + category: string, + createdAt: number, + sourceEventIds: number[], + now: number = Date.now() +): string { + const age = formatAgeMinutes(now - createdAt) + const eventLabel = sourceEventIds.length === 1 + ? `event #${sourceEventIds[0]}` + : `${sourceEventIds.length} events (#${sourceEventIds.slice(0, 3).join(', ')}${sourceEventIds.length > 3 ? ', …' : ''})` + return `${category} tier · queued ${age} ago · from ${eventLabel}` +} + +export function mapOperatorActionToStatus(action: InboxOperatorAction): InboxItemStatus { + switch (action) { + case 'open': + return 'surfaced' + case 'snooze': + return 'snoozed' + case 'done': + return 'resolved' + case 'dismiss': + return 'obsoleted' + case 'route': + case 'retry': + return 'surfaced' + default: + return 'surfaced' + } +} + +export function isActiveInboxStatus(status: string): boolean { + return status === 'new' || status === 'surfaced' || status === 'deferred' || status === 'snoozed' +} diff --git a/web/e2e-fixtures/inbox-debug-fixture.html b/web/e2e-fixtures/inbox-debug-fixture.html new file mode 100644 index 0000000000..a23ee16ca8 --- /dev/null +++ b/web/e2e-fixtures/inbox-debug-fixture.html @@ -0,0 +1,12 @@ + + + + + + Inbox debug fixture + + +
+ + + diff --git a/web/e2e-fixtures/inbox-debug-fixture.tsx b/web/e2e-fixtures/inbox-debug-fixture.tsx new file mode 100644 index 0000000000..e7b05a2a8c --- /dev/null +++ b/web/e2e-fixtures/inbox-debug-fixture.tsx @@ -0,0 +1,93 @@ +/* + * Standalone fixture for InboxDebugControls Playwright smoke (#23). + */ + +import React from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { AppContextProvider } from '../src/lib/app-context' +import { InboxDebugControls } from '../src/components/settings/InboxDebugControls' +import type { ApiClient } from '../src/api/client' + +declare global { + interface Window { + __inboxDebugE2E?: { + setItems(items: Array<{ + id: number + status: string + priority: number + basePriority: number + title: string + category: string + summary: string + suggestedAction: string | null + reasonForPriority: string | null + sourceEventIds: number[] + relatedSessionId: string | null + createdAt: number + updatedAt: number + }>): void + setError(message: string | null): void + } + } +} + +const sampleItems = [ + { + id: 1, + status: 'new', + priority: 20, + basePriority: 20, + title: 'feat: inbox substrate', + category: 'BLOCKED', + summary: 'CI auth failed on push', + suggestedAction: 'Fix GitHub token', + reasonForPriority: 'BLOCKED tier · queued 12m ago · from event #42', + sourceEventIds: [42], + relatedSessionId: 'sess-smoke-01', + createdAt: Date.UTC(2026, 5, 19, 12, 0, 0), + updatedAt: Date.UTC(2026, 5, 19, 12, 0, 0), + }, +] + +function createMockApi(): ApiClient { + let items = [...sampleItems] + let shouldFail = false + + const api = { + async fetchInboxItems() { + if (shouldFail) { + throw new Error('fixture fetch failed') + } + return { total: items.length, items } + }, + async recordInboxOperatorAction(itemId: number, action: string) { + items = items.map((item) => ( + item.id === itemId + ? { ...item, status: action === 'done' ? 'resolved' : item.status, updatedAt: Date.now() } + : item + )) + return { item: items.find((item) => item.id === itemId) } + }, + } as unknown as ApiClient + + window.__inboxDebugE2E = { + setItems(next) { + items = next + }, + setError(message) { + shouldFail = message !== null + }, + } + + return api +} + +const root = ReactDOM.createRoot(document.getElementById('root')!) +root.render( + +
+ +
+
+) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ab8618b6af..8dfc14adaa 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -874,4 +874,33 @@ export class ApiClient { const suffix = query.toString() return await this.request(`/api/system-events${suffix ? `?${suffix}` : ''}`) } + + async fetchInboxItems(params: { + limit?: number + activeOnly?: boolean + sessionId?: string + } = {}): Promise<{ total: number; items: unknown[] }> { + const query = new URLSearchParams() + if (params.limit !== undefined) query.set('limit', String(params.limit)) + if (params.activeOnly) query.set('activeOnly', '1') + if (params.sessionId) query.set('sessionId', params.sessionId) + const suffix = query.toString() + return await this.request(`/api/inbox-items${suffix ? `?${suffix}` : ''}`) + } + + async recordInboxOperatorAction( + inboxItemId: number, + action: import('@hapi/protocol').InboxOperatorAction, + feedback?: string | null, + snoozedUntil?: number | null + ): Promise<{ item: unknown }> { + return await this.request(`/api/inbox-items/${inboxItemId}/actions`, { + method: 'POST', + body: JSON.stringify({ + action, + feedback: feedback ?? undefined, + snoozedUntil: snoozedUntil ?? undefined + }) + }) + } } diff --git a/web/src/components/settings/InboxDebugControls.tsx b/web/src/components/settings/InboxDebugControls.tsx new file mode 100644 index 0000000000..d4ddc15891 --- /dev/null +++ b/web/src/components/settings/InboxDebugControls.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAppContext } from '@/lib/app-context' +import type { InboxOperatorAction } from '@hapi/protocol' + +export type InboxItemRow = { + id: number + status: string + priority: number + basePriority: number + title: string + category: string + summary: string + suggestedAction: string | null + reasonForPriority: string | null + sourceEventIds: number[] + relatedSessionId: string | null + createdAt: number + updatedAt: number +} + +type InboxItemsResponse = { + total: number + items: InboxItemRow[] +} + +function formatTs(ts: number): string { + return new Date(ts).toLocaleString() +} + +export function InboxDebugControls() { + const { api } = useAppContext() + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [total, setTotal] = useState(0) + const [items, setItems] = useState([]) + + const refresh = useCallback(async () => { + if (!api) { + setError('Not authenticated') + return + } + setLoading(true) + setError(null) + try { + const data = await api.fetchInboxItems({ limit: 80, activeOnly: true }) as InboxItemsResponse + setTotal(data.total) + setItems(data.items) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load inbox items') + } finally { + setLoading(false) + } + }, [api]) + + const runAction = useCallback(async (itemId: number, action: InboxOperatorAction) => { + if (!api) return + setLoading(true) + setError(null) + try { + await api.recordInboxOperatorAction(itemId, action) + await refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Action failed') + } finally { + setLoading(false) + } + }, [api, refresh]) + + useEffect(() => { + if (open) { + void refresh() + } + }, [open, refresh]) + + return ( +
+ + {open && ( +
+
+

+ Per-session promotion (#23). Coarse rank, oldest within tier. Actions log training labels. +

+ +
+ {error ? ( +

{error}

+ ) : null} +
+ {items.length === 0 ? ( +

No inbox items yet.

+ ) : ( +
    + {items.map((item) => ( +
  • +
    + #{item.id} + + {item.category} + + {formatTs(item.createdAt)} +
    +

    {item.title}

    +

    {item.summary}

    + {item.reasonForPriority ? ( +

    {item.reasonForPriority}

    + ) : null} + {item.suggestedAction ? ( +

    Next: {item.suggestedAction}

    + ) : null} +
    + {(['open', 'snooze', 'done', 'dismiss'] as const).map((action) => ( + + ))} +
    +
  • + ))} +
+ )} +
+
+ )} +
+ ) +} diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index ae88dba0fb..05ab43f80d 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -41,6 +41,7 @@ import { useThemeColors, type ThemeColorKeyId } from '@/hooks/useThemeColors' import { PROTOCOL_VERSION } from '@hapi/protocol' import { VoiceRespondsControls, VoiceSoundsControls, VoicePersonaControls, VoiceDiagnosticsControls } from '@/components/settings/VoiceAdvancedControls' import { EventsDebugControls } from '@/components/settings/EventsDebugControls' +import { InboxDebugControls } from '@/components/settings/InboxDebugControls' const locales: { value: Locale; nativeLabel: string }[] = [ { value: 'en', nativeLabel: 'English' }, @@ -1257,6 +1258,7 @@ export default function SettingsPage() {
+