Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,18 @@ export interface ProviderPluginRuntime {
// Plugin Registry (passed to plugins during registration)
// ============================================================================

/** A plugin-emitted in-app notification. */
export interface PluginNotification {
title: string
body: string
}

export interface ProviderPluginRegistry {
registerAuth(adapter: ProviderAuthAdapter): void
registerTransport(adapter: ProviderTransportAdapter): void
registerPreset(preset: ProviderPreset): void
/** Emit an in-app notification that surfaces in the UI (toast + history). */
notify(notification: PluginNotification): void
readonly runtime: ProviderPluginRuntime
}

Expand Down
13 changes: 13 additions & 0 deletions src/server/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,5 +439,18 @@ function runMigrations(db: Database.Database): void {
)
`)

// Global in-app notification history
db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'system',
read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at)`)

logger.info('Database migrations completed')
}
14 changes: 14 additions & 0 deletions src/server/db/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,4 +304,18 @@ describe('db migrations', () => {

db.close()
})

it('creates notifications table on a fresh database', () => {
const config = loadConfig()
config.database.path = dbPath
initDatabase(config)

const db = new Database(dbPath)
const columns = db.prepare(`PRAGMA table_info(notifications)`).all() as { name: string }[]
const columnNames = columns.map((c) => c.name)

expect(columnNames).toEqual(expect.arrayContaining(['id', 'title', 'body', 'source', 'read', 'created_at']))

db.close()
})
})
87 changes: 87 additions & 0 deletions src/server/db/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../config.js'
import { closeDatabase, initDatabase } from '../db/index.js'
import {
createNotification,
listNotifications,
deleteNotification,
clearNotifications,
markNotificationsRead,
countUnreadNotifications,
setNotificationRead,
getNotification,
} from '../db/notifications.js'

describe('notifications db', () => {
let tmpDir: string
let dbPath: string

beforeEach(async () => {
closeDatabase()
tmpDir = await mkdtemp(join(tmpdir(), 'openfox-notif-test-'))
dbPath = join(tmpDir, 'test.db')
const config = loadConfig()
config.database.path = dbPath
initDatabase(config)
})

afterEach(async () => {
closeDatabase()
await rm(tmpDir, { recursive: true, force: true })
})

it('creates and lists notifications newest first', () => {
createNotification({ title: 'First', body: 'body 1', source: 'plugin' })
const second = createNotification({ title: 'Second', body: 'body 2' })

const all = listNotifications()
expect(all.length).toBe(2)
// Newest first: second (just created) first
expect(all[0]!.id).toBe(second.id)
expect(all[0]!.source).toBe('system')
expect(all[1]!.source).toBe('plugin')
expect(all[0]!.read).toBe(false)
})

it('tracks unread count', () => {
createNotification({ title: 'A', body: 'a' })
const b = createNotification({ title: 'B', body: 'b' })
expect(countUnreadNotifications()).toBe(2)

setNotificationRead(b.id, true)
expect(countUnreadNotifications()).toBe(1)

markNotificationsRead()
expect(countUnreadNotifications()).toBe(0)
})

it('deletes a single notification', () => {
const a = createNotification({ title: 'A', body: 'a' })
createNotification({ title: 'B', body: 'b' })
deleteNotification(a.id)

expect(listNotifications().length).toBe(1)
expect(getNotification(a.id)).toBeNull()
})

it('clears all notifications', () => {
createNotification({ title: 'A', body: 'a' })
createNotification({ title: 'B', body: 'b' })
clearNotifications()

expect(listNotifications()).toEqual([])
expect(countUnreadNotifications()).toBe(0)
})

it('persists a test notification source', () => {
const n = createNotification({ title: 'Test', body: 'sample', source: 'test' })
expect(n.source).toBe('test')

const all = listNotifications()
expect(all[0]!.title).toBe('Test')
expect(all[0]!.source).toBe('test')
})
})
79 changes: 79 additions & 0 deletions src/server/db/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { randomBytes } from 'node:crypto'
import type { Notification, NotificationInput } from '../../shared/types.js'
import { getDatabase } from './index.js'

interface NotificationRow {
id: string
title: string
body: string
source: string
read: number
created_at: string
}

function toNotification(row: NotificationRow): Notification {
return {
id: row.id,
title: row.title,
body: row.body,
source: row.source,
read: row.read === 1,
createdAt: row.created_at,
}
}

export function createNotification(input: NotificationInput): Notification {
const db = getDatabase()
const now = new Date().toISOString()
const row: NotificationRow = {
id: randomBytes(8).toString('hex'),
title: input.title,
body: input.body,
source: input.source ?? 'system',
read: 0,
created_at: now,
}
db.prepare(
`INSERT INTO notifications (id, title, body, source, read, created_at)
VALUES (@id, @title, @body, @source, @read, @created_at)`,
).run(row)
return toNotification(row)
}

export function listNotifications(): Notification[] {
const db = getDatabase()
const rows = db.prepare('SELECT * FROM notifications ORDER BY created_at DESC').all() as NotificationRow[]
return rows.map(toNotification)
}

export function getNotification(id: string): Notification | null {
const db = getDatabase()
const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as NotificationRow | undefined
return row ? toNotification(row) : null
}

export function deleteNotification(id: string): void {
const db = getDatabase()
db.prepare('DELETE FROM notifications WHERE id = ?').run(id)
}

export function clearNotifications(): void {
const db = getDatabase()
db.prepare('DELETE FROM notifications').run()
}

export function markNotificationsRead(): void {
const db = getDatabase()
db.prepare('UPDATE notifications SET read = 1 WHERE read = 0').run()
}

