rufloui 0.3.2 → 0.3.35

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.
@@ -0,0 +1,295 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { useStore } from '../store'
3
+
4
+ const testAgent = { id: 'a1', name: 'coder-1', type: 'coder', status: 'idle' as const, createdAt: '2025-01-01T00:00:00Z' }
5
+ const testAgent2 = { id: 'a2', name: 'tester-1', type: 'tester', status: 'running' as const, createdAt: '2025-01-01T01:00:00Z' }
6
+ const testTask = { id: 't1', title: 'Test', description: 'Desc', status: 'pending' as const, priority: 'normal' as const, createdAt: '2025-01-01T00:00:00Z' }
7
+ const testTask2 = { id: 't2', title: 'Test 2', description: 'Desc 2', status: 'in_progress' as const, priority: 'high' as const, createdAt: '2025-01-01T01:00:00Z' }
8
+
9
+ beforeEach(() => {
10
+ useStore.setState({
11
+ systemHealth: null, connected: false, wsStatus: 'disconnected',
12
+ backendReachable: false, swarm: null, agents: [], tasks: [],
13
+ memoryEntries: [], memoryStats: null, sessions: [], activeSession: null,
14
+ hiveMind: null, neural: null, performance: null, hooks: [], workflows: [],
15
+ coordination: null, vizSessions: [], selectedVizNode: null, swarmMonitor: null,
16
+ logs: [], _initialLoaded: false,
17
+ })
18
+ })
19
+
20
+ // ── Simple setters ──────────────────────────────────────────────────────
21
+
22
+ describe('simple setters', () => {
23
+ it('setConnected updates connected', () => {
24
+ useStore.getState().setConnected(true)
25
+ expect(useStore.getState().connected).toBe(true)
26
+ })
27
+
28
+ it('setWsStatus updates wsStatus', () => {
29
+ useStore.getState().setWsStatus('connected')
30
+ expect(useStore.getState().wsStatus).toBe('connected')
31
+ })
32
+
33
+ it('setBackendReachable updates backendReachable', () => {
34
+ useStore.getState().setBackendReachable(true)
35
+ expect(useStore.getState().backendReachable).toBe(true)
36
+ })
37
+
38
+ it('setSystemHealth updates systemHealth', () => {
39
+ const health = { status: 'healthy' as const, uptime: '5m', cpu: 10, memory: 50, activeConnections: 1, mcpStatus: 'ok' }
40
+ useStore.getState().setSystemHealth(health)
41
+ expect(useStore.getState().systemHealth).toEqual(health)
42
+ })
43
+
44
+ it('setSwarm sets or clears swarm', () => {
45
+ const swarm = { id: 's1', topology: 'mesh' as const, strategy: 'round-robin', status: 'active' as const, maxAgents: 8, activeAgents: 2, agents: [], createdAt: '2025-01-01' }
46
+ useStore.getState().setSwarm(swarm)
47
+ expect(useStore.getState().swarm?.id).toBe('s1')
48
+ useStore.getState().setSwarm(null)
49
+ expect(useStore.getState().swarm).toBeNull()
50
+ })
51
+
52
+ it('setInitialLoaded sets _initialLoaded', () => {
53
+ expect(useStore.getState()._initialLoaded).toBe(false)
54
+ useStore.getState().setInitialLoaded()
55
+ expect(useStore.getState()._initialLoaded).toBe(true)
56
+ })
57
+ })
58
+
59
+ // ── Agent actions ───────────────────────────────────────────────────────
60
+
61
+ describe('agent actions', () => {
62
+ it('setAgents replaces agents array', () => {
63
+ useStore.getState().setAgents([testAgent])
64
+ expect(useStore.getState().agents).toHaveLength(1)
65
+ expect(useStore.getState().agents[0].id).toBe('a1')
66
+ })
67
+
68
+ it('addAgent appends to agents', () => {
69
+ useStore.getState().setAgents([testAgent])
70
+ useStore.getState().addAgent(testAgent2)
71
+ expect(useStore.getState().agents).toHaveLength(2)
72
+ expect(useStore.getState().agents[1].id).toBe('a2')
73
+ })
74
+
75
+ it('updateAgent merges partial update', () => {
76
+ useStore.getState().setAgents([testAgent, testAgent2])
77
+ useStore.getState().updateAgent('a1', { status: 'running', currentTask: 'fix-bug' })
78
+ const updated = useStore.getState().agents.find(a => a.id === 'a1')
79
+ expect(updated?.status).toBe('running')
80
+ expect(updated?.currentTask).toBe('fix-bug')
81
+ expect(updated?.name).toBe('coder-1') // unchanged
82
+ })
83
+
84
+ it('updateAgent does nothing for unknown id', () => {
85
+ useStore.getState().setAgents([testAgent])
86
+ useStore.getState().updateAgent('unknown', { status: 'error' })
87
+ expect(useStore.getState().agents).toHaveLength(1)
88
+ expect(useStore.getState().agents[0].status).toBe('idle')
89
+ })
90
+
91
+ it('removeAgent filters out by id', () => {
92
+ useStore.getState().setAgents([testAgent, testAgent2])
93
+ useStore.getState().removeAgent('a1')
94
+ expect(useStore.getState().agents).toHaveLength(1)
95
+ expect(useStore.getState().agents[0].id).toBe('a2')
96
+ })
97
+
98
+ it('removeAgent does nothing for unknown id', () => {
99
+ useStore.getState().setAgents([testAgent])
100
+ useStore.getState().removeAgent('unknown')
101
+ expect(useStore.getState().agents).toHaveLength(1)
102
+ })
103
+ })
104
+
105
+ // ── Task actions ────────────────────────────────────────────────────────
106
+
107
+ describe('task actions', () => {
108
+ it('setTasks replaces tasks array', () => {
109
+ useStore.getState().setTasks([testTask])
110
+ expect(useStore.getState().tasks).toHaveLength(1)
111
+ })
112
+
113
+ it('addTask appends to tasks', () => {
114
+ useStore.getState().setTasks([testTask])
115
+ useStore.getState().addTask(testTask2)
116
+ expect(useStore.getState().tasks).toHaveLength(2)
117
+ })
118
+
119
+ it('updateTask merges partial update', () => {
120
+ useStore.getState().setTasks([testTask])
121
+ useStore.getState().updateTask('t1', { status: 'completed', result: 'done' })
122
+ const updated = useStore.getState().tasks.find(t => t.id === 't1')
123
+ expect(updated?.status).toBe('completed')
124
+ expect(updated?.result).toBe('done')
125
+ expect(updated?.title).toBe('Test') // unchanged
126
+ })
127
+
128
+ it('updateTask does nothing for unknown id', () => {
129
+ useStore.getState().setTasks([testTask])
130
+ useStore.getState().updateTask('unknown', { status: 'failed' })
131
+ expect(useStore.getState().tasks[0].status).toBe('pending')
132
+ })
133
+ })
134
+
135
+ // ── Log actions ─────────────────────────────────────────────────────────
136
+
137
+ describe('addLog', () => {
138
+ it('adds a log entry with id and timestamp', () => {
139
+ useStore.getState().addLog({ level: 'info', message: 'test', source: 'api' })
140
+ const logs = useStore.getState().logs
141
+ expect(logs).toHaveLength(1)
142
+ expect(logs[0].level).toBe('info')
143
+ expect(logs[0].message).toBe('test')
144
+ expect(logs[0].source).toBe('api')
145
+ expect(logs[0].id).toBeTruthy()
146
+ expect(logs[0].timestamp).toBeTruthy()
147
+ })
148
+
149
+ it('prepends new logs (newest first)', () => {
150
+ useStore.getState().addLog({ level: 'info', message: 'first', source: 'api' })
151
+ useStore.getState().addLog({ level: 'info', message: 'second', source: 'api' })
152
+ expect(useStore.getState().logs[0].message).toBe('second')
153
+ expect(useStore.getState().logs[1].message).toBe('first')
154
+ })
155
+
156
+ it('caps logs at 500 entries', () => {
157
+ for (let i = 0; i < 510; i++) {
158
+ useStore.getState().addLog({ level: 'info', message: `log-${i}`, source: 'api' })
159
+ }
160
+ expect(useStore.getState().logs.length).toBeLessThanOrEqual(500)
161
+ })
162
+ })
163
+
164
+ // ── Other collection setters ────────────────────────────────────────────
165
+
166
+ describe('collection setters', () => {
167
+ it('setMemoryEntries sets memoryEntries', () => {
168
+ const entries = [{ key: 'k', value: 'v', namespace: 'default', tags: [], createdAt: '', updatedAt: '' }]
169
+ useStore.getState().setMemoryEntries(entries)
170
+ expect(useStore.getState().memoryEntries).toHaveLength(1)
171
+ })
172
+
173
+ it('setSessions sets sessions', () => {
174
+ const sessions = [{ id: 's1', name: 'test', status: 'saved' as const, createdAt: '', agentCount: 0, taskCount: 0 }]
175
+ useStore.getState().setSessions(sessions)
176
+ expect(useStore.getState().sessions).toHaveLength(1)
177
+ })
178
+
179
+ it('setWorkflows sets workflows', () => {
180
+ const workflows = [{ id: 'w1', name: 'wf', status: 'draft' as const, steps: [], createdAt: '' }]
181
+ useStore.getState().setWorkflows(workflows)
182
+ expect(useStore.getState().workflows).toHaveLength(1)
183
+ })
184
+
185
+ it('setHooks sets hooks', () => {
186
+ const hooks = [{ name: 'pre-task', type: 'shell', enabled: true, trigger: 'pre-task', runCount: 0 }]
187
+ useStore.getState().setHooks(hooks)
188
+ expect(useStore.getState().hooks).toHaveLength(1)
189
+ })
190
+ })
191
+
192
+ // ── Viz session actions ────────────────────────────────────────────────
193
+
194
+ describe('viz session actions', () => {
195
+ it('setVizSessions sets vizSessions', () => {
196
+ const sessions = [{
197
+ sessionId: 'v1', taskId: 't1', startedAt: '2025-01-01',
198
+ tree: { id: 'root', sessionId: 'v1', status: 'active' as const, children: [] },
199
+ }]
200
+ useStore.getState().setVizSessions(sessions)
201
+ expect(useStore.getState().vizSessions).toHaveLength(1)
202
+ })
203
+
204
+ it('updateVizSession updates existing session', () => {
205
+ const tree = { id: 'root', sessionId: 'v1', status: 'active' as const, children: [] }
206
+ useStore.getState().setVizSessions([{ sessionId: 'v1', taskId: 't1', startedAt: '2025-01-01', tree }])
207
+
208
+ const updatedTree = { ...tree, status: 'done' as const }
209
+ useStore.getState().updateVizSession('v1', updatedTree)
210
+
211
+ const session = useStore.getState().vizSessions.find(v => v.sessionId === 'v1')
212
+ expect(session?.tree.status).toBe('done')
213
+ })
214
+
215
+ it('updateVizSession adds new session if not found', () => {
216
+ useStore.getState().setVizSessions([])
217
+ const tree = { id: 'root', sessionId: 'v2', status: 'active' as const, children: [], taskId: 'task-1' }
218
+ useStore.getState().updateVizSession('v2', tree)
219
+ expect(useStore.getState().vizSessions).toHaveLength(1)
220
+ expect(useStore.getState().vizSessions[0].sessionId).toBe('v2')
221
+ })
222
+
223
+ it('setSelectedVizNode sets and clears', () => {
224
+ useStore.getState().setSelectedVizNode('node-1')
225
+ expect(useStore.getState().selectedVizNode).toBe('node-1')
226
+ useStore.getState().setSelectedVizNode(null)
227
+ expect(useStore.getState().selectedVizNode).toBeNull()
228
+ })
229
+ })
230
+
231
+ // ── Swarm Monitor actions ──────────────────────────────────────────────
232
+
233
+ describe('swarmMonitor actions', () => {
234
+ it('setSwarmMonitor sets or clears swarm monitor state', () => {
235
+ const monitor = {
236
+ swarmId: 's1', status: 'active', topology: 'mesh', objective: 'test',
237
+ strategy: 'round-robin', progress: 50,
238
+ agents: [], agentSummary: { total: 4, active: 2, idle: 1, completed: 1 },
239
+ taskSummary: { total: 10, completed: 5, inProgress: 3, pending: 2 },
240
+ metrics: { tokensUsed: 1000, avgResponseTime: '50ms', successRate: '90%', elapsedTime: '5m' },
241
+ coordination: { consensusRounds: 3, messagesSent: 100, conflictsResolved: 2 },
242
+ }
243
+ useStore.getState().setSwarmMonitor(monitor)
244
+ expect(useStore.getState().swarmMonitor?.swarmId).toBe('s1')
245
+ useStore.getState().setSwarmMonitor(null)
246
+ expect(useStore.getState().swarmMonitor).toBeNull()
247
+ })
248
+ })
249
+
250
+ // ── Additional setter tests ────────────────────────────────────────────
251
+
252
+ describe('additional setters', () => {
253
+ it('setMemoryStats sets memoryStats', () => {
254
+ const stats = { totalEntries: 10, namespaces: ['default'], storageSize: '1MB', hnswEnabled: true, indexedVectors: 5 }
255
+ useStore.getState().setMemoryStats(stats)
256
+ expect(useStore.getState().memoryStats?.totalEntries).toBe(10)
257
+ })
258
+
259
+ it('setActiveSession sets and clears', () => {
260
+ const session = { id: 's1', name: 'test', status: 'active' as const, createdAt: '', agentCount: 0, taskCount: 0 }
261
+ useStore.getState().setActiveSession(session)
262
+ expect(useStore.getState().activeSession?.id).toBe('s1')
263
+ useStore.getState().setActiveSession(null)
264
+ expect(useStore.getState().activeSession).toBeNull()
265
+ })
266
+
267
+ it('setHiveMind sets hive mind state', () => {
268
+ const hm = { status: 'active' as const, members: ['a1', 'a2'], consensusProtocol: 'majority' }
269
+ useStore.getState().setHiveMind(hm)
270
+ expect(useStore.getState().hiveMind?.status).toBe('active')
271
+ expect(useStore.getState().hiveMind?.members).toHaveLength(2)
272
+ })
273
+
274
+ it('setNeural sets neural state', () => {
275
+ const neural = { enabled: true, models: [{ name: 'gpt', status: 'ready', accuracy: 0.95 }], trainingQueue: 0 }
276
+ useStore.getState().setNeural(neural)
277
+ expect(useStore.getState().neural?.enabled).toBe(true)
278
+ expect(useStore.getState().neural?.models).toHaveLength(1)
279
+ })
280
+
281
+ it('setPerformance sets performance metrics', () => {
282
+ const perf = {
283
+ latency: { avg: 50, p95: 100, p99: 200 }, throughput: 100,
284
+ errorRate: 0.01, activeRequests: 5, history: [],
285
+ }
286
+ useStore.getState().setPerformance(perf)
287
+ expect(useStore.getState().performance?.throughput).toBe(100)
288
+ })
289
+
290
+ it('setCoordination sets coordination metrics', () => {
291
+ const coord = { topology: 'mesh', nodes: 4, syncLatency: 10, consensusRounds: 5, loadDistribution: { a: 1, b: 2 } }
292
+ useStore.getState().setCoordination(coord)
293
+ expect(useStore.getState().coordination?.topology).toBe('mesh')
294
+ })
295
+ })
@@ -1,5 +1,5 @@
1
1
  import { useStore } from './store'
