@gowelle/stint-agent 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.
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ commitQueue,
4
+ gitService,
5
+ projectService,
6
+ removePidFile,
7
+ writePidFile
8
+ } from "../chunk-PPODHVVP.js";
9
+ import {
10
+ apiService,
11
+ authService,
12
+ config,
13
+ logger
14
+ } from "../chunk-5DWSNHS6.js";
15
+
16
+ // src/daemon/runner.ts
17
+ import "dotenv/config";
18
+
19
+ // src/services/websocket.ts
20
+ import WebSocket from "ws";
21
+ var WebSocketServiceImpl = class {
22
+ ws = null;
23
+ userId = null;
24
+ reconnectAttempts = 0;
25
+ maxReconnectAttempts = 10;
26
+ reconnectTimer = null;
27
+ isManualDisconnect = false;
28
+ // Event handlers
29
+ commitApprovedHandlers = [];
30
+ projectUpdatedHandlers = [];
31
+ disconnectHandlers = [];
32
+ async connect() {
33
+ try {
34
+ const token = await authService.getToken();
35
+ if (!token) {
36
+ throw new Error("No authentication token available");
37
+ }
38
+ const wsUrl = config.getWsUrl();
39
+ const url = `${wsUrl}?token=${encodeURIComponent(token)}`;
40
+ logger.info("websocket", `Connecting to ${wsUrl}...`);
41
+ this.ws = new WebSocket(url);
42
+ return new Promise((resolve, reject) => {
43
+ if (!this.ws) {
44
+ reject(new Error("WebSocket not initialized"));
45
+ return;
46
+ }
47
+ this.ws.on("open", () => {
48
+ logger.success("websocket", "WebSocket connected");
49
+ this.reconnectAttempts = 0;
50
+ this.isManualDisconnect = false;
51
+ resolve();
52
+ });
53
+ this.ws.on("message", (data) => {
54
+ this.handleMessage(data);
55
+ });
56
+ this.ws.on("close", () => {
57
+ logger.warn("websocket", "WebSocket disconnected");
58
+ this.handleDisconnect();
59
+ });
60
+ this.ws.on("error", (error) => {
61
+ logger.error("websocket", "WebSocket error", error);
62
+ reject(error);
63
+ });
64
+ });
65
+ } catch (error) {
66
+ logger.error("websocket", "Failed to connect", error);
67
+ throw error;
68
+ }
69
+ }
70
+ disconnect() {
71
+ this.isManualDisconnect = true;
72
+ if (this.reconnectTimer) {
73
+ clearTimeout(this.reconnectTimer);
74
+ this.reconnectTimer = null;
75
+ }
76
+ if (this.ws) {
77
+ if (this.userId) {
78
+ this.sendMessage({
79
+ event: "pusher:unsubscribe",
80
+ data: {
81
+ channel: `private-user.${this.userId}`
82
+ }
83
+ });
84
+ }
85
+ this.ws.close();
86
+ this.ws = null;
87
+ logger.info("websocket", "WebSocket disconnected");
88
+ }
89
+ }
90
+ isConnected() {
91
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
92
+ }
93
+ subscribeToUserChannel(userId) {
94
+ this.userId = userId;
95
+ if (!this.isConnected()) {
96
+ logger.warn("websocket", "Cannot subscribe: not connected");
97
+ return;
98
+ }
99
+ const channel = `private-user.${userId}`;
100
+ logger.info("websocket", `Subscribing to channel: ${channel}`);
101
+ this.sendMessage({
102
+ event: "pusher:subscribe",
103
+ data: {
104
+ channel
105
+ }
106
+ });
107
+ }
108
+ onCommitApproved(handler) {
109
+ this.commitApprovedHandlers.push(handler);
110
+ }
111
+ onProjectUpdated(handler) {
112
+ this.projectUpdatedHandlers.push(handler);
113
+ }
114
+ onDisconnect(handler) {
115
+ this.disconnectHandlers.push(handler);
116
+ }
117
+ sendMessage(message) {
118
+ if (!this.isConnected()) {
119
+ logger.warn("websocket", "Cannot send message: not connected");
120
+ return;
121
+ }
122
+ this.ws.send(JSON.stringify(message));
123
+ }
124
+ handleMessage(data) {
125
+ try {
126
+ const message = JSON.parse(data.toString());
127
+ logger.debug("websocket", `Received message: ${message.event}`);
128
+ if (message.event === "pusher:connection_established") {
129
+ logger.success("websocket", "Connection established");
130
+ return;
131
+ }
132
+ if (message.event === "pusher_internal:subscription_succeeded") {
133
+ logger.success("websocket", `Subscribed to channel: ${message.channel}`);
134
+ return;
135
+ }
136
+ if (message.event === "commit.approved") {
137
+ const { commit, project } = message.data;
138
+ logger.info("websocket", `Commit approved: ${commit.id}`);
139
+ this.commitApprovedHandlers.forEach((handler) => handler(commit, project));
140
+ return;
141
+ }
142
+ if (message.event === "project.updated") {
143
+ const { project } = message.data;
144
+ logger.info("websocket", `Project updated: ${project.id}`);
145
+ this.projectUpdatedHandlers.forEach((handler) => handler(project));
146
+ return;
147
+ }
148
+ if (message.event === "sync.requested") {
149
+ const { projectId } = message.data;
150
+ logger.info("websocket", `Sync requested for project: ${projectId}`);
151
+ return;
152
+ }
153
+ logger.debug("websocket", `Unhandled event: ${message.event}`);
154
+ } catch (error) {
155
+ logger.error("websocket", "Failed to parse message", error);
156
+ }
157
+ }
158
+ handleDisconnect() {
159
+ this.ws = null;
160
+ this.disconnectHandlers.forEach((handler) => handler());
161
+ if (this.isManualDisconnect) {
162
+ return;
163
+ }
164
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
165
+ const delay = this.getReconnectDelay();
166
+ this.reconnectAttempts++;
167
+ logger.info("websocket", `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
168
+ this.reconnectTimer = setTimeout(async () => {
169
+ try {
170
+ await this.connect();
171
+ if (this.userId) {
172
+ this.subscribeToUserChannel(this.userId);
173
+ }
174
+ } catch (error) {
175
+ logger.error("websocket", "Reconnection failed", error);
176
+ }
177
+ }, delay);
178
+ } else {
179
+ logger.error("websocket", "Max reconnection attempts reached");
180
+ }
181
+ }
182
+ getReconnectDelay() {
183
+ const delays = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
184
+ const index = Math.min(this.reconnectAttempts, delays.length - 1);
185
+ return delays[index];
186
+ }
187
+ };
188
+ var websocketService = new WebSocketServiceImpl();
189
+
190
+ // src/daemon/watcher.ts
191
+ import fs from "fs";
192
+ var FileWatcher = class {
193
+ watchers = /* @__PURE__ */ new Map();
194
+ DEBOUNCE_DELAY = 3e3;
195
+ // 3 seconds
196
+ IGNORE_PATTERNS = [
197
+ ".git",
198
+ "node_modules",
199
+ "dist",
200
+ "build",
201
+ ".next",
202
+ ".cache",
203
+ ".turbo",
204
+ ".vscode",
205
+ ".idea",
206
+ "coverage",
207
+ ".nyc_output",
208
+ "*.log"
209
+ ];
210
+ /**
211
+ * Start watching all linked projects
212
+ */
213
+ start() {
214
+ logger.info("watcher", "Starting file watcher...");
215
+ const linkedProjects = projectService.getAllLinkedProjects();
216
+ if (Object.keys(linkedProjects).length === 0) {
217
+ logger.info("watcher", "No linked projects to watch");
218
+ return;
219
+ }
220
+ for (const [projectPath, linkedProject] of Object.entries(linkedProjects)) {
221
+ this.watchProject(projectPath, linkedProject.projectId);
222
+ }
223
+ logger.success("watcher", `Watching ${this.watchers.size} project(s)`);
224
+ }
225
+ /**
226
+ * Start watching a specific project path
227
+ */
228
+ watchProject(projectPath, projectId) {
229
+ if (this.watchers.has(projectPath)) {
230
+ logger.debug("watcher", `Already watching ${projectPath}`);
231
+ return;
232
+ }
233
+ try {
234
+ if (!fs.existsSync(projectPath)) {
235
+ logger.warn("watcher", `Project path does not exist: ${projectPath}`);
236
+ return;
237
+ }
238
+ const watcher = fs.watch(projectPath, { recursive: true }, (eventType, filename) => {
239
+ if (!filename) return;
240
+ const filenameStr = Buffer.isBuffer(filename) ? filename.toString("utf8") : filename;
241
+ if (this.shouldIgnore(filenameStr)) {
242
+ return;
243
+ }
244
+ logger.debug("watcher", `File change detected: ${filenameStr} (${eventType}) in ${projectPath}`);
245
+ this.debounceSync(projectPath, projectId);
246
+ });
247
+ watcher.on("error", (error) => {
248
+ logger.error("watcher", `Watcher error for ${projectPath}`, error);
249
+ const watcherInfo = this.watchers.get(projectPath);
250
+ if (watcherInfo) {
251
+ if (watcherInfo.debounceTimer) {
252
+ clearTimeout(watcherInfo.debounceTimer);
253
+ }
254
+ this.watchers.delete(projectPath);
255
+ }
256
+ });
257
+ this.watchers.set(projectPath, {
258
+ watcher,
259
+ debounceTimer: null,
260
+ projectId
261
+ });
262
+ logger.info("watcher", `Started watching: ${projectPath} (project: ${projectId})`);
263
+ } catch (error) {
264
+ logger.error("watcher", `Failed to watch ${projectPath}`, error);
265
+ }
266
+ }
267
+ /**
268
+ * Check if a file path should be ignored
269
+ */
270
+ shouldIgnore(filePath) {
271
+ const normalizedPath = filePath.replace(/\\/g, "/");
272
+ const filename = normalizedPath.split("/").pop() || normalizedPath;
273
+ for (const pattern of this.IGNORE_PATTERNS) {
274
+ if (pattern.includes("*")) {
275
+ let escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
276
+ escapedPattern = escapedPattern.replace(/\*/g, ".*");
277
+ let regexPattern;
278
+ if (pattern.startsWith("*") && pattern.endsWith("*")) {
279
+ regexPattern = `^${escapedPattern}$`;
280
+ } else if (pattern.startsWith("*")) {
281
+ regexPattern = `^${escapedPattern}$`;
282
+ } else if (pattern.endsWith("*")) {
283
+ regexPattern = `^${escapedPattern}$`;
284
+ } else {
285
+ regexPattern = `^${escapedPattern}$`;
286
+ }
287
+ const regex = new RegExp(regexPattern);
288
+ if (regex.test(filename)) {
289
+ return true;
290
+ }
291
+ } else {
292
+ if (normalizedPath.includes(pattern)) {
293
+ return true;
294
+ }
295
+ }
296
+ }
297
+ return false;
298
+ }
299
+ /**
300
+ * Debounce sync operation for a project
301
+ */
302
+ debounceSync(projectPath, projectId) {
303
+ const watcherInfo = this.watchers.get(projectPath);
304
+ if (!watcherInfo) return;
305
+ if (watcherInfo.debounceTimer) {
306
+ clearTimeout(watcherInfo.debounceTimer);
307
+ }
308
+ watcherInfo.debounceTimer = setTimeout(async () => {
309
+ try {
310
+ await this.performSync(projectPath, projectId);
311
+ } catch (error) {
312
+ logger.error("watcher", `Sync failed for ${projectPath}`, error);
313
+ } finally {
314
+ watcherInfo.debounceTimer = null;
315
+ }
316
+ }, this.DEBOUNCE_DELAY);
317
+ }
318
+ /**
319
+ * Perform sync operation for a project
320
+ */
321
+ async performSync(projectPath, projectId) {
322
+ logger.info("watcher", `Syncing project ${projectId} (${projectPath})`);
323
+ try {
324
+ const isRepo = await gitService.isRepo(projectPath);
325
+ if (!isRepo) {
326
+ logger.warn("watcher", `Project ${projectPath} is no longer a git repository`);
327
+ return;
328
+ }
329
+ const repoInfo = await gitService.getRepoInfo(projectPath);
330
+ await apiService.syncProject(projectId, repoInfo);
331
+ logger.success("watcher", `Synced project ${projectId}`);
332
+ } catch (error) {
333
+ logger.error("watcher", `Failed to sync project ${projectId}`, error);
334
+ }
335
+ }
336
+ /**
337
+ * Stop watching all projects
338
+ */
339
+ stop() {
340
+ logger.info("watcher", "Stopping file watcher...");
341
+ for (const [projectPath, watcherInfo] of this.watchers.entries()) {
342
+ try {
343
+ if (watcherInfo.debounceTimer) {
344
+ clearTimeout(watcherInfo.debounceTimer);
345
+ }
346
+ watcherInfo.watcher.close();
347
+ logger.debug("watcher", `Stopped watching: ${projectPath}`);
348
+ } catch (error) {
349
+ logger.error("watcher", `Error stopping watcher for ${projectPath}`, error);
350
+ }
351
+ }
352
+ this.watchers.clear();
353
+ logger.success("watcher", "File watcher stopped");
354
+ }
355
+ /**
356
+ * Add a new project to watch (called when a project is linked)
357
+ */
358
+ addProject(projectPath, projectId) {
359
+ this.watchProject(projectPath, projectId);
360
+ }
361
+ /**
362
+ * Remove a project from watching (called when a project is unlinked)
363
+ */
364
+ removeProject(projectPath) {
365
+ const watcherInfo = this.watchers.get(projectPath);
366
+ if (!watcherInfo) return;
367
+ try {
368
+ if (watcherInfo.debounceTimer) {
369
+ clearTimeout(watcherInfo.debounceTimer);
370
+ }
371
+ watcherInfo.watcher.close();
372
+ this.watchers.delete(projectPath);
373
+ logger.info("watcher", `Stopped watching: ${projectPath}`);
374
+ } catch (error) {
375
+ logger.error("watcher", `Error removing watcher for ${projectPath}`, error);
376
+ }
377
+ }
378
+ };
379
+
380
+ // src/daemon/index.ts
381
+ var heartbeatInterval = null;
382
+ var isShuttingDown = false;
383
+ var fileWatcher = new FileWatcher();
384
+ async function startDaemon() {
385
+ logger.info("daemon", "Starting daemon...");
386
+ try {
387
+ const user = await authService.validateToken();
388
+ if (!user) {
389
+ throw new Error('Not authenticated. Please run "stint login" first.');
390
+ }
391
+ logger.info("daemon", `Authenticated as ${user.email}`);
392
+ logger.info("daemon", "Connecting to API...");
393
+ const session = await apiService.connect();
394
+ logger.success("daemon", `Agent session connected: ${session.id}`);
395
+ logger.info("daemon", "Connecting to WebSocket...");
396
+ await websocketService.connect();
397
+ logger.success("daemon", "WebSocket connected");
398
+ websocketService.subscribeToUserChannel(user.id);
399
+ websocketService.onCommitApproved((commit, project) => {
400
+ logger.info("daemon", `Commit approved: ${commit.id} for project ${project.name}`);
401
+ commitQueue.addToQueue(commit, project);
402
+ });
403
+ websocketService.onProjectUpdated((project) => {
404
+ logger.info("daemon", `Project updated: ${project.id} - ${project.name}`);
405
+ });
406
+ websocketService.onDisconnect(() => {
407
+ logger.warn("daemon", "WebSocket disconnected, will attempt to reconnect");
408
+ });
409
+ setupSignalHandlers();
410
+ startHeartbeat();
411
+ fileWatcher.start();
412
+ logger.success("daemon", "Daemon started successfully");
413
+ await new Promise(() => {
414
+ });
415
+ } catch (error) {
416
+ logger.error("daemon", "Failed to start daemon", error);
417
+ await shutdown();
418
+ throw error;
419
+ }
420
+ }
421
+ function startHeartbeat() {
422
+ const HEARTBEAT_INTERVAL = 3e4;
423
+ logger.info("daemon", "Starting heartbeat loop (30s interval)");
424
+ heartbeatInterval = setInterval(async () => {
425
+ if (isShuttingDown) return;
426
+ try {
427
+ await apiService.heartbeat();
428
+ logger.debug("daemon", "Heartbeat sent successfully");
429
+ } catch (error) {
430
+ logger.error("daemon", "Heartbeat failed", error);
431
+ }
432
+ }, HEARTBEAT_INTERVAL);
433
+ }
434
+ function stopHeartbeat() {
435
+ if (heartbeatInterval) {
436
+ clearInterval(heartbeatInterval);
437
+ heartbeatInterval = null;
438
+ logger.info("daemon", "Heartbeat loop stopped");
439
+ }
440
+ }
441
+ function setupSignalHandlers() {
442
+ const signals = ["SIGTERM", "SIGINT"];
443
+ signals.forEach((signal) => {
444
+ process.on(signal, async () => {
445
+ logger.info("daemon", `Received ${signal}, shutting down...`);
446
+ await shutdown();
447
+ process.exit(0);
448
+ });
449
+ });
450
+ logger.info("daemon", "Signal handlers registered");
451
+ }
452
+ async function shutdown() {
453
+ if (isShuttingDown) return;
454
+ isShuttingDown = true;
455
+ logger.info("daemon", "Shutting down daemon...");
456
+ stopHeartbeat();
457
+ try {
458
+ fileWatcher.stop();
459
+ logger.info("daemon", "File watcher stopped");
460
+ } catch (error) {
461
+ logger.error("daemon", "Failed to stop file watcher", error);
462
+ }
463
+ try {
464
+ websocketService.disconnect();
465
+ logger.info("daemon", "Disconnected from WebSocket");
466
+ } catch (error) {
467
+ logger.error("daemon", "Failed to disconnect from WebSocket", error);
468
+ }
469
+ try {
470
+ await apiService.disconnect();
471
+ logger.info("daemon", "Disconnected from API");
472
+ } catch (error) {
473
+ logger.error("daemon", "Failed to disconnect from API", error);
474
+ }
475
+ removePidFile();
476
+ logger.success("daemon", "Daemon shutdown complete");
477
+ }
478
+
479
+ // src/daemon/runner.ts
480
+ writePidFile(process.pid);
481
+ startDaemon().catch((error) => {
482
+ logger.error("daemon-runner", "Daemon crashed", error);
483
+ process.exit(1);
484
+ });