agentdev-webui 1.0.0

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 (39) hide show
  1. package/lib/agent-api.js +530 -0
  2. package/lib/auth.js +127 -0
  3. package/lib/config.js +53 -0
  4. package/lib/database.js +762 -0
  5. package/lib/device-flow.js +257 -0
  6. package/lib/email.js +420 -0
  7. package/lib/encryption.js +112 -0
  8. package/lib/github.js +339 -0
  9. package/lib/history.js +143 -0
  10. package/lib/pwa.js +107 -0
  11. package/lib/redis-logs.js +226 -0
  12. package/lib/routes.js +680 -0
  13. package/migrations/000_create_database.sql +33 -0
  14. package/migrations/001_create_agentdev_schema.sql +135 -0
  15. package/migrations/001_create_agentdev_schema.sql.old +100 -0
  16. package/migrations/001_create_agentdev_schema_fixed.sql +135 -0
  17. package/migrations/002_add_github_token.sql +17 -0
  18. package/migrations/003_add_agent_logs_table.sql +23 -0
  19. package/migrations/004_remove_oauth_columns.sql +11 -0
  20. package/migrations/005_add_projects.sql +44 -0
  21. package/migrations/006_project_github_token.sql +7 -0
  22. package/migrations/007_project_repositories.sql +12 -0
  23. package/migrations/008_add_notifications.sql +20 -0
  24. package/migrations/009_unified_oauth.sql +153 -0
  25. package/migrations/README.md +97 -0
  26. package/package.json +37 -0
  27. package/public/css/styles.css +1140 -0
  28. package/public/device.html +384 -0
  29. package/public/docs.html +862 -0
  30. package/public/docs.md +697 -0
  31. package/public/favicon.svg +5 -0
  32. package/public/index.html +271 -0
  33. package/public/js/app.js +2379 -0
  34. package/public/login.html +224 -0
  35. package/public/profile.html +394 -0
  36. package/public/register.html +392 -0
  37. package/public/reset-password.html +349 -0
  38. package/public/verify-email.html +177 -0
  39. package/server.js +1450 -0
