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.
- package/TESTS.md +91 -0
- package/package.json +2 -2
- package/src/backend/__tests__/e2e-workflows.test.ts +438 -0
- package/src/backend/__tests__/server-integration.test.ts +444 -0
- package/src/backend/__tests__/server-utils.test.ts +200 -0
- package/src/backend/__tests__/webhook-gitlab.test.ts +605 -0
- package/src/backend/server.ts +301 -14
- package/src/backend/webhook-github.ts +0 -1
- package/src/backend/webhook-gitlab.ts +313 -0
- package/src/frontend/__tests__/api.test.ts +375 -0
- package/src/frontend/__tests__/components.test.tsx +195 -0
- package/src/frontend/__tests__/store.test.ts +295 -0
- package/src/frontend/api.ts +8 -1
- package/src/frontend/pages/TasksPanel.tsx +59 -2
- package/src/frontend/pages/WebhooksPanel.tsx +282 -116
- package/src/frontend/types.ts +12 -1
- package/vitest.config.ts +1 -0
- package/frontend +0 -0
- package/release-notes.md +0 -27
- package/{ +0 -0
- package/{, +0 -0
- package/{,+ +0 -0
- /package/{Webhooks) → {}),} +0 -0
|
@@ -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
|
+
})
|
package/src/frontend/api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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 && (
|