export function countUnreadNotifications(): number {
const db = getDatabase()
const row = db.prepare('SELECT COUNT(*) AS count FROM notifications WHERE read = 0').get() as { count: number }
return row.count
}

export function setNotificationRead(id: string, read: boolean): void {
const db = getDatabase()
db.prepare('UPDATE notifications SET read = ? WHERE id = ?').run(read ? 1 : 0, id)
}
36 changes: 36 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
launch: import('./runner/launch.js').WorkflowLaunchPayload,
) => void = () => {}

// Deferred broadcast for the notifications service. Same rationale as the
// tasks broadcast: the WebSocket server doesn't exist yet when the routes and
// plugin registry are wired, so notifications are buffered through this hook.
let deferNotificationsBroadcast: (message: import('../shared/protocol.js').ServerMessage) => void = () => {}

// Get config directory for loading user items
const configDir = getGlobalConfigDir(config.mode ?? 'production')

Expand All @@ -135,6 +140,17 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
mode: config.mode === 'development' ? 'development' : 'production',
configDirectory: configDir,
})

// Notifications service: persists rows and broadcasts to all UIs. The
// broadcaster is deferred because the WebSocket server is created later.
const { createNotificationsService } = await import('./notifications/service.js')
const notificationsService = createNotificationsService({
notify: (notification) => {
deferNotificationsBroadcast(createServerMessage('notifications.new', { notification }))
},
})
providerAdapters.setNotify((notification) => notificationsService.notify({ source: 'plugin', ...notification }))

const pluginDiagnostics = await loadProviderPlugins({ registry: providerAdapters, configDirectory: configDir })
for (const diagnostic of pluginDiagnostics) {
if (!diagnostic.loaded) logger.warn('Provider plugin failed to load', { ...diagnostic })
Expand Down Expand Up @@ -532,6 +548,23 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
registerTaskRoutes(tasksRouter, tasksService)
app.use('/api', tasksRouter)

// Global in-app notification history. Also mounted before the Vite
// middleware; mutations broadcast through the deferred WebSocket sink.
const { registerNotificationRoutes } = await import('./routes/notifications.js')
const notificationsRouter = express.Router()
registerNotificationRoutes(notificationsRouter, {
deleted: (id) => {
deferNotificationsBroadcast(createServerMessage('notifications.deleted', { id }))
},
read: () => {
deferNotificationsBroadcast(createServerMessage('notifications.read', {}))
},
cleared: () => {
deferNotificationsBroadcast(createServerMessage('notifications.cleared', {}))
},
})
app.use('/api', notificationsRouter)

// Branch management endpoints (project-scoped, repo operations)

/** List local git branches */
Expand Down Expand Up @@ -3456,6 +3489,9 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
deferTasksBroadcast = (projectId, payload) =>
wssExports.broadcastForProject(projectId, '', { type: 'tasks.update', payload })

// Point the notifications service at the live WebSocket broadcaster now that it exists.
deferNotificationsBroadcast = (message) => wssExports.broadcastAll(message)

// Point the tasks service at the workflow launcher. Task-seeded workflows run
// through the same shared launcher as runner.launch (src/server/runner/launch.ts).
const { launchWorkflowRun, abortRunnerRun } = await import('./runner/launch.js')
Expand Down
27 changes: 27 additions & 0 deletions src/server/notifications/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Notification, NotificationInput } from '../../shared/types.js'
import { createNotification as dbCreateNotification } from '../db/notifications.js'

export type NotificationsBroadcaster = {
/** Broadcast a newly created notification to all clients. */
notify(notification: Notification): void
}

/**
* Global in-app notification service. Persists the notification row and pushes
* it to every connected UI. The broadcaster is injected so the service works
* before the WebSocket server exists (deferred wiring in the server entry) and
* stays testable.
*/
export class NotificationsService {
constructor(private broadcaster: NotificationsBroadcaster) {}

notify(input: NotificationInput): Notification {
const notification = dbCreateNotification(input)
this.broadcaster.notify(notification)
return notification
}
}

export function createNotificationsService(broadcaster: NotificationsBroadcaster): NotificationsService {
return new NotificationsService(broadcaster)
}
3 changes: 3 additions & 0 deletions src/server/providers/plugins/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export async function loadProviderPlugins(options: {
options.registry.registerPreset(preset)
diagnostic.presets.push(preset.id)
},
notify(notification) {
options.registry.notify(notification)
},
}
try {
const module = (await import(pathToFileURL(join(packageDir, plugin)).href)) as {
Expand Down
10 changes: 10 additions & 0 deletions src/server/providers/plugins/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,25 @@ import type {
ProviderPluginRuntime,
ProviderPreset,
ProviderTransportAdapter,
PluginNotification,
} from '../../../provider/index.js'

export class ProviderRegistry implements ProviderPluginRegistry {
private readonly authAdapters = new Map<string, ProviderAuthAdapter>()
private readonly transportAdapters = new Map<string, ProviderTransportAdapter>()
private readonly presets = new Map<string, ProviderPreset>()
private notifyFn: ((notification: PluginNotification) => void) | null = null

constructor(readonly runtime: ProviderPluginRuntime) {}

setNotify(fn: (notification: PluginNotification) => void): void {
this.notifyFn = fn
}

notify(notification: PluginNotification): void {
this.notifyFn?.(notification)
}

registerAuth(adapter: ProviderAuthAdapter): void {
this.register(this.authAdapters, adapter.id, adapter, 'auth adapter')
}
Expand Down
Loading
Loading