2
- import type { WebhookEvent, GitHubWebhookStatus } from './types'
2
+ import type { WebhookEvent, GitHubWebhookStatus, GitLabWebhookStatus } from './types'
3
3
 
4
4
  export interface PreflightCheck {
5
5
  id: string
@@ -115,6 +115,7 @@ export const api = {
115
115
  complete: (id: string, result?: string) =>
116
116
  request(`/tasks/${id}/complete`, { method: 'POST', body: JSON.stringify({ result }) }),
117
117
  cancel: (id: string) => request(`/tasks/${id}/cancel`, { method: 'POST' }),
118
+ cleanCompleted: () => request<{ ok: boolean; deleted: number }>('/tasks/clean-completed', { method: 'POST' }),
118
119
  continue: (id: string, instruction: string) =>
119
120
  request(`/tasks/${id}/continue`, { method: 'POST', body: JSON.stringify({ instruction }) }),
120
121
  output: (id: string, tail = 200) =>
@@ -260,6 +261,12 @@ export const api = {
260
261
  getGitHubEvents: () => request<WebhookEvent[]>('/webhooks/github/events'),
261
262
  testGitHub: () => request<{ ok: boolean; eventId?: string; taskId?: string; error?: string }>(
262
263
  '/webhooks/github/test', { method: 'POST' }),
264
+ getGitLabConfig: () => request<GitLabWebhookStatus>('/webhooks/gitlab/config'),
265
+ setGitLabConfig: (config: Record<string, unknown>) =>
266
+ request('/webhooks/gitlab/config', { method: 'PUT', body: JSON.stringify(config) }),
267
+ getGitLabEvents: () => request<WebhookEvent[]>('/webhooks/gitlab/events'),
268
+ testGitLab: () => request<{ ok: boolean; eventId?: string; taskId?: string; error?: string }>(
269
+ '/webhooks/gitlab/test', { method: 'POST' }),
263
270
  },
264
271
  }
265
272
 
@@ -316,8 +316,13 @@ export default function TasksPanel() {
316
316
  const [expandedId, setExpandedId] = useState<string | null>(null)
317
317
  const [summary, setSummary] = useState<TaskSummary | null>(null)
318
318
  const [taskOutputs, setTaskOutputs] = useState<Record<string, string[]>>({})
319
+ const [cleaning, setCleaning] = useState(false)
319
320
  const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined)
320
321
  const wsRef = useRef<WebSocket | null>(null)
322
+ // Track tasks updated via WebSocket to avoid poll overwriting them
323
+ const wsUpdatedRef = useRef<Map<string, { task: Task; ts: number }>>(new Map())
324
+ // Track cleaned task IDs so poll doesn't re-add them
325
+ const cleanedIdsRef = useRef<{ ids: Set<string>; ts: number }>({ ids: new Set(), ts: 0 })
321
326
 
322
327
  // Load persisted output history for a task
323
328
  const loadOutputHistory = useCallback(async (taskId: string) => {
@@ -358,6 +363,13 @@ export default function TasksPanel() {
358
363
  }))
359
364
  }
