@gowelle/stint-agent 1.2.18 → 1.2.19

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.
@@ -1,534 +0,0 @@
1
- import {
2
- apiService
3
- } from "./chunk-XPZNWXB4.js";
4
- import {
5
- gitService,
6
- projectService
7
- } from "./chunk-A2CVSQ3K.js";
8
- import {
9
- authService,
10
- config,
11
- logger
12
- } from "./chunk-WETVBZ6Z.js";
13
-
14
- // src/utils/notify.ts
15
- import notifier from "node-notifier";
16
- import path from "path";
17
- import { fileURLToPath } from "url";
18
- var __filename = fileURLToPath(import.meta.url);
19
- var __dirname = path.dirname(__filename);
20
- var DEFAULT_ICON = path.resolve(__dirname, "../assets/logo.png");
21
- function notify(options) {
22
- if (!config.areNotificationsEnabled()) {
23
- logger.debug("notify", "Notifications disabled, skipping notification");
24
- return;
25
- }
26
- try {
27
- notifier.notify({
28
- title: options.title,
29
- message: options.message,
30
- open: options.open,
31
- icon: options.icon || DEFAULT_ICON,
32
- sound: true,
33
- wait: false,
34
- appID: "Stint Agent"
35
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
- }, (error) => {
37
- if (error) {
38
- logger.error("notify", "Failed to send notification", error);
39
- }
40
- });
41
- } catch (error) {
42
- logger.error("notify", "Failed to send notification", error);
43
- }
44
- }
45
-
46
- // src/daemon/queue.ts
47
- var CommitQueueProcessor = class {
48
- queue = [];
49
- isProcessing = false;
50
- /**
51
- * Add commit to processing queue
52
- */
53
- addToQueue(commit, project) {
54
- this.queue.push({ commit, project });
55
- logger.info("queue", `Added commit ${commit.id} to queue (position: ${this.queue.length})`);
56
- if (!this.isProcessing) {
57
- this.processQueue();
58
- }
59
- }
60
- /**
61
- * Process commits sequentially
62
- */
63
- async processQueue() {
64
- if (this.isProcessing) {
65
- return;
66
- }
67
- this.isProcessing = true;
68
- while (this.queue.length > 0) {
69
- const item = this.queue.shift();
70
- if (!item) break;
71
- try {
72
- await this.executeCommit(item.commit, item.project);
73
- } catch (error) {
74
- logger.error("queue", `Failed to execute commit ${item.commit.id}`, error);
75
- }
76
- }
77
- this.isProcessing = false;
78
- }
79
- /**
80
- * Execute a single commit
81
- */
82
- async executeCommit(commit, project, onProgress, options) {
83
- logger.info("queue", `Processing commit: ${commit.id} - ${commit.message}`);
84
- try {
85
- onProgress?.("Finding project directory...");
86
- const projectPath = this.findProjectPath(project.id);
87
- if (!projectPath) {
88
- throw new Error(`Project ${project.id} is not linked to any local directory`);
89
- }
90
- logger.info("queue", `Executing in directory: ${projectPath}`);
91
- onProgress?.("Validating repository...");
92
- const isRepo = await gitService.isRepo(projectPath);
93
- if (!isRepo) {
94
- throw new Error(`Directory ${projectPath} is not a git repository`);
95
- }
96
- onProgress?.("Checking repository status...");
97
- let status = await gitService.getStatus(projectPath);
98
- if (commit.files && commit.files.length > 0) {
99
- onProgress?.(`Staging ${commit.files.length} specified files...`);
100
- await gitService.stageFiles(projectPath, commit.files);
101
- status = await gitService.getStatus(projectPath);
102
- logger.info("queue", `Auto-staged files: ${commit.files.join(", ")}`);
103
- } else if (status.staged.length === 0) {
104
- const hasChanges = status.unstaged.length > 0 || status.untracked.length > 0;
105
- if (hasChanges) {
106
- onProgress?.("Staging all changes...");
107
- await gitService.stageAll(projectPath);
108
- status = await gitService.getStatus(projectPath);
109
- logger.info("queue", `Auto-staged all changes`);
110
- }
111
- }
112
- if (status.staged.length === 0) {
113
- throw new Error("No changes to commit. The working directory is clean.");
114
- }
115
- logger.info("queue", `Committing ${status.staged.length} staged files.`);
116
- onProgress?.("Creating commit...");
117
- logger.info("queue", `Creating commit with message: "${commit.message}"`);
118
- const sha = await gitService.commit(projectPath, commit.message);
119
- logger.success("queue", `Commit created successfully: ${sha}`);
120
- let pushed = false;
121
- let pushError;
122
- const shouldPush = options?.push !== false;
123
- if (shouldPush) {
124
- try {
125
- onProgress?.("Pushing to remote...");
126
- await gitService.push(projectPath);
127
- pushed = true;
128
- logger.success("queue", `Pushed commit ${sha} to remote`);
129
- notify({
130
- title: `Commit Pushed - ${project.name}`,
131
- message: `Commit "${commit.message}" successfully pushed.`
132
- });
133
- } catch (error) {
134
- pushError = error.message;
135
- const isConflict = pushError.includes("rejected") || pushError.includes("non-fast-forward") || pushError.includes("failed to push") || pushError.includes("Updates were rejected");
136
- if (isConflict) {
137
- logger.warn("queue", `Push failed due to remote conflict: ${pushError}`);
138
- notify({
139
- title: `Push Conflict - ${project.name}`,
140
- message: `Commit "${commit.message}" created but push failed.
141
- Run "git pull --rebase" to resolve.`
142
- });
143
- } else {
144
- logger.error("queue", `Push failed: ${pushError}`);
145
- notify({
146
- title: `Push Failed - ${project.name}`,
147
- message: `Commit created but push failed: ${pushError}`
148
- });
149
- }
150
- }
151
- }
152
- onProgress?.("Reporting to server...");
153
- await this.reportSuccess(commit.id, sha, pushed, pushError);
154
- if (!pushed && !pushError) {
155
- notify({
156
- title: `Commit Created - ${project.name}`,
157
- message: `Commit "${commit.message}" created locally.`
158
- });
159
- }
160
- return sha;
161
- } catch (error) {
162
- const msg = error.message;
163
- logger.error("queue", `Commit execution failed: ${msg}`);
164
- notify({
165
- title: `Commit Failed - ${project.name}`,
166
- message: `Failed to execute commit "${commit.message}": ${msg}`
167
- });
168
- await this.reportFailure(commit.id, msg);
169
- throw error;
170
- }
171
- }
172
- /**
173
- * Report successful execution to API
174
- * @param commitId - Commit ID
175
- * @param sha - Git commit SHA
176
- * @param pushed - Whether the commit was pushed to remote
177
- * @param pushError - Error message if push failed
178
- */
179
- async reportSuccess(commitId, sha, pushed = true, pushError) {
180
- try {
181
- await apiService.markCommitExecuted(commitId, sha, pushed, pushError);
182
- const status = pushed ? "executed" : "committed (push failed)";
183
- logger.success("queue", `Reported commit ${status} to API: ${commitId} -> ${sha}`);
184
- } catch (error) {
185
- logger.error("queue", "Failed to report commit success to API", error);
186
- }
187
- }
188
- /**
189
- * Report failed execution to API
190
- */
191
- async reportFailure(commitId, error) {
192
- try {
193
- await apiService.markCommitFailed(commitId, error);
194
- logger.info("queue", `Reported commit failure to API: ${commitId}`);
195
- } catch (apiError) {
196
- logger.error("queue", "Failed to report commit failure to API", apiError);
197
- }
198
- }
199
- /**
200
- * Find local path for a project ID
201
- */
202
- findProjectPath(projectId) {
203
- const allProjects = projectService.getAllLinkedProjects();
204
- for (const [path3, linkedProject] of Object.entries(allProjects)) {
205
- if (linkedProject.projectId === projectId) {
206
- return path3;
207
- }
208
- }
209
- return null;
210
- }
211
- /**
212
- * Check if queue is currently processing
213
- */
214
- isCurrentlyProcessing() {
215
- return this.isProcessing;
216
- }
217
- /**
218
- * Get queue length
219
- */
220
- getQueueLength() {
221
- return this.queue.length;
222
- }
223
- };
224
- var commitQueue = new CommitQueueProcessor();
225
-
226
- // src/services/websocket.ts
227
- import Echo from "laravel-echo";
228
- import Pusher from "pusher-js";
229
- import fs from "fs";
230
- import os from "os";
231
- import path2 from "path";
232
- var STATUS_FILE_PATH = path2.join(os.homedir(), ".config", "stint", "daemon.status.json");
233
- function writeStatus(update) {
234
- try {
235
- const dir = path2.dirname(STATUS_FILE_PATH);
236
- if (!fs.existsSync(dir)) {
237
- fs.mkdirSync(dir, { recursive: true });
238
- }
239
- let status = { websocket: { connected: false } };
240
- if (fs.existsSync(STATUS_FILE_PATH)) {
241
- try {
242
- status = JSON.parse(fs.readFileSync(STATUS_FILE_PATH, "utf8"));
243
- } catch {
244
- }
245
- }
246
- status.websocket = { ...status.websocket, ...update };
247
- fs.writeFileSync(STATUS_FILE_PATH, JSON.stringify(status, null, 2));
248
- } catch {
249
- }
250
- }
251
- var WebSocketServiceImpl = class {
252
- echo = null;
253
- userId = null;
254
- reconnectAttempts = 0;
255
- maxReconnectAttempts = 10;
256
- reconnectTimer = null;
257
- isManualDisconnect = false;
258
- // Event handlers
259
- commitApprovedHandlers = [];
260
- commitPendingHandlers = [];
261
- suggestionCreatedHandlers = [];
262
- projectUpdatedHandlers = [];
263
- disconnectHandlers = [];
264
- agentDisconnectedHandlers = [];
265
- syncRequestedHandlers = [];
266
- /**
267
- * Connect to the WebSocket server using Laravel Echo
268
- * @throws Error if connection fails or no auth token available
269
- */
270
- async connect() {
271
- try {
272
- const token = await authService.getToken();
273
- if (!token) {
274
- throw new Error("No authentication token available");
275
- }
276
- const reverbAppKey = config.getReverbAppKey();
277
- if (!reverbAppKey) {
278
- throw new Error("Reverb app key not configured");
279
- }
280
- const apiUrl = config.getApiUrl();
281
- const environment = config.getEnvironment();
282
- let wsHost;
283
- let wsPort;
284
- let forceTLS;
285
- if (environment === "development") {
286
- wsHost = "localhost";
287
- wsPort = 8080;
288
- forceTLS = false;
289
- } else {
290
- wsHost = "stint.codes";
291
- wsPort = 443;
292
- forceTLS = true;
293
- }
294
- logger.info("websocket", `Connecting to ${wsHost}:${wsPort} with key ${reverbAppKey}...`);
295
- const pusherClient = new Pusher(reverbAppKey, {
296
- wsHost,
297
- wsPort,
298
- forceTLS,
299
- enabledTransports: ["ws", "wss"],
300
- disableStats: true,
301
- cluster: "",
302
- // Required but unused for Reverb
303
- authorizer: (channel) => ({
304
- authorize: async (socketId, callback) => {
305
- try {
306
- const response = await fetch(`${apiUrl}/api/broadcasting/auth`, {
307
- method: "POST",
308
- headers: {
309
- "Authorization": `Bearer ${token}`,
310
- "Accept": "application/json",
311
- "Content-Type": "application/json"
312
- },
313
- body: JSON.stringify({
314
- socket_id: socketId,
315
- channel_name: channel.name
316
- })
317
- });
318
- if (!response.ok) {
319
- const errorText = await response.text();
320
- logger.error("websocket", `Auth failed (${response.status}): ${errorText}`);
321
- callback(new Error(`Auth failed: ${response.status}`));
322
- return;
323
- }
324
- const data = await response.json();
325
- callback(null, data);
326
- } catch (error) {
327
- logger.error("websocket", "Channel auth error", error);
328
- callback(error);
329
- }
330
- }
331
- })
332
- });
333
- this.echo = new Echo({
334
- broadcaster: "reverb",
335
- key: reverbAppKey,
336
- wsHost,
337
- wsPort,
338
- forceTLS,
339
- disableStats: true,
340
- enabledTransports: ["ws", "wss"],
341
- authEndpoint: `${apiUrl}/api/broadcasting/auth`,
342
- auth: {
343
- headers: {
344
- Authorization: `Bearer ${token}`,
345
- Accept: "application/json"
346
- }
347
- },
348
- client: pusherClient
349
- });
350
- logger.info("websocket", "Echo instance created, setting up connection handlers...");
351
- return new Promise((resolve, reject) => {
352
- if (!this.echo) {
353
- reject(new Error("Echo not initialized"));
354
- return;
355
- }
356
- const connectionTimeout = setTimeout(() => {
357
- const state = this.echo?.connector.pusher.connection.state || "unknown";
358
- logger.error("websocket", `Connection timeout after 15s (state: ${state})`);
359
- reject(new Error(`Connection timeout - stuck in state: ${state}`));
360
- }, 15e3);
361
- this.echo.connector.pusher.connection.bind("state_change", (states) => {
362
- logger.info("websocket", `Connection state: ${states.previous} -> ${states.current}`);
363
- });
364
- this.echo.connector.pusher.connection.bind("connected", () => {
365
- clearTimeout(connectionTimeout);
366
- logger.success("websocket", "\u2705 Connected to Broadcaster via Sanctum");
367
- writeStatus({ connected: true });
368
- this.reconnectAttempts = 0;
369
- this.isManualDisconnect = false;
370
- resolve();
371
- });
372
- this.echo.connector.pusher.connection.bind("error", (error) => {
373
- clearTimeout(connectionTimeout);
374
- const errorMessage = error instanceof Error ? error.message : JSON.stringify(error) || "Unknown connection error";
375
- logger.error("websocket", `WebSocket error: ${errorMessage}`);
376
- reject(new Error(errorMessage));
377
- });
378
- this.echo.connector.pusher.connection.bind("disconnected", () => {
379
- logger.warn("websocket", "WebSocket disconnected");
380
- writeStatus({ connected: false });
381
- this.handleDisconnect();
382
- });
383
- this.echo.connector.pusher.connection.bind("failed", () => {
384
- clearTimeout(connectionTimeout);
385
- logger.error("websocket", "WebSocket connection failed");
386
- reject(new Error("WebSocket connection failed"));
387
- });
388
- });
389
- } catch (error) {
390
- logger.error("websocket", "Failed to connect", error);
391
- throw error;
392
- }
393
- }
394
- /**
395
- * Disconnect from the WebSocket server
396
- * Prevents automatic reconnection
397
- */
398
- disconnect() {
399
- this.isManualDisconnect = true;
400
- if (this.reconnectTimer) {
401
- clearTimeout(this.reconnectTimer);
402
- this.reconnectTimer = null;
403
- }
404
- if (this.echo) {
405
- if (this.userId) {
406
- this.echo.leave(`user.${this.userId}`);
407
- }
408
- this.echo.disconnect();
409
- this.echo = null;
410
- logger.info("websocket", "WebSocket disconnected");
411
- }
412
- }
413
- /**
414
- * Check if WebSocket is currently connected
415
- * @returns True if connected and ready
416
- */
417
- isConnected() {
418
- return this.echo !== null && this.echo.connector.pusher.connection.state === "connected";
419
- }
420
- /**
421
- * Subscribe to user-specific private channel for real-time updates
422
- * @param userId - User ID to subscribe to
423
- */
424
- async subscribeToUserChannel(userId) {
425
- this.userId = userId;
426
- if (!this.echo) {
427
- logger.warn("websocket", "Cannot subscribe: not connected");
428
- return;
429
- }
430
- if (!this.isConnected()) {
431
- logger.warn("websocket", "Cannot subscribe: not connected");
432
- return;
433
- }
434
- const channel = `user.${userId}`;
435
- logger.info("websocket", `Subscribing to private channel: ${channel}`);
436
- const privateChannel = this.echo.private(channel);
437
- writeStatus({ channel });
438
- privateChannel.listen(".commit.approved", (data) => {
439
- logger.info("websocket", `Commit approved: ${data.pendingCommit.id}`);
440
- writeStatus({ lastEvent: "commit.approved", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
441
- this.commitApprovedHandlers.forEach(
442
- (handler) => handler(data.pendingCommit, data.pendingCommit.project)
443
- );
444
- }).listen(".commit.pending", (data) => {
445
- logger.info("websocket", `Commit pending: ${data.pendingCommit.id}`);
446
- writeStatus({ lastEvent: "commit.pending", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
447
- this.commitPendingHandlers.forEach((handler) => handler(data.pendingCommit));
448
- }).listen(".suggestion.created", (data) => {
449
- logger.info("websocket", `Suggestion created: ${data.suggestion.id}`);
450
- writeStatus({ lastEvent: "suggestion.created", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
451
- this.suggestionCreatedHandlers.forEach((handler) => handler(data.suggestion));
452
- }).listen(".project.updated", (data) => {
453
- logger.info("websocket", `Project updated: ${data.project.id}`);
454
- writeStatus({ lastEvent: "project.updated", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
455
- this.projectUpdatedHandlers.forEach((handler) => handler(data.project));
456
- }).listen(".sync.requested", (data) => {
457
- logger.info("websocket", `Sync requested for project: ${data.project.id}`);
458
- writeStatus({ lastEvent: "sync.requested", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
459
- this.syncRequestedHandlers.forEach((handler) => handler(data.project.id));
460
- }).listen(".agent.disconnected", (data) => {
461
- const reason = data.reason ?? "Server requested disconnect";
462
- logger.warn("websocket", `Agent disconnected by server: ${reason}`);
463
- writeStatus({ lastEvent: "agent.disconnected", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
464
- this.agentDisconnectedHandlers.forEach((handler) => handler(reason));
465
- });
466
- logger.success("websocket", `Subscribed to private channel: ${channel}`);
467
- }
468
- /**
469
- * Register handler for commit approved events
470
- * @param handler - Callback function
471
- */
472
- onCommitApproved(handler) {
473
- this.commitApprovedHandlers.push(handler);
474
- }
475
- onCommitPending(handler) {
476
- this.commitPendingHandlers.push(handler);
477
- }
478
- onSuggestionCreated(handler) {
479
- this.suggestionCreatedHandlers.push(handler);
480
- }
481
- onProjectUpdated(handler) {
482
- this.projectUpdatedHandlers.push(handler);
483
- }
484
- onDisconnect(handler) {
485
- this.disconnectHandlers.push(handler);
486
- }
487
- onAgentDisconnected(handler) {
488
- this.agentDisconnectedHandlers.push(handler);
489
- }
490
- onSyncRequested(handler) {
491
- this.syncRequestedHandlers.push(handler);
492
- }
493
- handleDisconnect() {
494
- this.disconnectHandlers.forEach((handler) => handler());
495
- if (this.isManualDisconnect) {
496
- return;
497
- }
498
- if (this.reconnectAttempts < this.maxReconnectAttempts) {
499
- const delay = this.getReconnectDelay();
500
- this.reconnectAttempts++;
501
- logger.info("websocket", `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
502
- this.reconnectTimer = setTimeout(async () => {
503
- try {
504
- await this.connect();
505
- if (this.userId) {
506
- await this.subscribeToUserChannel(this.userId);
507
- }
508
- } catch (error) {
509
- logger.error("websocket", "Reconnection failed", error);
510
- }
511
- }, delay);
512
- } else {
513
- logger.error("websocket", "Max reconnection attempts reached");
514
- }
515
- }
516
- /**
517
- * Get reconnect delay with exponential backoff and jitter
518
- * Jitter prevents thundering herd problem when many clients reconnect simultaneously
519
- */
520
- getReconnectDelay() {
521
- const delays = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
522
- const index = Math.min(this.reconnectAttempts, delays.length - 1);
523
- const baseDelay = delays[index];
524
- const jitter = baseDelay * (Math.random() * 0.3);
525
- return Math.floor(baseDelay + jitter);
526
- }
527
- };
528
- var websocketService = new WebSocketServiceImpl();
529
-
530
- export {
531
- notify,
532
- commitQueue,
533
- websocketService
534
- };