package/server.js ADDED
@@ -0,0 +1,1450 @@
1
+ #!/usr/bin/env node
2
+ const http = require('http');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const config = require('./lib/config');
6
+ const history = require('./lib/history');
7
+ const github = require('./lib/github');
8
+ const pwa = require('./lib/pwa');
9
+ const auth = require('./lib/auth');
10
+ const routes = require('./lib/routes');
11
+ const db = require('./lib/database');
12
+ const emailService = require('./lib/email');
13
+ const agentApi = require('./lib/agent-api');
14
+ const deviceFlow = require('./lib/device-flow');
15
+ const encryption = require('./lib/encryption');
16
+ const crypto = require('crypto');
17
+ const redisLogs = require('./lib/redis-logs');
18
+
19
+ // Ensure directories exist
20
+ if (!fs.existsSync(config.LOG_FILE)) {
21
+ fs.writeFileSync(config.LOG_FILE, '');
22
+ }
23
+ if (!fs.existsSync(config.AGENT_LOGS_DIR)) {
24
+ fs.mkdirSync(config.AGENT_LOGS_DIR, { recursive: true });
25
+ }
26
+
27
+ // Initialize history
28
+ history.loadHistory();
29
+ history.importExistingLogs();
30
+
31
+ const clients = new Set();
32
+ const agentSizes = new Map();
33
+ let mainLogSize = 0;
34
+ let proc = null;
35
+
36
+ // Ring buffer for recent log lines (sent to newly connecting clients)
37
+ const MAX_LOG_BUFFER = 200;
38
+ const logBuffer = [];
39
+
40
+ function bufferLog(entry) {
41
+ logBuffer.push(entry);
42
+ if (logBuffer.length > MAX_LOG_BUFFER) {
43
+ logBuffer.shift();
44
+ }
45
+ }
46
+
47
+ // Track previously active agents to detect completion
48
+ const previousActiveAgents = new Map();
49
+
50
+ // Watch main log file
51
+ fs.watchFile(config.LOG_FILE, { interval: 500 }, (curr) => {
52
+ if (curr.size > mainLogSize) {
53
+ const s = fs.createReadStream(config.LOG_FILE, { start: mainLogSize, end: curr.size });
54
+ let d = '';
55
+ s.on('data', c => d += c);
56
+ s.on('end', () => d.split('\n').filter(l => l.trim()).forEach(l => {
57
+ const entry = { type: 'log', content: l };
58
+ bufferLog(entry);
59
+ broadcast(entry);
60
+ }));
61
+ }
62
+ mainLogSize = curr.size;
63
+ });
64
+
65
+ // Seed log buffer with last lines from main log file on startup
66
+ try {
67
+ const logContent = fs.readFileSync(config.LOG_FILE, 'utf8');
68
+ const lines = logContent.split('\n').filter(l => l.trim());
69
+ const recentLines = lines.slice(-MAX_LOG_BUFFER);
70
+ recentLines.forEach(l => bufferLog({ type: 'log', content: l }));
71
+ } catch (e) { /* ignore */ }
72
+
73
+ // Watch agent logs directory
74
+ function watchAgentLogs() {
75
+ try {
76
+ if (!fs.existsSync(config.AGENT_LOGS_DIR)) return;
77
+
78
+ const files = fs.readdirSync(config.AGENT_LOGS_DIR).filter(f => f.endsWith('.log'));
79
+
80
+ files.forEach(file => {
81
+ const filePath = path.join(config.AGENT_LOGS_DIR, file);
82
+ const agentId = file.replace('.log', '');
83
+
84
+ if (!agentSizes.has(filePath)) {
85
+ agentSizes.set(filePath, 0);
86
+
87
+ try {
88
+ fs.watchFile(filePath, { interval: 500 }, (curr) => {
89
+ const lastSize = agentSizes.get(filePath) || 0;
90
+ if (curr.size > lastSize) {
91
+ const s = fs.createReadStream(filePath, { start: lastSize, end: curr.size });
92
+ let d = '';
93
+ s.on('data', c => d += c);
94
+ s.on('end', () => {
95
+ d.split('\n').filter(l => l.trim()).forEach(l => {
96
+ const entry = { type: 'log', content: l, agent: agentId };
97
+ bufferLog(entry);
98
+ broadcast(entry);
99
+ });
100
+ });
101
+ }
102
+ agentSizes.set(filePath, curr.size);
103
+ });
104
+ } catch (err) {
105
+ console.error(`Failed to watch file ${filePath}:`, err.message);
106
+ }
107
+
108
+ if (fs.existsSync(filePath)) {
109
+ const content = fs.readFileSync(filePath, 'utf8');
110
+ agentSizes.set(filePath, content.length);
111
+ }
112
+ }
113
+ });
114
+ } catch (error) {
115
+ console.error('Error in watchAgentLogs:', error.message);
116
+ }
117
+ }
118
+
119
+ async function broadcastAgents() {
120
+ console.log('[broadcastAgents] Function called at', new Date().toISOString());
121
+ try {
122
+ console.log('[broadcastAgents] Starting query...');
123
+ // Get active agents from database (heartbeat within last 2 minutes)
124
+ const activeAgents = await db.getActiveAgents();
125
+ console.log(`[broadcastAgents] Found ${activeAgents.length} active agents from DB`);
126
+
127
+ const currentActiveIds = new Set();
128
+ const agents = [];
129
+
130
+ for (const agentRow of activeAgents) {
131
+ const agentId = agentRow.id;
132
+ const ticket = agentRow.github_issue_number;
133
+ const repo = agentRow.github_repo;
134
+
135
+ let title = null;
136
+ let description = null;
137
+ let ticketStatus = agentRow.ticket_status || 'OPEN';
138
+ let authorName = null;
139
+ let authorAvatar = null;
140
+ let createdAt = null;
141
+
142
+ // Fetch GitHub ticket details
143
+ if (repo && ticket) {
144
+ try {
145
+ const details = await github.fetchTicketDetails(repo, ticket);
146
+ if (details) {
147
+ title = details.title;
148
+ description = details.body || '';
149
+ ticketStatus = details.state || 'OPEN';
150
+ if (details.author) {
151
+ authorName = details.author.login || details.author.name;
152
+ authorAvatar = details.author.avatarUrl;
153
+ }
154
+ createdAt = details.createdAt;
155
+ }
156
+ } catch (err) {
157
+ console.error(`[broadcastAgents] Failed to fetch ticket details for ${repo}#${ticket}:`, err.message);
158
+ // Continue without GitHub details
159
+ }
160
+ }
161
+
162
+ const agent = {
163
+ id: agentId,
164
+ ticket,
165
+ repo,
166
+ title,
167
+ active: true,
168
+ startTime: agentRow.registered_at?.toISOString(),
169
+ description,
170
+ ticketStatus,
171
+ authorName,
172
+ authorAvatar,
173
+ createdAt,
174
+ status: agentRow.status,
175
+ project_id: agentRow.project_id
176
+ };
177
+
178
+ agents.push(agent);
179
+ currentActiveIds.add(agentId);
180
+ previousActiveAgents.set(agentId, agent);
181
+ }
182
+
183
+ // Check for agents that were active but are now complete
184
+ for (const [agentId, agent] of previousActiveAgents) {
185
+ if (!currentActiveIds.has(agentId)) {
186
+ // Agent completed - it will appear in history via database query
187
+ previousActiveAgents.delete(agentId);
188
+ }
189
+ }
190
+
191
+ console.log(`Broadcasting ${agents.length} active agents`);
192
+ broadcast({ type: 'agents', list: agents });
193
+
194
+ // Get history from database (inactive agents)
195
+ const historyAgents = await db.getAgentHistory(100);
196
+ const historyList = historyAgents.map(a => ({
197
+ id: a.id,
198
+ ticket: a.github_issue_number,
199
+ repo: a.github_repo,
200
+ title: null, // Will be fetched from GitHub if needed
201
+ time: a.last_heartbeat ? new Date(a.last_heartbeat).toLocaleString() : 'Unknown',
202
+ completedAt: a.last_heartbeat ? new Date(a.last_heartbeat).toLocaleString() : null,
203
+ active: false,
204
+ status: a.status,
205
+ project_id: a.project_id
206
+ }));
207
+
208
+ console.log(`Broadcasting ${historyList.length} history items`);
209
+ broadcast({ type: 'history', list: historyList });
210
+ } catch (error) {
211
+ console.error('Error broadcasting agents:', error);
212
+ }
213
+ }
214
+
215
+ setInterval(watchAgentLogs, 2000);
216
+ watchAgentLogs();
217
+
218
+ // Broadcast agent status every 5 seconds
219
+ setInterval(broadcastAgents, 5000);
220
+ broadcastAgents();
221
+
222
+ // Reclaim stale/failed tickets every 2 minutes
223
+ setInterval(async () => {
224
+ try {
225
+ const reclaimed = await db.reclaimStaleTickets(3, 3);
226
+ if (reclaimed.length > 0) {
227
+ for (const t of reclaimed) {
228
+ if (t.reason === 'retry') {
229
+ console.log(`[ticket-reclaim] Retrying failed ticket #${t.github_issue_number} (attempt ${t.retry_count}/3)`);
230
+ } else if (t.reason === 'idle_agent') {
231
+ console.log(`[ticket-reclaim] Reset ticket #${t.github_issue_number} (agent alive but idle)`);
232
+ } else {
233
+ console.log(`[ticket-reclaim] Reset stale ticket #${t.github_issue_number}`);
234
+ }
235
+ }
236
+ }
237
+ } catch (err) {
238
+ console.error('[ticket-reclaim] Error:', err.message);
239
+ }
240
+ }, 120000);
241
+
242
+ // Broadcast project tickets from GitHub every 10 seconds (all projects)
243
+ async function resolveProjectToken(project) {
244
+ // 1. Project-specific token
245
+ if (project.github_token) return project.github_token;
246
+ // 2. Project creator's unified OAuth token
247
+ if (project.created_by) {
248
+ try {
249
+ const oauthProvider = await db.getOAuthProvider(project.created_by, 'github');
250
+ if (oauthProvider?.access_token) {
251
+ return encryption.decrypt(oauthProvider.access_token);
252
+ }
253
+ // Fallback to legacy column
254
+ const user = await db.getUserById(project.created_by);
255
+ if (user?.github_token_encrypted) {
256
+ return encryption.decrypt(user.github_token_encrypted);
257
+ }
258
+ } catch (e) { /* ignore */ }
259
+ }
260
+ // 3. Any user's OAuth token (first available)
261
+ try {
262
+ const result = await db.query('SELECT access_token FROM agentdev_oauth_providers WHERE provider_key = $1 AND access_token IS NOT NULL AND status = $2 LIMIT 1', ['github', 'active']);
263
+ if (result.rows.length > 0) {
264
+ return encryption.decrypt(result.rows[0].access_token);
265
+ }
266
+ // Fallback to legacy column
267
+ const legacyResult = await db.query('SELECT github_token_encrypted FROM agentdev_users WHERE github_token_encrypted IS NOT NULL LIMIT 1');
268
+ if (legacyResult.rows.length > 0) {
269
+ return encryption.decrypt(legacyResult.rows[0].github_token_encrypted);
270
+ }
271
+ } catch (e) { /* ignore */ }
272
+ // 4. Env var fallback
273
+ return process.env.GH_TOKEN || process.env.GITHUB_DEFAULT_TOKEN || null;
274
+ }
275
+
276
+ async function broadcastTodoTickets() {
277
+ try {
278
+ let allTickets = [];
279
+ let projects = [];
280
+ try {
281
+ const result = await db.query('SELECT * FROM agentdev_projects ORDER BY id ASC');
282
+ projects = result.rows;
283
+ } catch (e) {
284
+ // DB not ready yet, fallback to single project from config
285
+ }
286
+
287
+ if (projects.length > 0) {
288
+ for (const project of projects) {
289
+ const projectConfig = {
290
+ github_org: project.github_org,
291
+ project_number: project.project_number,
292
+ github_project_id: project.github_project_id,
293
+ status_field_id: project.status_field_id,
294
+ status_options: typeof project.status_options === 'string'
295
+ ? JSON.parse(project.status_options)
296
+ : project.status_options,
297
+ };
298
+ const token = await resolveProjectToken(project);
299
+ const tickets = await github.fetchProjectTickets(projectConfig, token, { claudeOnly: false });
300
+ tickets.forEach(t => { t.project_id = project.id; });
301
+ allTickets.push(...tickets);
302
+ }
303
+ } else {
304
+ // Fallback: no projects in DB yet, use default config
305
+ allTickets = await github.fetchProjectTickets();
306
+ }
307
+
308
+ broadcast({ type: 'todos', list: allTickets });
309
+ } catch (error) {
310
+ console.error('Error broadcasting project tickets:', error.message);
311
+ }
312
+ }
313
+ setInterval(broadcastTodoTickets, 10000);
314
+ broadcastTodoTickets();
315
+
316
+ function broadcast(data) {
317
+ const m = 'data: ' + JSON.stringify(data) + '\n\n';
318
+ clients.forEach(c => c.write(m));
319
+ }
320
+
321
+ // Listen for ticket completion events from agent-api and broadcast via SSE
322
+ agentApi.events.on('ticket-completed', (data) => {
323
+ broadcast({ type: 'ticket-completed', ticket: data });
324
+ });
325
+
326
+ // Static file serving
327
+ const MIME_TYPES = {
328
+ '.html': 'text/html',
329
+ '.css': 'text/css',
330
+ '.js': 'application/javascript',
331
+ '.json': 'application/json',
332
+ '.png': 'image/png',
333
+ '.svg': 'image/svg+xml',
334
+ '.md': 'text/markdown; charset=utf-8'
335
+ };
336
+
337
+ function serveStaticFile(filePath, res) {
338
+ const ext = path.extname(filePath);
339
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
340
+
341
+ fs.readFile(filePath, (err, content) => {
342
+ if (err) {
343
+ res.writeHead(404);
344
+ res.end('Not found');
345
+ } else {
346
+ res.writeHead(200, { 'Content-Type': contentType });
347
+ res.end(content);
348
+ }
349
+ });
350
+ }
351
+
352
+ http.createServer(async (req, res) => {
353
+ const url = req.url.split('?')[0];
354
+
355
+ // ============================================================================
356
+ // Try new agent API routes first (from lib/routes.js)
357
+ // ============================================================================
358
+
359
+ const handled = routes.registerRoutes(req, res);
360
+ if (handled) {
361
+ return; // Route was handled by new API
362
+ }
363
+
364
+ // ============================================================================
365
+ // Existing routes (authentication, PWA, GitHub integration)
366
+ // ============================================================================
367
+
368
+ // Login page
369
+ if (url === '/login' || url === '/login.html') {
370
+ serveStaticFile(path.join(__dirname, 'public', 'login.html'), res);
371
+ return;
372
+ }
373
+
374
+ // Login endpoint
375
+ if (url === '/api/login' && req.method === 'POST') {
376
+ let body = '';
377
+ req.on('data', chunk => body += chunk);
378
+ req.on('end', async () => {
379
+ try {
380
+ const { username, password } = JSON.parse(body);
381
+ const user = await auth.validateCredentials(username, password);
382
+ if (user) {
383
+ // Check if email is verified
384
+ if (!user.email_verified) {
385
+ res.writeHead(403, { 'Content-Type': 'application/json' });
386
+ res.end(JSON.stringify({ success: false, error: 'Please verify your email before logging in' }));
387
+ return;
388
+ }
389
+ const sessionId = auth.createSession(user.id, user.email);
390
+ // Add Secure flag for HTTPS (production)
391
+ const isSecure = process.env.NODE_ENV === 'production' || process.env.BASE_URL?.startsWith('https://');
392
+ const secureCookie = isSecure ? '; Secure' : '';
393
+ res.writeHead(200, {
394
+ 'Content-Type': 'application/json',
395
+ 'Set-Cookie': `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict${secureCookie}; Max-Age=${config.AUTH.SESSION_TTL / 1000}`
396
+ });
397
+ res.end(JSON.stringify({ success: true }));
398
+ } else {
399
+ res.writeHead(401, { 'Content-Type': 'application/json' });
400
+ res.end(JSON.stringify({ success: false, error: 'Invalid credentials' }));
401
+ }
402
+ } catch (e) {
403
+ console.error('Login error:', e);
404
+ res.writeHead(400, { 'Content-Type': 'application/json' });
405
+ res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
406
+ }
407
+ });
408
+ return;
409
+ }
410
+
411
+ // Registration endpoint
412
+ if (url === '/api/register' && req.method === 'POST') {
413
+ let body = '';
414
+ req.on('data', chunk => body += chunk);
415
+ req.on('end', async () => {
416
+ try {
417
+ const { email, password, maxAgents } = JSON.parse(body);
418
+
419
+ // Validation
420
+ if (!email || !password) {
421
+ res.writeHead(400, { 'Content-Type': 'application/json' });
422
+ res.end(JSON.stringify({ success: false, error: 'Email and password are required' }));
423
+ return;
424
+ }
425
+
426
+ // Email validation
427
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
428
+ if (!emailRegex.test(email)) {
429
+ res.writeHead(400, { 'Content-Type': 'application/json' });
430
+ res.end(JSON.stringify({ success: false, error: 'Invalid email format' }));
431
+ return;
432
+ }
433
+
434
+ // Password validation
435
+ if (password.length < 8) {
436
+ res.writeHead(400, { 'Content-Type': 'application/json' });
437
+ res.end(JSON.stringify({ success: false, error: 'Password must be at least 8 characters' }));
438
+ return;
439
+ }
440
+
441
+ if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
442
+ res.writeHead(400, { 'Content-Type': 'application/json' });
443
+ res.end(JSON.stringify({ success: false, error: 'Password must contain uppercase, lowercase, and number' }));
444
+ return;
445
+ }
446
+
447
+ // Max agents validation
448
+ const agentLimit = maxAgents ? parseInt(maxAgents) : 3;
449
+ if (agentLimit < 1 || agentLimit > 10) {
450
+ res.writeHead(400, { 'Content-Type': 'application/json' });
451
+ res.end(JSON.stringify({ success: false, error: 'Max agents must be between 1 and 10' }));
452
+ return;
453
+ }
454
+
455
+ // Hash password and create user
456
+ const passwordHash = auth.hashPassword(password);
457
+ const verificationToken = crypto.randomBytes(32).toString('hex');
458
+ const verificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
459
+
460
+ const user = await db.createUser(email, passwordHash, {
461
+ maxAgents: agentLimit
462
+ });
463
+
464
+ // Set verification token
465
+ await db.query(
466
+ 'UPDATE agentdev_users SET email_verification_token = $1, email_verification_expires = $2 WHERE id = $3',
467
+ [verificationToken, verificationExpires, user.id]
468
+ );
469
+
470
+ // Send verification email
471
+ await emailService.sendVerificationEmail(email, verificationToken, config.BASE_URL);
472
+
473
+ res.writeHead(201, { 'Content-Type': 'application/json' });
474
+ res.end(JSON.stringify({
475
+ success: true,
476
+ message: 'Registration successful! Please check your email to verify your account.',
477
+ user: {
478
+ id: user.id,
479
+ email: user.email,
480
+ maxAgents: user.max_agents
481
+ }
482
+ }));
483
+ } catch (error) {
484
+ console.error('Registration error:', error);
485
+
486
+ // Handle duplicate email
487
+ if (error.code === '23505') {
488
+ res.writeHead(409, { 'Content-Type': 'application/json' });
489
+ res.end(JSON.stringify({ success: false, error: 'Email already registered' }));
490
+ return;
491
+ }
492
+
493
+ res.writeHead(500, { 'Content-Type': 'application/json' });
494
+ res.end(JSON.stringify({ success: false, error: 'Registration failed' }));
495
+ }
496
+ });
497
+ return;
498
+ }
499
+
500
+ // Password reset request endpoint
501
+ if (url === '/api/reset-password/request' && req.method === 'POST') {
502
+ let body = '';
503
+ req.on('data', chunk => body += chunk);
504
+ req.on('end', async () => {
505
+ try {
506
+ const { email: userEmail } = JSON.parse(body);
507
+ if (!userEmail) {
508
+ res.writeHead(400, { 'Content-Type': 'application/json' });
509
+ res.end(JSON.stringify({ success: false, error: 'Email is required' }));
510
+ return;
511
+ }
512
+ const user = await db.getUserByEmail(userEmail);
513
+ if (!user) {
514
+ res.writeHead(200, { 'Content-Type': 'application/json' });
515
+ res.end(JSON.stringify({ success: true }));
516
+ return;
517
+ }
518
+ const resetToken = crypto.randomBytes(32).toString('hex');
519
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
520
+ await db.query(
521
+ 'INSERT INTO agentdev_password_resets (user_id, token, expires_at) VALUES ($1, $2, $3)',
522
+ [user.id, resetToken, expiresAt]
523
+ );
524
+ await emailService.sendPasswordResetEmail(userEmail, resetToken, config.BASE_URL);
525
+ res.writeHead(200, { 'Content-Type': 'application/json' });
526
+ res.end(JSON.stringify({ success: true }));
527
+ } catch (error) {
528
+ console.error('Password reset request error:', error);
529
+ res.writeHead(500, { 'Content-Type': 'application/json' });
530
+ res.end(JSON.stringify({ success: false, error: 'Failed to process request' }));
531
+ }
532
+ });
533
+ return;
534
+ }
535
+
536
+ // Password reset confirm endpoint
537
+ if (url === '/api/reset-password/confirm' && req.method === 'POST') {
538
+ let body = '';
539
+ req.on('data', chunk => body += chunk);
540
+ req.on('end', async () => {
541
+ try {
542
+ const { token, password } = JSON.parse(body);
543
+ if (!token || !password) {
544
+ res.writeHead(400, { 'Content-Type': 'application/json' });
545
+ res.end(JSON.stringify({ success: false, error: 'Token and password required' }));
546
+ return;
547
+ }
548
+ if (password.length < 8) {
549
+ res.writeHead(400, { 'Content-Type': 'application/json' });
550
+ res.end(JSON.stringify({ success: false, error: 'Password must be at least 8 characters' }));
551
+ return;
552
+ }
553
+ const result = await db.query(
554
+ `SELECT pr.id, pr.user_id FROM agentdev_password_resets pr
555
+ WHERE pr.token = $1 AND pr.expires_at > NOW() AND pr.used_at IS NULL`,
556
+ [token]
557
+ );
558
+ if (result.rows.length === 0) {
559
+ res.writeHead(400, { 'Content-Type': 'application/json' });
560
+ res.end(JSON.stringify({ success: false, error: 'Invalid or expired reset token' }));
561
+ return;
562
+ }
563
+ const resetRecord = result.rows[0];
564
+ const passwordHash = auth.hashPassword(password);
565
+ await db.query(
566
+ 'UPDATE agentdev_users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
567
+ [passwordHash, resetRecord.user_id]
568
+ );
569
+ await db.query(
570
+ 'UPDATE agentdev_password_resets SET used_at = NOW() WHERE id = $1',
571
+ [resetRecord.id]
572
+ );
573
+ res.writeHead(200, { 'Content-Type': 'application/json' });
574
+ res.end(JSON.stringify({ success: true }));
575
+ } catch (error) {
576
+ console.error('Password reset confirm error:', error);
577
+ res.writeHead(500, { 'Content-Type': 'application/json' });
578
+ res.end(JSON.stringify({ success: false, error: 'Failed to reset password' }));
579
+ }
580
+ });
581
+ return;
582
+ }
583
+
584
+ // Email verification endpoint
585
+ if (url === '/api/verify-email' && req.method === 'POST') {
586
+ let body = '';
587
+ req.on('data', chunk => body += chunk);
588
+ req.on('end', async () => {
589
+ try {
590
+ const { token } = JSON.parse(body);
591
+ if (!token) {
592
+ res.writeHead(400, { 'Content-Type': 'application/json' });
593
+ res.end(JSON.stringify({ success: false, error: 'Token is required' }));
594
+ return;
595
+ }
596
+ const result = await db.query(
597
+ `SELECT id, email FROM agentdev_users
598
+ WHERE email_verification_token = $1 AND email_verification_expires > NOW() AND email_verified = FALSE`,
599
+ [token]
600
+ );
601
+ if (result.rows.length === 0) {
602
+ res.writeHead(400, { 'Content-Type': 'application/json' });
603
+ res.end(JSON.stringify({ success: false, error: 'Invalid or expired verification token' }));
604
+ return;
605
+ }
606
+ const user = result.rows[0];
607
+ await db.query(
608
+ 'UPDATE agentdev_users SET email_verified = TRUE, email_verification_token = NULL, email_verification_expires = NULL WHERE id = $1',
609
+ [user.id]
610
+ );
611
+ res.writeHead(200, { 'Content-Type': 'application/json' });
612
+ res.end(JSON.stringify({ success: true, message: 'Email verified successfully' }));
613
+ } catch (error) {
614
+ console.error('Email verification error:', error);
615
+ res.writeHead(500, { 'Content-Type': 'application/json' });
616
+ res.end(JSON.stringify({ success: false, error: 'Verification failed' }));
617
+ }
618
+ });
619
+ return;
620
+ }
621
+
622
+ // Logout endpoint
623
+ if (url === '/api/logout' && req.method === 'POST') {
624
+ const sessionId = auth.getSessionFromRequest(req);
625
+ if (sessionId) {
626
+ auth.destroySession(sessionId);
627
+ }
628
+ res.writeHead(200, {
629
+ 'Content-Type': 'application/json',
630
+ 'Set-Cookie': 'session=; Path=/; HttpOnly; Max-Age=0'
631
+ });
632
+ res.end(JSON.stringify({ success: true }));
633
+ return;
634
+ }
635
+
636
+ // SSE logs endpoint (before auth check so authenticated users can access)
637
+ if (url === '/logs') {
638
+ // Check if user is authenticated
639
+ if (!auth.isAuthenticated(req)) {
640
+ res.writeHead(401, { 'Content-Type': 'application/json' });
641
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
642
+ return;
643
+ }
644
+
645
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
646
+ clients.add(res);
647
+
648
+ // Subscribe to all agent logs (pattern: agentdev:logs:*)
649
+ const logCallback = (agentId, logData) => {
650
+ try {
651
+ res.write('data: ' + JSON.stringify({
652
+ type: 'log',
653
+ content: logData.content,
654
+ agent: agentId,
655
+ level: logData.level || 'INFO'
656
+ }) + '\n\n');
657
+ } catch (error) {
658
+ console.error('Error sending log to client:', error);
659
+ }
660
+ };
661
+
662
+ // Subscribe to all agent logs
663
+ redisLogs.subscribeToLogs('*', logCallback).catch(err => {
664
+ console.error('Failed to subscribe to logs:', err);
665
+ });
666
+
667
+ // Send recent logs from database
668
+ db.getRecentLogs(50).then(logs => {
669
+ logs.forEach(log => {
670
+ res.write('data: ' + JSON.stringify({
671
+ type: 'log',
672
+ content: log.content,
673
+ agent: log.agent_id,
674
+ level: log.level
675
+ }) + '\n\n');
676
+ });
677
+
678
+ // If no DB logs, send buffered file-based logs
679
+ if (logs.length === 0 && logBuffer.length > 0) {
680
+ logBuffer.forEach(entry => {
681
+ try {
682
+ res.write('data: ' + JSON.stringify(entry) + '\n\n');
683
+ } catch (e) { /* client may have disconnected */ }
684
+ });
685
+ }
686
+ }).catch(err => {
687
+ console.error('Failed to load recent logs:', err);
688
+ // Fallback: send buffered logs even if DB query fails
689
+ logBuffer.forEach(entry => {
690
+ try {
691
+ res.write('data: ' + JSON.stringify(entry) + '\n\n');
692
+ } catch (e) { /* client may have disconnected */ }
693
+ });
694
+ });
695
+
696
+ broadcastAgents();
697
+ broadcastTodoTickets();
698
+
699
+ req.on('close', () => {
700
+ clients.delete(res);
701
+ redisLogs.unsubscribeFromLogs('agentdev:logs:*', logCallback).catch(err => {
702
+ console.error('Failed to unsubscribe:', err);
703
+ });
704
+ });
705
+ return;
706
+ }
707
+
708
+ // Check authentication for all other routes (except public paths)
709
+ if (auth.requireAuth(req, res)) {
710
+ return; // Not authenticated, response already sent
711
+ }
712
+
713
+ // Profile API endpoints
714
+ if (url === '/api/profile' && req.method === 'GET') {
715
+ const sessionId = auth.getSessionFromRequest(req);
716
+ if (!auth.validateSession(sessionId)) {
717
+ res.writeHead(401, { 'Content-Type': 'application/json' });
718
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
719
+ return;
720
+ }
721
+ const session = auth.getSession(sessionId);
722
+
723
+ try {
724
+ const user = await db.getUserById(session.userId);
725
+ // Check unified OAuth table for GitHub token
726
+ const oauthProvider = await db.getOAuthProvider(session.userId, 'github');
727
+ res.writeHead(200, { 'Content-Type': 'application/json' });
728
+ res.end(JSON.stringify({
729
+ email: user.email,
730
+ max_agents: user.max_agents || 3,
731
+ has_github_token: !!(oauthProvider?.access_token) || !!user.github_token_encrypted
732
+ }));
733
+ } catch (error) {
734
+ res.writeHead(500, { 'Content-Type': 'application/json' });
735
+ res.end(JSON.stringify({ error: 'Failed to load profile' }));
736
+ }
737
+ return;
738
+ }
739
+
740
+ // Notification API endpoints
741
+ if (url === '/api/notifications' && req.method === 'GET') {
742
+ const sessionId = auth.getSessionFromRequest(req);
743
+ if (!auth.validateSession(sessionId)) {
744
+ res.writeHead(401, { 'Content-Type': 'application/json' });
745
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
746
+ return;
747
+ }
748
+ const session = auth.getSession(sessionId);
749
+ try {
750
+ const notifications = await db.getUnreadNotifications(session.userId);
751
+ res.writeHead(200, { 'Content-Type': 'application/json' });
752
+ res.end(JSON.stringify({ notifications }));
753
+ } catch (error) {
754
+ res.writeHead(500, { 'Content-Type': 'application/json' });
755
+ res.end(JSON.stringify({ error: 'Failed to load notifications' }));
756
+ }
757
+ return;
758
+ }
759
+
760
+ if (url === '/api/notifications/read' && req.method === 'POST') {
761
+ const sessionId = auth.getSessionFromRequest(req);
762
+ if (!auth.validateSession(sessionId)) {
763
+ res.writeHead(401, { 'Content-Type': 'application/json' });
764
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
765
+ return;
766
+ }
767
+ const session = auth.getSession(sessionId);
768
+ try {
769
+ await db.markNotificationsRead(session.userId);
770
+ res.writeHead(200, { 'Content-Type': 'application/json' });
771
+ res.end(JSON.stringify({ success: true }));
772
+ } catch (error) {
773
+ res.writeHead(500, { 'Content-Type': 'application/json' });
774
+ res.end(JSON.stringify({ error: 'Failed to mark notifications read' }));
775
+ }
776
+ return;
777
+ }
778
+
779
+ if (url === '/api/profile/limits' && req.method === 'POST') {
780
+ const sessionId = auth.getSessionFromRequest(req);
781
+ if (!auth.validateSession(sessionId)) {
782
+ res.writeHead(401, { 'Content-Type': 'application/json' });
783
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
784
+ return;
785
+ }
786
+ const session = auth.getSession(sessionId);
787
+
788
+ let body = '';
789
+ req.on('data', chunk => body += chunk);
790
+ req.on('end', async () => {
791
+ try {
792
+ const { max_agents } = JSON.parse(body);
793
+ await db.updateUserLimits(session.userId, { max_agents });
794
+
795
+ res.writeHead(200, { 'Content-Type': 'application/json' });
796
+ res.end(JSON.stringify({ success: true }));
797
+ } catch (error) {
798
+ res.writeHead(500, { 'Content-Type': 'application/json' });
799
+ res.end(JSON.stringify({ error: 'Failed to save limits' }));
800
+ }
801
+ });
802
+ return;
803
+ }
804
+
805
+ if (url === '/api/profile/token' && req.method === 'POST') {
806
+ const sessionId = auth.getSessionFromRequest(req);
807
+ if (!auth.validateSession(sessionId)) {
808
+ res.writeHead(401, { 'Content-Type': 'application/json' });
809
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
810
+ return;
811
+ }
812
+ const session = auth.getSession(sessionId);
813
+
814
+ let body = '';
815
+ req.on('data', chunk => body += chunk);
816
+ req.on('end', async () => {
817
+ try {
818
+ const { github_token } = JSON.parse(body);
819
+
820
+ if (!github_token || !github_token.trim()) {
821
+ res.writeHead(400, { 'Content-Type': 'application/json' });
822
+ res.end(JSON.stringify({ error: 'GitHub token is required' }));
823
+ return;
824
+ }
825
+
826
+ // Encrypt GitHub token
827
+ const encryptedToken = encryption.encrypt(github_token);
828
+
829
+ console.log(`Saving GitHub token for user ID: ${session.userId}`);
830
+
831
+ // Save to unified OAuth providers table
832
+ try {
833
+ const githubConfig = await db.getOAuthProviderConfig('github');
834
+ if (githubConfig) {
835
+ await db.upsertOAuthProvider({
836
+ userId: session.userId,
837
+ configId: githubConfig.id,
838
+ providerKey: 'github',
839
+ accessToken: encryptedToken,
840
+ scopes: ['repo', 'read:org', 'project']
841
+ });
842
+ console.log(`Saved to unified OAuth table for user ${session.userId}`);
843
+ }
844
+ } catch (oauthErr) {
845
+ console.error('Failed to save to unified OAuth table (will use legacy):', oauthErr.message);
846
+ }
847
+
848
+ // Also save to legacy column for backward compatibility
849
+ const result = await db.updateUserGitHubToken(session.userId, encryptedToken);
850
+ console.log(`Update result:`, result);
851
+
852
+ if (!result) {
853
+ console.error(`No user found with ID: ${session.userId}`);
854
+ res.writeHead(404, { 'Content-Type': 'application/json' });
855
+ res.end(JSON.stringify({ error: 'User not found' }));
856
+ return;
857
+ }
858
+
859
+ res.writeHead(200, { 'Content-Type': 'application/json' });
860
+ res.end(JSON.stringify({ success: true }));
861
+ } catch (error) {
862
+ console.error('GitHub token save error:', error);
863
+ res.writeHead(500, { 'Content-Type': 'application/json' });
864
+ res.end(JSON.stringify({ error: 'Failed to save GitHub token' }));
865
+ }
866
+ });
867
+ return;
868
+ }
869
+
870
+ if (url === '/api/agents' && req.method === 'GET') {
871
+ const sessionId = auth.getSessionFromRequest(req);
872
+ if (!auth.validateSession(sessionId)) {
873
+ res.writeHead(401, { 'Content-Type': 'application/json' });
874
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
875
+ return;
876
+ }
877
+ const session = auth.getSession(sessionId);
878
+
879
+ try {
880
+ const agents = await db.getAgentsByUser(session.userId);
881
+ res.writeHead(200, { 'Content-Type': 'application/json' });
882
+ res.end(JSON.stringify(agents));
883
+ } catch (error) {
884
+ res.writeHead(500, { 'Content-Type': 'application/json' });
885
+ res.end(JSON.stringify({ error: 'Failed to load agents' }));
886
+ }
887
+ return;
888
+ }
889
+
890
+ // Stop a running agent's ticket
891
+ if (url === '/api/agent/stop' && req.method === 'POST') {
892
+ const sessionId = auth.getSessionFromRequest(req);
893
+ if (!auth.validateSession(sessionId)) {
894
+ res.writeHead(401, { 'Content-Type': 'application/json' });
895
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
896
+ return;
897
+ }
898
+
899
+ let body = '';
900
+ req.on('data', chunk => body += chunk);
901
+ req.on('end', async () => {
902
+ try {
903
+ const { agent_id } = JSON.parse(body);
904
+ if (!agent_id) {
905
+ res.writeHead(400, { 'Content-Type': 'application/json' });
906
+ res.end(JSON.stringify({ success: false, error: 'agent_id is required' }));
907
+ return;
908
+ }
909
+
910
+ const agent = await db.getAgentById(agent_id);
911
+ if (!agent) {
912
+ res.writeHead(404, { 'Content-Type': 'application/json' });
913
+ res.end(JSON.stringify({ success: false, error: 'Agent not found' }));
914
+ return;
915
+ }
916
+
917
+ // Mark ticket as failed if agent has one
918
+ if (agent.current_ticket_id) {
919
+ await db.updateTicketStatus(agent.current_ticket_id, 'failed', 'Stopped by user');
920
+
921
+ // Set stop signal in Redis (TTL 60s)
922
+ await redisLogs.set(`agentdev:stop:${agent.current_ticket_id}`, '1', 60);
923
+ }
924
+
925
+ // Set agent to idle
926
+ await db.updateAgentHeartbeat(agent_id, 'idle', null);
927
+
928
+ // Broadcast updated agent list
929
+ broadcastAgents();
930
+
931
+ res.writeHead(200, { 'Content-Type': 'application/json' });
932
+ res.end(JSON.stringify({ success: true }));
933
+ } catch (error) {
934
+ console.error('Stop agent error:', error);
935
+ res.writeHead(500, { 'Content-Type': 'application/json' });
936
+ res.end(JSON.stringify({ success: false, error: error.message }));
937
+ }
938
+ });
939
+ return;
940
+ }
941
+
942
+ // Device approval endpoints
943
+ if (url === '/api/device/info' && req.method === 'POST') {
944
+ const sessionId = auth.getSessionFromRequest(req);
945
+ if (!auth.validateSession(sessionId)) {
946
+ res.writeHead(401, { 'Content-Type': 'application/json' });
947
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
948
+ return;
949
+ }
950
+
951
+ let body = '';
952
+ req.on('data', chunk => body += chunk);
953
+ req.on('end', async () => {
954
+ try {
955
+ const { user_code } = JSON.parse(body);
956
+ const deviceInfo = await deviceFlow.getDeviceCodeByUserCode(user_code);
957
+
958
+ if (!deviceInfo) {
959
+ res.writeHead(404, { 'Content-Type': 'application/json' });
960
+ res.end(JSON.stringify({ error: 'Invalid or expired code' }));
961
+ return;
962
+ }
963
+
964
+ res.writeHead(200, { 'Content-Type': 'application/json' });
965
+ res.end(JSON.stringify(deviceInfo));
966
+ } catch (error) {
967
+ res.writeHead(500, { 'Content-Type': 'application/json' });
968
+ res.end(JSON.stringify({ error: 'Failed to get device info' }));
969
+ }
970
+ });
971
+ return;
972
+ }
973
+
974
+ if (url === '/api/device/approve' && req.method === 'POST') {
975
+ const sessionId = auth.getSessionFromRequest(req);
976
+ if (!auth.validateSession(sessionId)) {
977
+ res.writeHead(401, { 'Content-Type': 'application/json' });
978
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
979
+ return;
980
+ }
981
+ const session = auth.getSession(sessionId);
982
+
983
+ let body = '';
984
+ req.on('data', chunk => body += chunk);
985
+ req.on('end', async () => {
986
+ try {
987
+ const { user_code, project_id } = JSON.parse(body);
988
+ await deviceFlow.approveDeviceCode(user_code, session.userId, project_id || null);
989
+
990
+ res.writeHead(200, { 'Content-Type': 'application/json' });
991
+ res.end(JSON.stringify({ success: true }));
992
+ } catch (error) {
993
+ console.error('Device approval error:', error);
994
+ res.writeHead(500, { 'Content-Type': 'application/json' });
995
+ res.end(JSON.stringify({ error: 'Failed to approve device' }));
996
+ }
997
+ });
998
+ return;
999
+ }
1000
+
1001
+ // Agent API endpoints are handled by routes.js (before requireAuth)
1002
+ // SSE broadcast for ticket-completed is handled via agentApi.events listener above
1003
+
1004
+ // Serve index.html for root
1005
+ if (url === '/') {
1006
+ serveStaticFile(path.join(__dirname, 'public', 'index.html'), res);
1007
+ }
1008
+ // Profile page
1009
+ else if (url === '/profile' || url === '/profile.html') {
1010
+ serveStaticFile(path.join(__dirname, 'public', 'profile.html'), res);
1011
+ }
1012
+ // Device authorization page
1013
+ else if (url === '/device' || url === '/device.html') {
1014
+ serveStaticFile(path.join(__dirname, 'public', 'device.html'), res);
1015
+ }
1016
+ // Registration page
1017
+ else if (url === '/register' || url === '/register.html') {
1018
+ serveStaticFile(path.join(__dirname, 'public', 'register.html'), res);
1019
+ }
1020
+ // Password reset page
1021
+ else if (url === '/reset-password' || url === '/reset-password.html') {
1022
+ serveStaticFile(path.join(__dirname, 'public', 'reset-password.html'), res);
1023
+ }
1024
+ // Email verification page
1025
+ else if (url === '/verify-email' || url === '/verify-email.html') {
1026
+ serveStaticFile(path.join(__dirname, 'public', 'verify-email.html'), res);
1027
+ }
1028
+ // Documentation page (public, no auth required)
1029
+ else if (url === '/docs' || url === '/docs.html') {
1030
+ serveStaticFile(path.join(__dirname, 'public', 'docs.html'), res);
1031
+ }
1032
+ // Documentation in markdown format
1033
+ else if (url === '/docs.md') {
1034
+ serveStaticFile(path.join(__dirname, 'public', 'docs.md'), res);
1035
+ }
1036
+ // Serve static files from public directory
1037
+ else if (url.startsWith('/css/') || url.startsWith('/js/')) {
1038
+ serveStaticFile(path.join(__dirname, 'public', url), res);
1039
+ }
1040
+ // PWA manifest
1041
+ else if (url === '/manifest.json') {
1042
+ res.writeHead(200, { 'Content-Type': 'application/manifest+json' });
1043
+ res.end(JSON.stringify(pwa.getManifest()));
1044
+ }
1045
+ // Service worker
1046
+ else if (url === '/sw.js') {
1047
+ res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache, no-store, must-revalidate' });
1048
+ res.end(pwa.getServiceWorkerCode());
1049
+ }
1050
+ // Favicon
1051
+ else if (url === '/favicon.svg' || url === '/favicon.ico') {
1052
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
1053
+ res.end(pwa.getIconSvg(32));
1054
+ }
1055
+ // PWA icons
1056
+ else if (url === '/icon-192.png' || url === '/icon-512.png') {
1057
+ const size = url.includes('192') ? 192 : 512;
1058
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
1059
+ res.end(pwa.getIconSvg(size));
1060
+ }
1061
+ // OG image for social sharing
1062
+ else if (url === '/og-image.svg') {
1063
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
1064
+ res.end(pwa.getOgImageSvg());
1065
+ }
1066
+ // Create ticket endpoint
1067
+ else if (url === '/create-ticket' && req.method === 'POST') {
1068
+ let body = '';
1069
+ req.on('data', chunk => body += chunk);
1070
+ req.on('end', async () => {
1071
+ try {
1072
+ const { repo, title, body: ticketBody, project_id } = JSON.parse(body);
1073
+
1074
+ if (!repo || !title || !ticketBody) {
1075
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1076
+ res.end(JSON.stringify({ success: false, error: 'Missing required fields' }));
1077
+ return;
1078
+ }
1079
+
1080
+ // Get user's GitHub token from session
1081
+ const sessionId = auth.getSessionFromRequest(req);
1082
+ if (!auth.validateSession(sessionId)) {
1083
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1084
+ res.end(JSON.stringify({ success: false, error: 'Not authenticated' }));
1085
+ return;
1086
+ }
1087
+ const session = auth.getSession(sessionId);
1088
+
1089
+ const user = await db.getUserById(session.userId);
1090
+
1091
+ // Look up project config from DB (with token)
1092
+ let projectConfig = null;
1093
+ let projectId = project_id ? parseInt(project_id) : null;
1094
+
1095
+ // Default to first project if none specified
1096
+ if (!projectId) {
1097
+ const projects = await db.getProjects();
1098
+ if (projects.length > 0) {
1099
+ projectId = projects[0].id;
1100
+ }
1101
+ }
1102
+
1103
+ if (projectId) {
1104
+ const project = await db.getProjectWithToken(projectId);
1105
+ if (project) {
1106
+ projectConfig = {
1107
+ github_org: project.github_org,
1108
+ project_number: project.project_number,
1109
+ github_project_id: project.github_project_id,
1110
+ status_field_id: project.status_field_id,
1111
+ status_options: typeof project.status_options === 'string'
1112
+ ? JSON.parse(project.status_options)
1113
+ : project.status_options,
1114
+ };
1115
+ }
1116
+ }
1117
+
1118
+ // Resolve GitHub token: unified OAuth table > legacy column > project token > default
1119
+ let githubToken = null;
1120
+ const oauthProvider = await db.getOAuthProvider(session.userId, 'github');
1121
+ if (oauthProvider?.access_token) {
1122
+ githubToken = encryption.decrypt(oauthProvider.access_token);
1123
+ }
1124
+ if (!githubToken && user?.github_token_encrypted) {
1125
+ githubToken = encryption.decrypt(user.github_token_encrypted);
1126
+ }
1127
+ if (!githubToken && projectId) {
1128
+ const projectFull = await db.getProjectWithToken(projectId);
1129
+ if (projectFull?.github_token) {
1130
+ githubToken = projectFull.github_token;
1131
+ }
1132
+ }
1133
+
1134
+ if (!githubToken) {
1135
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1136
+ res.end(JSON.stringify({ success: false, error: 'No GitHub token available. Add one in your profile or in the project settings.' }));
1137
+ return;
1138
+ }
1139
+
1140
+ console.log(`Using GitHub token for user ${session.userId}, token starts with: ${githubToken.substring(0, 10)}...`);
1141
+
1142
+ const issue = await github.createTicket(repo, title, ticketBody, githubToken, projectConfig);
1143
+
1144
+ // Add to project board
1145
+ const addedToProject = await github.addToProject(repo, issue.number, githubToken, projectConfig);
1146
+ console.log('Ticket #' + issue.number + ' created and added to project');
1147
+
1148
+ // Insert into database so agents can find it
1149
+ if (addedToProject) {
1150
+ const projectItemId = await github.getProjectItemId(repo, issue.number, githubToken, projectConfig);
1151
+ await db.createTicket({
1152
+ issueNumber: issue.number,
1153
+ repo: repo,
1154
+ projectItemId: projectItemId,
1155
+ priority: 5,
1156
+ projectId: projectId
1157
+ });
1158
+ console.log('Ticket #' + issue.number + ' added to database');
1159
+ }
1160
+
1161
+ // Clear cache and broadcast updated board immediately
1162
+ github.clearTicketCache();
1163
+ broadcastTodoTickets();
1164
+
1165
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1166
+ res.end(JSON.stringify({ success: true, number: issue.number, url: issue.url, repo }));
1167
+
1168
+ } catch (e) {
1169
+ console.error('Error creating ticket:', e.message);
1170
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1171
+ res.end(JSON.stringify({ success: false, error: e.message }));
1172
+ }
1173
+ });
1174
+ }
1175
+ // Add comment endpoint
1176
+ else if (url === '/add-comment' && req.method === 'POST') {
1177
+ let body = '';
1178
+ req.on('data', chunk => body += chunk);
1179
+ req.on('end', async () => {
1180
+ try {
1181
+ const { repo, issue, comment, project_id } = JSON.parse(body);
1182
+
1183
+ if (!repo || !issue || !comment) {
1184
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1185
+ res.end(JSON.stringify({ success: false, error: 'Missing required fields' }));
1186
+ return;
1187
+ }
1188
+
1189
+ // Look up project config from DB (with token)
1190
+ let projectConfig = null;
1191
+ let statusOptions = config.STATUS_OPTIONS;
1192
+ let projectId = project_id ? parseInt(project_id) : null;
1193
+
1194
+ // Default to first project if none specified
1195
+ if (!projectId) {
1196
+ const projects = await db.getProjects();
1197
+ if (projects.length > 0) {
1198
+ projectId = projects[0].id;
1199
+ }
1200
+ }
1201
+
1202
+ if (projectId) {
1203
+ const project = await db.getProjectWithToken(projectId);
1204
+ if (project) {
1205
+ projectConfig = {
1206
+ github_org: project.github_org,
1207
+ project_number: project.project_number,
1208
+ github_project_id: project.github_project_id,
1209
+ status_field_id: project.status_field_id,
1210
+ status_options: typeof project.status_options === 'string'
1211
+ ? JSON.parse(project.status_options)
1212
+ : project.status_options,
1213
+ };
1214
+ statusOptions = projectConfig.status_options;
1215
+ }
1216
+ }
1217
+
1218
+ // Resolve GitHub token: unified OAuth table > legacy column > project token > default
1219
+ const sessionId = auth.getSessionFromRequest(req);
1220
+ const session = auth.getSession(sessionId);
1221
+ let githubToken = null;
1222
+ if (session?.userId) {
1223
+ const oauthProvider = await db.getOAuthProvider(session.userId, 'github');
1224
+ if (oauthProvider?.access_token) {
1225
+ githubToken = encryption.decrypt(oauthProvider.access_token);
1226
+ }
1227
+ if (!githubToken) {
1228
+ const user = await db.getUserById(session.userId);
1229
+ if (user?.github_token_encrypted) {
1230
+ githubToken = encryption.decrypt(user.github_token_encrypted);
1231
+ }
1232
+ }
1233
+ }
1234
+ if (!githubToken && projectId) {
1235
+ const projectFull = await db.getProjectWithToken(projectId);
1236
+ if (projectFull?.github_token) {
1237
+ githubToken = projectFull.github_token;
1238
+ }
1239
+ }
1240
+
1241
+ let reopened = false;
1242
+
1243
+ // Check if issue is closed
1244
+ const issueState = await github.getIssueState(repo, issue, githubToken, projectConfig);
1245
+
1246
+ if (issueState === 'CLOSED') {
1247
+ // Reopen the issue
1248
+ await github.reopenIssue(repo, issue, githubToken, projectConfig);
1249
+ console.log('Reopened issue #' + issue);
1250
+ reopened = true;
1251
+
1252
+ // Move to Todo in project board
1253
+ const itemId = await github.getProjectItemId(repo, issue, githubToken, projectConfig);
1254
+ if (itemId) {
1255
+ const todoOptionId = statusOptions.TODO || statusOptions.todo;
1256
+ await github.moveToStatus(itemId, todoOptionId, githubToken, projectConfig);
1257
+ console.log('Moved issue #' + issue + ' to Todo');
1258
+ }
1259
+ }
1260
+
1261
+ // Add the comment
1262
+ await github.addComment(repo, issue, comment, githubToken, projectConfig);
1263
+ console.log('Comment added to issue #' + issue);
1264
+
1265
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1266
+ res.end(JSON.stringify({ success: true, reopened }));
1267
+
1268
+ } catch (e) {
1269
+ console.error('Error adding comment:', e.message);
1270
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1271
+ res.end(JSON.stringify({ success: false, error: e.message }));
1272
+ }
1273
+ });
1274
+ }
1275
+ // Move ticket between board columns
1276
+ else if (url === '/api/tickets/move' && req.method === 'POST') {
1277
+ if (!auth.isAuthenticated(req)) {
1278
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1279
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1280
+ return;
1281
+ }
1282
+
1283
+ let body = '';
1284
+ req.on('data', chunk => body += chunk);
1285
+ req.on('end', async () => {
1286
+ try {
1287
+ const { projectItemId, targetStatus, projectId } = JSON.parse(body);
1288
+ if (!projectItemId || !targetStatus) {
1289
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1290
+ res.end(JSON.stringify({ error: 'Missing projectItemId or targetStatus' }));
1291
+ return;
1292
+ }
1293
+
1294
+ // Map display status to internal key
1295
+ const STATUS_KEY_MAP = { 'Todo': 'TODO', 'In Progress': 'IN_PROGRESS', 'test': 'TEST', 'Done': 'DONE' };
1296
+ const statusKey = STATUS_KEY_MAP[targetStatus];
1297
+ if (!statusKey) {
1298
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1299
+ res.end(JSON.stringify({ error: 'Invalid target status: ' + targetStatus }));
1300
+ return;
1301
+ }
1302
+
1303
+ // Look up project config
1304
+ let projectConfig = null;
1305
+ let statusOptions = config.STATUS_OPTIONS;
1306
+ const pid = projectId ? parseInt(projectId) : null;
1307
+ if (pid) {
1308
+ const project = await db.getProjectWithToken(pid);
1309
+ if (project) {
1310
+ projectConfig = {
1311
+ github_org: project.github_org,
1312
+ project_number: project.project_number,
1313
+ github_project_id: project.github_project_id,
1314
+ status_field_id: project.status_field_id,
1315
+ status_options: typeof project.status_options === 'string'
1316
+ ? JSON.parse(project.status_options)
1317
+ : project.status_options,
1318
+ };
1319
+ statusOptions = projectConfig.status_options;
1320
+ }
1321
+ }
1322
+
1323
+ const statusOptionId = statusOptions[statusKey];
1324
+ if (!statusOptionId) {
1325
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1326
+ res.end(JSON.stringify({ error: 'No option ID for status: ' + statusKey }));
1327
+ return;
1328
+ }
1329
+
1330
+ // Resolve token
1331
+ const token = await resolveProjectToken(
1332
+ pid ? (await db.query('SELECT * FROM agentdev_projects WHERE id=$1', [pid])).rows[0] || {} : {}
1333
+ );
1334
+
1335
+ await github.moveToStatus(projectItemId, statusOptionId, token, projectConfig);
1336
+
1337
+ // Clear ticket cache and re-broadcast
1338
+ github.clearTicketCache();
1339
+ broadcastTodoTickets();
1340
+
1341
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1342
+ res.end(JSON.stringify({ success: true }));
1343
+ } catch (e) {
1344
+ console.error('Error moving ticket:', e.message);
1345
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1346
+ res.end(JSON.stringify({ error: e.message }));
1347
+ }
1348
+ });
1349
+ }
1350
+ // Update ticket (title/body)
1351
+ else if (url === '/api/tickets/update' && req.method === 'POST') {
1352
+ if (!auth.isAuthenticated(req)) {
1353
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1354
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1355
+ return;
1356
+ }
1357
+ let body = '';
1358
+ req.on('data', chunk => body += chunk);
1359
+ req.on('end', async () => {
1360
+ try {
1361
+ const { repo, issue, title, body: issueBody, state, projectId } = JSON.parse(body);
1362
+ if (!repo || !issue) {
1363
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1364
+ res.end(JSON.stringify({ error: 'Missing repo or issue' }));
1365
+ return;
1366
+ }
1367
+ const updates = {};
1368
+ if (title !== undefined) updates.title = title;
1369
+ if (issueBody !== undefined) updates.body = issueBody;
1370
+ if (state !== undefined) updates.state = state;
1371
+
1372
+ const project = projectId ? (await db.query('SELECT * FROM agentdev_projects WHERE id=$1', [projectId])).rows[0] : null;
1373
+ const token = await resolveProjectToken(project || {});
1374
+ const projectConfig = project ? {
1375
+ github_org: project.github_org,
1376
+ project_number: project.project_number,
1377
+ } : null;
1378
+
1379
+ await github.updateIssue(repo, issue, updates, token, projectConfig);
1380
+ github.clearTicketCache();
1381
+ broadcastTodoTickets();
1382
+
1383
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1384
+ res.end(JSON.stringify({ success: true }));
1385
+ } catch (e) {
1386
+ console.error('Error updating ticket:', e.message);
1387
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1388
+ res.end(JSON.stringify({ error: e.message }));
1389
+ }
1390
+ });
1391
+ }
1392
+ // Add comment from ticket panel
1393
+ else if (url === '/api/tickets/comment' && req.method === 'POST') {
1394
+ if (!auth.isAuthenticated(req)) {
1395
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1396
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1397
+ return;
1398
+ }
1399
+ let body = '';
1400
+ req.on('data', chunk => body += chunk);
1401
+ req.on('end', async () => {
1402
+ try {
1403
+ const { repo, issue, comment, projectId } = JSON.parse(body);
1404
+ if (!repo || !issue || !comment) {
1405
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1406
+ res.end(JSON.stringify({ error: 'Missing repo, issue, or comment' }));
1407
+ return;
1408
+ }
1409
+ const project = projectId ? (await db.query('SELECT * FROM agentdev_projects WHERE id=$1', [projectId])).rows[0] : null;
1410
+ const token = await resolveProjectToken(project || {});
1411
+ const projectConfig = project ? { github_org: project.github_org } : null;
1412
+
1413
+ await github.addComment(repo, issue, comment, token, projectConfig);
1414
+ github.clearTicketCache();
1415
+ broadcastTodoTickets();
1416
+
1417
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1418
+ res.end(JSON.stringify({ success: true }));
1419
+ } catch (e) {
1420
+ console.error('Error adding comment:', e.message);
1421
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1422
+ res.end(JSON.stringify({ error: e.message }));
1423
+ }
1424
+ });
1425
+ }
1426
+ else {
1427
+ res.writeHead(404);
1428
+ res.end('Not found');
1429
+ }
1430
+ }).listen(config.PORT, () => {
1431
+ // Initialize GitHub default token from environment
1432
+ if (process.env.GH_TOKEN) {
1433
+ github.setDefaultToken(process.env.GH_TOKEN);
1434
+ }
1435
+ console.log('AgentDev Web UI running on http://localhost:' + config.PORT);
1436
+
1437
+ // Run ticket sync every 5 minutes (checks for new tickets + re-queues completed tickets with new @claude comments)
1438
+ const { execFile } = require('child_process');
1439
+ setInterval(() => {
1440
+ execFile('node', [path.join(__dirname, 'sync-github-tickets.js')], {
1441
+ env: { ...process.env }
1442
+ }, (err, stdout, stderr) => {
1443
+ if (err) {
1444
+ console.error('[sync] Error:', err.message);
1445
+ return;
1446
+ }
1447
+ if (stdout.includes('Re-queued')) console.log('[sync]', stdout.trim());
1448
+ });
1449
+ }, 5 * 60 * 1000);
1450
+ });