360
365
  }
366
+ // Track task updates from WebSocket so polling doesn't overwrite them
367
+ if ((msg.type === 'task:updated' || msg.type === 'task:added') && msg.payload) {
368
+ const t = msg.payload as Task
369
+ if (t.id) {
370
+ wsUpdatedRef.current.set(t.id, { task: t, ts: Date.now() })
371
+ }
372
+ }
361
373
  } catch { /* ignore */ }
362
374
  }
363
375
  return () => { ws.close() }
@@ -374,7 +386,33 @@ export default function TasksPanel() {
374
386
  ])
375
387
  if (taskRes.status === 'fulfilled') {
376
388
  const data = taskRes.value as { tasks?: Task[] } | Task[]
377
- setTasks(Array.isArray(data) ? data : (data.tasks ?? []))
389
+ let polledTasks = Array.isArray(data) ? data : (data.tasks ?? [])
390
+ // Merge: prefer WebSocket-updated tasks over stale poll data (within 10s window)
391
+ const now = Date.now()
392
+ const wsMap = wsUpdatedRef.current
393
+ polledTasks = polledTasks.map(t => {
394
+ const ws = wsMap.get(t.id)
395
+ if (ws && now - ws.ts < 10_000) return ws.task
396
+ return t
397
+ })
398
+ // Add any WS tasks not yet in poll results (newly created)
399
+ for (const [id, ws] of wsMap.entries()) {
400
+ if (now - ws.ts < 10_000 && !polledTasks.find(t => t.id === id)) {
401
+ polledTasks.push(ws.task)
402
+ }
403
+ }
404
+ // Clean up old WS entries
405
+ for (const [id, ws] of wsMap.entries()) {
406
+ if (now - ws.ts > 10_000) wsMap.delete(id)
407
+ }
408
+ // Filter out recently cleaned tasks (prevent poll re-adding them)
409
+ const cleaned = cleanedIdsRef.current
410
+ if (cleaned.ids.size > 0 && now - cleaned.ts < 10_000) {
411
+ polledTasks = polledTasks.filter(t => !cleaned.ids.has(t.id))
412
+ } else if (cleaned.ids.size > 0) {
413
+ cleanedIdsRef.current = { ids: new Set(), ts: 0 }
414
+ }
415
+ setTasks(polledTasks)
378
416
  }
