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.
- package/lib/agent-api.js +530 -0
- package/lib/auth.js +127 -0
- package/lib/config.js +53 -0
- package/lib/database.js +762 -0
- package/lib/device-flow.js +257 -0
- package/lib/email.js +420 -0
- package/lib/encryption.js +112 -0
- package/lib/github.js +339 -0
- package/lib/history.js +143 -0
- package/lib/pwa.js +107 -0
- package/lib/redis-logs.js +226 -0
- package/lib/routes.js +680 -0
- package/migrations/000_create_database.sql +33 -0
- package/migrations/001_create_agentdev_schema.sql +135 -0
- package/migrations/001_create_agentdev_schema.sql.old +100 -0
- package/migrations/001_create_agentdev_schema_fixed.sql +135 -0
- package/migrations/002_add_github_token.sql +17 -0
- package/migrations/003_add_agent_logs_table.sql +23 -0
- package/migrations/004_remove_oauth_columns.sql +11 -0
- package/migrations/005_add_projects.sql +44 -0
- package/migrations/006_project_github_token.sql +7 -0
- package/migrations/007_project_repositories.sql +12 -0
- package/migrations/008_add_notifications.sql +20 -0
- package/migrations/009_unified_oauth.sql +153 -0
- package/migrations/README.md +97 -0
- package/package.json +37 -0
- package/public/css/styles.css +1140 -0
- package/public/device.html +384 -0
- package/public/docs.html +862 -0
- package/public/docs.md +697 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +271 -0
- package/public/js/app.js +2379 -0
- package/public/login.html +224 -0
- package/public/profile.html +394 -0
- package/public/register.html +392 -0
- package/public/reset-password.html +349 -0
- package/public/verify-email.html +177 -0
- 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
|
+
});
|