rufloui 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/'1' +0 -0
  2. package/.env.example +46 -0
  3. package/CHANGELOG.md +87 -0
  4. package/CLAUDE.md +287 -0
  5. package/LICENSE +21 -0
  6. package/README.md +316 -0
  7. package/Webhooks) +0 -0
  8. package/docs/plans/2026-03-11-github-webhooks.md +957 -0
  9. package/docs/screenshot-swarm-monitor.png +0 -0
  10. package/frontend +0 -0
  11. package/index.html +13 -0
  12. package/package.json +56 -0
  13. package/public/vite.svg +4 -0
  14. package/src/backend/__tests__/webhook-github.test.ts +934 -0
  15. package/src/backend/jsonl-monitor.ts +430 -0
  16. package/src/backend/server.ts +2972 -0
  17. package/src/backend/telegram-bot.ts +511 -0
  18. package/src/backend/webhook-github.ts +350 -0
  19. package/src/frontend/App.tsx +461 -0
  20. package/src/frontend/api.ts +281 -0
  21. package/src/frontend/components/ErrorBoundary.tsx +98 -0
  22. package/src/frontend/components/Layout.tsx +431 -0
  23. package/src/frontend/components/ui/Button.tsx +111 -0
  24. package/src/frontend/components/ui/Card.tsx +51 -0
  25. package/src/frontend/components/ui/StatusBadge.tsx +60 -0
  26. package/src/frontend/main.tsx +63 -0
  27. package/src/frontend/pages/AgentVizPanel.tsx +428 -0
  28. package/src/frontend/pages/AgentsPanel.tsx +445 -0
  29. package/src/frontend/pages/ConfigPanel.tsx +661 -0
  30. package/src/frontend/pages/Dashboard.tsx +482 -0
  31. package/src/frontend/pages/HiveMindPanel.tsx +355 -0
  32. package/src/frontend/pages/HooksPanel.tsx +240 -0
  33. package/src/frontend/pages/LogsPanel.tsx +261 -0
  34. package/src/frontend/pages/MemoryPanel.tsx +444 -0
  35. package/src/frontend/pages/NeuralPanel.tsx +301 -0
  36. package/src/frontend/pages/PerformancePanel.tsx +198 -0
  37. package/src/frontend/pages/SessionsPanel.tsx +428 -0
  38. package/src/frontend/pages/SetupWizard.tsx +181 -0
  39. package/src/frontend/pages/SwarmMonitorPanel.tsx +634 -0
  40. package/src/frontend/pages/SwarmPanel.tsx +322 -0
  41. package/src/frontend/pages/TasksPanel.tsx +535 -0
  42. package/src/frontend/pages/WebhooksPanel.tsx +335 -0
  43. package/src/frontend/pages/WorkflowsPanel.tsx +448 -0
  44. package/src/frontend/store.ts +185 -0
  45. package/src/frontend/styles/global.css +113 -0
  46. package/src/frontend/test-setup.ts +1 -0
  47. package/src/frontend/tour/TourContext.tsx +161 -0
  48. package/src/frontend/tour/tourSteps.ts +181 -0
  49. package/src/frontend/tour/tourStyles.css +116 -0
  50. package/src/frontend/types.ts +239 -0
  51. package/src/frontend/utils/formatTime.test.ts +83 -0
  52. package/src/frontend/utils/formatTime.ts +23 -0
  53. package/tsconfig.json +23 -0
  54. package/vite.config.ts +26 -0
  55. package/vitest.config.ts +17 -0
  56. package/{,+ +0 -0
@@ -0,0 +1,350 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto'
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { Router, Request, Response, RequestHandler } from 'express'
5
+
6
+ // ── Types ──────────────────────────────────────────────────────────────────
7
+
8
+ export interface GitHubWebhookConfig {
9
+ enabled: boolean
10
+ /** Personal access token (repo scope) for creating branches/PRs */
11
+ githubToken: string
12
+ /** Webhook secret for HMAC-SHA256 validation */
13
+ webhookSecret: string
14
+ /** Repos to monitor — array of "owner/repo" strings */
15
+ repos: string[]
16
+ /** Auto-assign new issue tasks to the active swarm */
17
+ autoAssign: boolean
18
+ /** Custom instructions template for swarm tasks. Placeholders: {{title}}, {{body}}, {{url}}, {{author}}, {{labels}}, {{repo}}, {{number}} */
19
+ taskTemplate: string
20
+ }
21
+
22
+ export interface WebhookEvent {
23
+ id: string
24
+ provider: 'github'
25
+ repo: string
26
+ event: string
27
+ title: string
28
+ body: string
29
+ url: string
30
+ number: number
31
+ author: string
32
+ labels: string[]
33
+ receivedAt: string
34
+ taskId?: string
35
+ status: 'received' | 'processing' | 'completed' | 'failed' | 'ignored'
36
+ }
37
+
38
+ const DEFAULT_TEMPLATE = 'Analyze this issue, investigate the codebase, implement a fix, write tests, and prepare a summary of changes.'
39
+
40
+ const DEFAULT_CONFIG: GitHubWebhookConfig = {
41
+ enabled: false,
42
+ githubToken: '',
43
+ webhookSecret: '',
44
+ repos: [],
45
+ autoAssign: true,
46
+ taskTemplate: '',
47
+ }
48
+
49
+ function buildTaskDescription(event: WebhookEvent, template: string): string {
50
+ const bodyText = event.body?.slice(0, 2000) || 'No description provided.'
51
+ const instructions = template || DEFAULT_TEMPLATE
52
+ const rendered = instructions
53
+ .replace(/\{\{title\}\}/g, event.title)
54
+ .replace(/\{\{body\}\}/g, bodyText)
55
+ .replace(/\{\{url\}\}/g, event.url)
56
+ .replace(/\{\{author\}\}/g, event.author)
57
+ .replace(/\{\{labels\}\}/g, event.labels.join(', ') || 'none')
58
+ .replace(/\{\{repo\}\}/g, event.repo)
59
+ .replace(/\{\{number\}\}/g, String(event.number))
60
+
61
+ // If the custom template already includes {{body}}, skip the body in the header
62
+ // to avoid duplication
63
+ const templateHasBody = template && /\{\{body\}\}/.test(template)
64
+
65
+ const parts = [
66
+ `GitHub Issue: ${event.url}`,
67
+ `Author: ${event.author}`,
68
+ `Labels: ${event.labels.join(', ') || 'none'}`,
69
+ ]
70
+ if (!templateHasBody) {
71
+ parts.push('', bodyText)
72
+ }
73
+ parts.push('', '---', `Instructions: ${rendered}`)
74
+
75
+ return parts.join('\n')
76
+ }
77
+
78
+ // ── Persistence ────────────────────────────────────────────────────────────
79
+
80
+ function configPath(): string {
81
+ const dir = process.env.RUFLO_PERSIST_DIR || '.ruflo'
82
+ return join(dir, 'github-webhook.json')
83
+ }
84
+
85
+ export function loadGitHubWebhookConfig(): GitHubWebhookConfig {
86
+ try {
87
+ if (existsSync(configPath())) {
88
+ const raw = JSON.parse(readFileSync(configPath(), 'utf-8'))
89
+ return { ...DEFAULT_CONFIG, ...raw }
90
+ }
91
+ } catch { /* use defaults */ }
92
+ // Fallback to env vars
93
+ return {
94
+ ...DEFAULT_CONFIG,
95
+ enabled: process.env.GITHUB_WEBHOOK_ENABLED === 'true',
96
+ githubToken: process.env.GITHUB_TOKEN || '',
97
+ webhookSecret: process.env.GITHUB_WEBHOOK_SECRET || '',
98
+ repos: process.env.GITHUB_WEBHOOK_REPOS?.split(',').map(r => r.trim()).filter(Boolean) || [],
99
+ }
100
+ }
101
+
102
+ export function saveGitHubWebhookConfig(config: GitHubWebhookConfig): void {
103
+ const dir = process.env.RUFLO_PERSIST_DIR || '.ruflo'
104
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
105
+ writeFileSync(configPath(), JSON.stringify(config, null, 2), 'utf-8')
106
+ try { chmodSync(configPath(), 0o600) } catch { /* Windows */ }
107
+ }
108
+
109
+ // ── HMAC Validation ────────────────────────────────────────────────────────
110
+
111
+ export function verifyGitHubSignature(
112
+ payload: Buffer | string,
113
+ signature: string | undefined,
114
+ secret: string,
115
+ ): boolean {
116
+ if (!signature || !secret) return false
117
+ // Strip the 'sha256=' prefix from the header value
118
+ const sig = signature.startsWith('sha256=') ? signature.slice(7) : signature
119
+ const expected = createHmac('sha256', secret).update(payload).digest('hex')
120
+ if (expected.length !== sig.length) return false
121
+ // Use timing-safe comparison to prevent timing attacks
122
+ return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'))
123
+ }
124
+
125
+ // ── In-memory event store ──────────────────────────────────────────────────
126
+
127
+ const MAX_EVENTS = 200
128
+ let webhookEvents: WebhookEvent[] = []
129
+
130
+ function addEvent(event: WebhookEvent): void {
131
+ webhookEvents.unshift(event)
132
+ if (webhookEvents.length > MAX_EVENTS) webhookEvents = webhookEvents.slice(0, MAX_EVENTS)
133
+ }
134
+
135
+ export function updateWebhookEventByTaskId(taskId: string, status: WebhookEvent['status']): void {
136
+ const event = webhookEvents.find(e => e.taskId === taskId)
137
+ if (event) event.status = status
138
+ }
139
+
140
+ export function getWebhookEvents(): WebhookEvent[] {
141
+ return webhookEvents
142
+ }
143
+
144
+ // ── Route Factory ──────────────────────────────────────────────────────────
145
+
146
+ export interface WebhookStores {
147
+ createAndAssignTask: (title: string, description: string) => Promise<{ taskId: string; assigned: boolean }>
148
+ broadcast: (type: string, payload: unknown) => void
149
+ }
150
+
151
+ export function githubWebhookRoutes(
152
+ getConfig: () => GitHubWebhookConfig,
153
+ setConfig: (c: GitHubWebhookConfig) => void,
154
+ stores: WebhookStores,
155
+ ): Router {
156
+ const router = Router()
157
+
158
+ const wrap = (fn: (req: Request, res: Response) => Promise<void>): RequestHandler =>
159
+ async (req, res, _next) => {
160
+ try { await fn(req, res) } catch (err: unknown) {
161
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
162
+ }
163
+ }
164
+
165
+ // ── Webhook receiver ───────────────────────────────────────────────────
166
+
167
+ router.post('/github', wrap(async (req, res) => {
168
+ const config = getConfig()
169
+ if (!config.enabled) {
170
+ res.status(503).json({ error: 'GitHub webhooks disabled' })
171
+ return
172
+ }
173
+
174
+ // The raw body buffer is attached by the express.raw() middleware in server.ts
175
+ const rawBody: Buffer | undefined = (req as any).rawBody
176
+ const sig = req.headers['x-hub-signature-256'] as string | undefined
177
+
178
+ // Validate HMAC signature
179
+ if (config.webhookSecret) {
180
+ if (!sig) {
181
+ res.status(401).json({ error: 'Missing X-Hub-Signature-256 header' })
182
+ return
183
+ }
184
+ if (!rawBody || rawBody.length === 0) {
185
+ res.status(400).json({ error: 'Empty request body' })
186
+ return
187
+ }
188
+ if (!verifyGitHubSignature(rawBody, sig, config.webhookSecret)) {
189
+ res.status(401).json({ error: 'Invalid signature' })
190
+ return
191
+ }
192
+ }
193
+
194
+ const ghEvent = req.headers['x-github-event'] as string || 'unknown'
195
+ const payload = req.body
196
+
197
+ // Respond to GitHub's initial ping event
198
+ if (ghEvent === 'ping') {
199
+ res.json({ ok: true, action: 'pong', zen: payload.zen || '' })
200
+ return
201
+ }
202
+
203
+ // Only handle issue events for now.
204
+ if (ghEvent !== 'issues') {
205
+ res.json({ ok: true, action: 'ignored', reason: `event type '${ghEvent}' not handled` })
206
+ return
207
+ }
208
+
209
+ const action = payload.action
210
+ if (action !== 'opened' && action !== 'reopened') {
211
+ res.json({ ok: true, action: 'ignored', reason: `issues.${action} not handled` })
212
+ return
213
+ }
214
+
215
+ const issue = payload.issue
216
+ const repo = payload.repository?.full_name || 'unknown/unknown'
217
+
218
+ // Check if this repo is monitored
219
+ if (config.repos.length > 0 && !config.repos.includes(repo)) {
220
+ res.json({ ok: true, action: 'ignored', reason: `repo '${repo}' not monitored` })
221
+ return
222
+ }
223
+
224
+ // Create webhook event
225
+ const event: WebhookEvent = {
226
+ id: `gh-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
227
+ provider: 'github',
228
+ repo,
229
+ event: `issues.${action}`,
230
+ title: issue.title || 'Untitled',
231
+ body: issue.body || '',
232
+ url: issue.html_url || '',
233
+ number: issue.number || 0,
234
+ author: issue.user?.login || 'unknown',
235
+ labels: (issue.labels || []).map((l: { name: string }) => l.name),
236
+ receivedAt: new Date().toISOString(),
237
+ status: 'received',
238
+ }
239
+ addEvent(event)
240
+ stores.broadcast('webhook:received', event)
241
+
242
+ // Auto-create task if enabled
243
+ if (config.autoAssign) {
244
+ event.status = 'processing'
245
+ stores.broadcast('webhook:updated', event)
246
+
247
+ const taskTitle = `[${repo}#${issue.number}] ${issue.title}`
248
+ const taskDesc = buildTaskDescription(event, config.taskTemplate)
249
+
250
+ try {
251
+ const result = await stores.createAndAssignTask(taskTitle, taskDesc)
252
+ event.taskId = result.taskId
253
+ event.status = result.assigned ? 'processing' : 'received'
254
+ stores.broadcast('webhook:updated', event)
255
+ } catch {
256
+ event.status = 'failed'
257
+ stores.broadcast('webhook:updated', event)
258
+ }
259
+ }
260
+
261
+ res.json({ ok: true, action: 'task_created', eventId: event.id, taskId: event.taskId })
262
+ }))
263
+
264
+ // ── Config API ─────────────────────────────────────────────────────────
265
+
266
+ router.get('/github/config', wrap(async (_req, res) => {
267
+ const config = getConfig()
268
+ res.json({
269
+ enabled: config.enabled,
270
+ hasToken: !!config.githubToken,
271
+ tokenPreview: config.githubToken ? '...' + config.githubToken.slice(-4) : '',
272
+ webhookSecret: config.webhookSecret ? '****' : '',
273
+ hasSecret: !!config.webhookSecret,
274
+ repos: config.repos,
275
+ autoAssign: config.autoAssign,
276
+ taskTemplate: config.taskTemplate || '',
277
+ })
278
+ }))
279
+
280
+ router.put('/github/config', wrap(async (req, res) => {
281
+ const config = getConfig()
282
+ const { enabled, githubToken, webhookSecret, repos, autoAssign, taskTemplate } = req.body
283
+ if (typeof enabled === 'boolean') config.enabled = enabled
284
+ if (typeof githubToken === 'string') config.githubToken = githubToken
285
+ if (typeof webhookSecret === 'string') config.webhookSecret = webhookSecret
286
+ if (Array.isArray(repos)) config.repos = repos.filter((r: unknown) => typeof r === 'string' && (r as string).includes('/'))
287
+ if (typeof autoAssign === 'boolean') config.autoAssign = autoAssign
288
+ if (typeof taskTemplate === 'string') config.taskTemplate = taskTemplate
289
+ setConfig(config)
290
+ saveGitHubWebhookConfig(config)
291
+ res.json({ ok: true })
292
+ }))
293
+
294
+ // ── Test endpoint ───────────────────────────────────────────────────────
295
+
296
+ router.post('/github/test', wrap(async (_req, res) => {
297
+ const config = getConfig()
298
+ if (!config.enabled) {
299
+ res.status(503).json({ error: 'GitHub webhooks disabled' })
300
+ return
301
+ }
302
+
303
+ const event: WebhookEvent = {
304
+ id: `gh-test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
305
+ provider: 'github',
306
+ repo: 'test/webhook-test',
307
+ event: 'issues.opened',
308
+ title: 'Test webhook event',
309
+ body: 'This is a test event sent from the RuFloUI dashboard to verify the webhook pipeline works correctly.',
310
+ url: 'https://github.com/test/webhook-test/issues/0',
311
+ number: 0,
312
+ author: 'rufloui-test',
313
+ labels: ['test'],
314
+ receivedAt: new Date().toISOString(),
315
+ status: 'received',
316
+ }
317
+ addEvent(event)
318
+ stores.broadcast('webhook:received', event)
319
+
320
+ if (config.autoAssign) {
321
+ event.status = 'processing'
322
+ stores.broadcast('webhook:updated', event)
323
+ const taskTitle = `[test/webhook-test#0] Test webhook event`
324
+ const taskDesc = buildTaskDescription(event, config.taskTemplate)
325
+ try {
326
+ const result = await stores.createAndAssignTask(taskTitle, taskDesc)
327
+ event.taskId = result.taskId
328
+ event.status = result.assigned ? 'processing' : 'received'
329
+ stores.broadcast('webhook:updated', event)
330
+ res.json({ ok: true, eventId: event.id, taskId: result.taskId, assigned: result.assigned })
331
+ } catch {
332
+ event.status = 'failed'
333
+ stores.broadcast('webhook:updated', event)
334
+ res.json({ ok: false, error: 'Task creation failed', eventId: event.id })
335
+ }
336
+ } else {
337
+ res.json({ ok: true, eventId: event.id, message: 'Test event created (auto-assign disabled)' })
338
+ }
339
+ }))
340
+
341
+ // ── Events API ─────────────────────────────────────────────────────────
342
+ // Pipeline verified: events stored via addEvent(), retrievable here,
343
+ // broadcast via 'webhook:received'/'webhook:updated' for real-time WebSocket updates.
344
+
345
+ router.get('/github/events', wrap(async (_req, res) => {
346
+ res.json(webhookEvents)
347
+ }))
348
+
349
+ return router
350
+ }