379
417
  if (summaryRes.status === 'fulfilled') {
380
418
  setSummary(summaryRes.value as TaskSummary)
@@ -392,6 +430,19 @@ export default function TasksPanel() {
392
430
  return () => clearInterval(intervalRef.current)
393
431
  }, [fetchData])
394
432
 
433
+ const handleCleanCompleted = async () => {
434
+ setCleaning(true)
435
+ // Optimistic: remove from local store immediately and prevent poll re-adding
436
+ const removedIds = new Set(tasks.filter(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled').map(t => t.id))
437
+ cleanedIdsRef.current = { ids: removedIds, ts: Date.now() }
438
+ setTasks(tasks.filter(t => !removedIds.has(t.id)))
439
+ try {
440
+ await api.tasks.cleanCompleted()
441
+ } catch { /* silent */ }
442
+ setCleaning(false)
443
+ fetchData()
444
+ }
445
+
395
446
  const handleCreate = async () => {
396
447
  if (!title.trim()) return
397
448
  setCreating(true)
@@ -477,7 +528,13 @@ export default function TasksPanel() {
477
528
  <div key={col.key} style={s.column}>
478
529
  <div style={s.colHeader}>
479
530
  {col.label}
480
- <span style={s.colCount}>{colTasks.length}</span>
531
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
532
+ {(col.key === 'completed' || col.key === 'failed') && colTasks.length > 0 && (
533
+ <Button size="sm" variant="ghost" loading={cleaning} onClick={(e) => { e.stopPropagation(); handleCleanCompleted() }}
534
+ style={{ fontSize: 10, padding: '2px 6px', color: 'var(--text-muted)' }}>Clean</Button>
535
+ )}
536
+ <span style={s.colCount}>{colTasks.length}</span>
537
+ </div>
481
538
  </div>
482
539
  <div style={s.colBody}>
483
540
  {colTasks.length === 0 && (