@gowelle/stint-agent 1.2.37 → 1.2.39

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,15 +1,15 @@
1
1
  import {
2
2
  apiService
3
- } from "./chunk-IBGWKTT7.js";
3
+ } from "./chunk-4WAUHGFS.js";
4
4
  import {
5
5
  gitService,
6
6
  projectService
7
- } from "./chunk-XRNTJYCQ.js";
7
+ } from "./chunk-LU6CQVSL.js";
8
8
  import {
9
9
  authService,
10
10
  config,
11
11
  logger
12
- } from "./chunk-HPHXBSGB.js";
12
+ } from "./chunk-PWOHR6IZ.js";
13
13
 
14
14
  // src/utils/notify.ts
15
15
  import notifier from "node-notifier";
@@ -263,10 +263,25 @@ var hookService = new HookService();
263
263
  var CommitQueueProcessor = class {
264
264
  queue = [];
265
265
  isProcessing = false;
266
+ currentCommitId = null;
267
+ /**
268
+ * Check if commit is already in queue or processing
269
+ */
270
+ hasCommit(commitId) {
271
+ if (this.currentCommitId === commitId) return true;
272
+ return this.queue.some((item) => item.commit.id === commitId);
273
+ }
266
274
  /**
267
275
  * Add commit to processing queue
268
276
  */
269
277
  addToQueue(commit, project) {
278
+ if (this.hasCommit(commit.id)) {
279
+ logger.info(
280
+ "queue",
281
+ `Commit ${commit.id} is already in the queue or being processed. Skipping.`
282
+ );
283
+ return;
284
+ }
270
285
  this.queue.push({ commit, project });
271
286
  logger.info(
272
287
  "queue",
@@ -287,6 +302,7 @@ var CommitQueueProcessor = class {
287
302
  while (this.queue.length > 0) {
288
303
  const item = this.queue.shift();
289
304
  if (!item) break;
305
+ this.currentCommitId = item.commit.id;
290
306
  try {
291
307
  await this.executeCommit(item.commit, item.project);
292
308
  } catch (error) {
@@ -295,6 +311,8 @@ var CommitQueueProcessor = class {
295
311
  `Failed to execute commit ${item.commit.id}`,
296
312
  error
297
313
  );
314
+ } finally {
315
+ this.currentCommitId = null;
298
316
  }
299
317
  }
300
318
  this.isProcessing = false;
@@ -595,10 +613,12 @@ var WebSocketServiceImpl = class {
595
613
  echo = null;
596
614
  userId = null;
597
615
  reconnectAttempts = 0;
598
- maxReconnectAttempts = 10;
616
+ maxReconnectAttempts = -1;
617
+ // -1 = infinite reconnection
599
618
  reconnectTimer = null;
600
619
  isManualDisconnect = false;
601
620
  currentPusherClient = null;
621
+ lastSuccessfulConnection = null;
602
622
  // Event handlers
603
623
  commitApprovedHandlers = [];
604
624
  commitPendingHandlers = [];
@@ -775,6 +795,7 @@ var WebSocketServiceImpl = class {
775
795
  "\u2705 Connected to Broadcaster via Sanctum"
776
796
  );
777
797
  writeStatus({ connected: true });
798
+ this.lastSuccessfulConnection = /* @__PURE__ */ new Date();
778
799
  this.reconnectAttempts = 0;
779
800
  this.isManualDisconnect = false;
780
801
  safeResolve();
@@ -890,7 +911,7 @@ var WebSocketServiceImpl = class {
890
911
  "websocket",
891
912
  `Commit ${commit.id} marked as large, fetching full details...`
892
913
  );
893
- const { apiService: apiService2 } = await import("./api-AFSILC7K.js");
914
+ const { apiService: apiService2 } = await import("./api-JGCDZSG6.js");
894
915
  const fullCommit = await apiService2.getCommit(commit.id);
895
916
  commit = {
896
917
  ...commit,
@@ -934,7 +955,7 @@ var WebSocketServiceImpl = class {
934
955
  (handler) => handler(data.suggestion)
935
956
  );
936
957
  }).listen(".project.updated", (data) => {
937
- logger.info("websocket", `Project updated: ${data.project.id}`);
958
+ logger.debug("websocket", `Project updated: ${data.project.id}`);
938
959
  writeStatus({
939
960
  lastEvent: "project.updated",
940
961
  lastEventTime: (/* @__PURE__ */ new Date()).toISOString()
@@ -997,19 +1018,30 @@ var WebSocketServiceImpl = class {
997
1018
  clearTimeout(this.reconnectTimer);
998
1019
  this.reconnectTimer = null;
999
1020
  }
1000
- if (this.reconnectAttempts < this.maxReconnectAttempts) {
1021
+ if (this.maxReconnectAttempts === -1 || this.reconnectAttempts < this.maxReconnectAttempts) {
1001
1022
  const delay = this.getReconnectDelay();
1002
1023
  this.reconnectAttempts++;
1003
- logger.info(
1004
- "websocket",
1005
- `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`
1006
- );
1024
+ const attemptInfo = this.maxReconnectAttempts === -1 ? `attempt ${this.reconnectAttempts}` : `attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}`;
1025
+ logger.info("websocket", `Reconnecting in ${delay}ms (${attemptInfo})`);
1007
1026
  this.reconnectTimer = setTimeout(async () => {
1008
1027
  try {
1028
+ const token = await authService.getToken();
1029
+ if (!token) {
1030
+ logger.error(
1031
+ "websocket",
1032
+ "Cannot reconnect: authentication token expired or missing"
1033
+ );
1034
+ this.handleDisconnect();
1035
+ return;
1036
+ }
1009
1037
  await this.connect();
1010
1038
  if (this.userId) {
1011
1039
  await this.subscribeToUserChannel(this.userId);
1012
1040
  }
1041
+ logger.success(
1042
+ "websocket",
1043
+ `Reconnected successfully after ${this.reconnectAttempts} attempts`
1044
+ );
1013
1045
  } catch (error) {
1014
1046
  const errorMessage = error instanceof Error ? error.message : String(error);
1015
1047
  if (errorMessage.includes("disconnected during connection")) {
@@ -1026,12 +1058,56 @@ var WebSocketServiceImpl = class {
1026
1058
  logger.error("websocket", "Max reconnection attempts reached");
1027
1059
  }
1028
1060
  }
1061
+ /**
1062
+ * Force a reconnection attempt - used by health monitor
1063
+ * Resets attempt counter and triggers immediate reconnection
1064
+ */
1065
+ async forceReconnect() {
1066
+ logger.info("websocket", "Force reconnect requested by health monitor");
1067
+ this.reconnectAttempts = 0;
1068
+ if (this.reconnectTimer) {
1069
+ clearTimeout(this.reconnectTimer);
1070
+ this.reconnectTimer = null;
1071
+ }
1072
+ if (this.echo) {
1073
+ this.currentPusherClient = null;
1074
+ this.echo.disconnect();
1075
+ this.echo = null;
1076
+ }
1077
+ try {
1078
+ await this.connect();
1079
+ if (this.userId) {
1080
+ await this.subscribeToUserChannel(this.userId);
1081
+ }
1082
+ } catch (error) {
1083
+ logger.error("websocket", "Force reconnect failed", error);
1084
+ this.handleDisconnect();
1085
+ }
1086
+ }
1087
+ /**
1088
+ * Get the timestamp of last successful connection
1089
+ * Used by health monitor to detect stale connections
1090
+ */
1091
+ getLastSuccessfulConnection() {
1092
+ return this.lastSuccessfulConnection;
1093
+ }
1029
1094
  /**
1030
1095
  * Get reconnect delay with exponential backoff and jitter
1031
1096
  * Jitter prevents thundering herd problem when many clients reconnect simultaneously
1097
+ * Caps at 5 minutes for long-running daemon resilience
1032
1098
  */
1033
1099
  getReconnectDelay() {
1034
- const delays = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
1100
+ const delays = [
1101
+ 1e3,
1102
+ 2e3,
1103
+ 4e3,
1104
+ 8e3,
1105
+ 16e3,
1106
+ 3e4,
1107
+ 6e4,
1108
+ 12e4,
1109
+ 3e5
1110
+ ];
1035
1111
  const index = Math.min(this.reconnectAttempts, delays.length - 1);
1036
1112
  const baseDelay = delays[index];
1037
1113
  const jitter = baseDelay * (Math.random() * 0.3);
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  config,
3
3
  logger
4
- } from "./chunk-HPHXBSGB.js";
4
+ } from "./chunk-PWOHR6IZ.js";
5
5
 
6
6
  // src/services/git.ts
7
7
  import simpleGit from "simple-git";
@@ -346,7 +346,7 @@ var AuthServiceImpl = class {
346
346
  return null;
347
347
  }
348
348
  try {
349
- const { apiService } = await import("./api-AFSILC7K.js");
349
+ const { apiService } = await import("./api-JGCDZSG6.js");
350
350
  const user = await apiService.getCurrentUser();
351
351
  logger.info("auth", `Token validated for user: ${user.email}`);
352
352
  return user;
@@ -3,20 +3,20 @@ import {
3
3
  commitQueue,
4
4
  notify,
5
5
  websocketService
6
- } from "../chunk-NODAAPCO.js";
6
+ } from "../chunk-IFPIIRU3.js";
7
7
  import {
8
8
  apiService
9
- } from "../chunk-IBGWKTT7.js";
9
+ } from "../chunk-4WAUHGFS.js";
10
10
  import {
11
11
  gitService,
12
12
  projectService,
13
13
  removePidFile,
14
14
  writePidFile
15
- } from "../chunk-XRNTJYCQ.js";
15
+ } from "../chunk-LU6CQVSL.js";
16
16
  import {
17
17
  authService,
18
18
  logger
19
- } from "../chunk-HPHXBSGB.js";
19
+ } from "../chunk-PWOHR6IZ.js";
20
20
 
21
21
  // src/daemon/runner.ts
22
22
  import "dotenv/config";
@@ -310,31 +310,173 @@ async function syncPendingCommits() {
310
310
  "sync",
311
311
  `Checking ${relevantProjects.length} projects for pending commits...`
312
312
  );
313
- for (const project of relevantProjects) {
314
- try {
315
- const pendingCommits = await apiService.getPendingCommits(project.id);
316
- if (pendingCommits.length > 0) {
317
- logger.info(
313
+ await Promise.all(
314
+ relevantProjects.map(async (project) => {
315
+ try {
316
+ const pendingCommits = await apiService.getPendingCommits(project.id);
317
+ if (pendingCommits.length > 0) {
318
+ logger.info(
319
+ "sync",
320
+ `Found ${pendingCommits.length} pending commits for project ${project.name}`
321
+ );
322
+ for (const commit of pendingCommits) {
323
+ commitQueue.addToQueue(commit, project);
324
+ }
325
+ }
326
+ } catch (error) {
327
+ logger.error(
318
328
  "sync",
319
- `Found ${pendingCommits.length} pending commits for project ${project.name}`
329
+ `Failed to sync commits for project ${project.name}`,
330
+ error
320
331
  );
321
- for (const commit of pendingCommits) {
322
- commitQueue.addToQueue(commit, project);
323
- }
324
332
  }
333
+ })
334
+ );
335
+ logger.success("sync", "Pending commit sync complete");
336
+ } catch (error) {
337
+ logger.error("sync", "Failed to sync pending commits", error);
338
+ }
339
+ }
340
+
341
+ // src/daemon/health.ts
342
+ var HealthMonitor = class {
343
+ checkInterval = null;
344
+ startTime = null;
345
+ lastSuccessfulHeartbeat = null;
346
+ consecutiveFailures = 0;
347
+ maxConsecutiveFailures = 3;
348
+ checkIntervalMs = 9e4;
349
+ // 90 seconds
350
+ /**
351
+ * Start the health monitor
352
+ */
353
+ start() {
354
+ if (this.checkInterval) {
355
+ logger.warn("health", "Health monitor already running");
356
+ return;
357
+ }
358
+ this.startTime = /* @__PURE__ */ new Date();
359
+ this.consecutiveFailures = 0;
360
+ logger.info("health", "Starting health monitor (90s interval)");
361
+ this.checkInterval = setInterval(() => {
362
+ this.performHealthCheck().catch((error) => {
363
+ logger.error("health", "Health check error", error);
364
+ });
365
+ }, this.checkIntervalMs);
366
+ }
367
+ /**
368
+ * Stop the health monitor
369
+ */
370
+ stop() {
371
+ if (this.checkInterval) {
372
+ clearInterval(this.checkInterval);
373
+ this.checkInterval = null;
374
+ logger.info("health", "Health monitor stopped");
375
+ }
376
+ }
377
+ /**
378
+ * Record a successful heartbeat
379
+ * Called by the daemon after each successful API heartbeat
380
+ */
381
+ recordHeartbeat() {
382
+ this.lastSuccessfulHeartbeat = /* @__PURE__ */ new Date();
383
+ if (this.consecutiveFailures > 0) {
384
+ logger.info(
385
+ "health",
386
+ `Health restored after ${this.consecutiveFailures} consecutive failures`
387
+ );
388
+ this.consecutiveFailures = 0;
389
+ }
390
+ }
391
+ /**
392
+ * Get current health status
393
+ */
394
+ getStatus() {
395
+ const websocketConnected = websocketService.isConnected();
396
+ const lastSuccessfulConnection = websocketService.getLastSuccessfulConnection();
397
+ const heartbeatStale = this.isHeartbeatStale();
398
+ const healthy = websocketConnected && !heartbeatStale;
399
+ return {
400
+ healthy,
401
+ websocketConnected,
402
+ lastSuccessfulHeartbeat: this.lastSuccessfulHeartbeat,
403
+ lastSuccessfulConnection,
404
+ consecutiveFailures: this.consecutiveFailures,
405
+ uptime: this.startTime ? Math.floor((Date.now() - this.startTime.getTime()) / 1e3) : 0
406
+ };
407
+ }
408
+ /**
409
+ * Perform a health check and trigger recovery if needed
410
+ */
411
+ async performHealthCheck() {
412
+ const status = this.getStatus();
413
+ logger.debug(
414
+ "health",
415
+ `Health check: connected=${status.websocketConnected}, heartbeat_stale=${this.isHeartbeatStale()}, failures=${this.consecutiveFailures}`
416
+ );
417
+ if (!status.healthy) {
418
+ this.consecutiveFailures++;
419
+ logger.warn(
420
+ "health",
421
+ `Unhealthy state detected (${this.consecutiveFailures}/${this.maxConsecutiveFailures})`
422
+ );
423
+ if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
424
+ await this.triggerRecovery();
425
+ }
426
+ } else {
427
+ if (this.consecutiveFailures > 0) {
428
+ logger.info("health", "Health restored");
429
+ this.consecutiveFailures = 0;
430
+ }
431
+ }
432
+ }
433
+ /**
434
+ * Check if heartbeat is stale (no successful heartbeat in 3 minutes)
435
+ */
436
+ isHeartbeatStale() {
437
+ if (!this.lastSuccessfulHeartbeat) {
438
+ if (this.startTime) {
439
+ const sinceSart = Date.now() - this.startTime.getTime();
440
+ return sinceSart > 12e4;
441
+ }
442
+ return false;
443
+ }
444
+ const staleThreshold = 18e4;
445
+ const timeSinceHeartbeat = Date.now() - this.lastSuccessfulHeartbeat.getTime();
446
+ return timeSinceHeartbeat > staleThreshold;
447
+ }
448
+ /**
449
+ * Trigger recovery actions
450
+ */
451
+ async triggerRecovery() {
452
+ logger.warn(
453
+ "health",
454
+ "Triggering recovery due to persistent unhealthy state"
455
+ );
456
+ this.consecutiveFailures = 0;
457
+ if (!websocketService.isConnected()) {
458
+ logger.info("health", "Forcing WebSocket reconnection...");
459
+ try {
460
+ await websocketService.forceReconnect();
461
+ logger.success("health", "WebSocket reconnection initiated");
325
462
  } catch (error) {
326
463
  logger.error(
327
- "sync",
328
- `Failed to sync commits for project ${project.name}`,
464
+ "health",
465
+ "Failed to force WebSocket reconnection",
329
466
  error
330
467
  );
331
468
  }
332
469
  }
333
- logger.success("sync", "Pending commit sync complete");
334
- } catch (error) {
335
- logger.error("sync", "Failed to sync pending commits", error);
470
+ try {
471
+ await apiService.heartbeat();
472
+ this.recordHeartbeat();
473
+ logger.success("health", "API session verified");
474
+ } catch (error) {
475
+ logger.error("health", "API session check failed", error);
476
+ }
336
477
  }
337
- }
478
+ };
479
+ var healthMonitor = new HealthMonitor();
338
480
 
339
481
  // src/daemon/index.ts
340
482
  var heartbeatInterval = null;
@@ -390,18 +532,16 @@ Project: ${project.name}`,
390
532
  });
391
533
  });
392
534
  websocketService.onProjectUpdated((project) => {
393
- logger.info("daemon", `Project updated: ${project.id} - ${project.name}`);
535
+ logger.debug(
536
+ "daemon",
537
+ `Project updated: ${project.id} - ${project.name}`
538
+ );
394
539
  if (project.settings?.auto_sync) {
395
540
  fileWatcher.updateProjectSettings(
396
541
  project.id,
397
542
  project.settings.auto_sync
398
543
  );
399
544
  }
400
- notify({
401
- title: "Project Updated",
402
- message: project.name,
403
- category: "sync"
404
- });
405
545
  });
406
546
  websocketService.onDisconnect(() => {
407
547
  logger.warn(
@@ -456,6 +596,7 @@ Priority: ${suggestion.priority}`,
456
596
  });
457
597
  setupSignalHandlers();
458
598
  startHeartbeat();
599
+ healthMonitor.start();
459
600
  logger.info("daemon", "Starting file watcher...");
460
601
  fileWatcher.start();
461
602
  logger.info("daemon", "Daemon started successfully");
@@ -466,17 +607,19 @@ Priority: ${suggestion.priority}`,
466
607
  "daemon",
467
608
  `Syncing ${projectEntries.length} linked project(s) on startup...`
468
609
  );
469
- for (const [, linkedProject] of projectEntries) {
470
- try {
471
- await fileWatcher.syncProjectById(linkedProject.projectId);
472
- } catch (error) {
473
- logger.error(
474
- "daemon",
475
- `Failed to sync project ${linkedProject.projectId} on startup`,
476
- error
477
- );
478
- }
479
- }
610
+ await Promise.all(
611
+ projectEntries.map(async ([, linkedProject]) => {
612
+ try {
613
+ await fileWatcher.syncProjectById(linkedProject.projectId);
614
+ } catch (error) {
615
+ logger.error(
616
+ "daemon",
617
+ `Failed to sync project ${linkedProject.projectId} on startup`,
618
+ error
619
+ );
620
+ }
621
+ })
622
+ );
480
623
  logger.success("daemon", "Initial project sync complete");
481
624
  }
482
625
  await syncPendingCommits();
@@ -496,6 +639,7 @@ function startHeartbeat() {
496
639
  if (isShuttingDown) return;
497
640
  try {
498
641
  await apiService.heartbeat();
642
+ healthMonitor.recordHeartbeat();
499
643
  logger.debug("daemon", "Heartbeat sent successfully");
500
644
  } catch (error) {
501
645
  logger.error("daemon", "Heartbeat failed", error);
@@ -525,6 +669,7 @@ async function shutdown(reason) {
525
669
  isShuttingDown = true;
526
670
  shutdownReason = reason;
527
671
  logger.info("daemon", "Shutting down daemon...");
672
+ healthMonitor.stop();
528
673
  stopHeartbeat();
529
674
  try {
530
675
  fileWatcher.stop();
@@ -554,7 +699,35 @@ async function shutdown(reason) {
554
699
 
555
700
  // src/daemon/runner.ts
556
701
  writePidFile(process.pid);
557
- startDaemon().catch((error) => {
558
- logger.error("daemon-runner", "Daemon crashed", error);
559
- process.exit(1);
702
+ var startupAttempts = 0;
703
+ var maxStartupAttempts = 5;
704
+ async function startWithRetry() {
705
+ while (startupAttempts < maxStartupAttempts) {
706
+ try {
707
+ startupAttempts++;
708
+ await startDaemon();
709
+ logger.warn("daemon-runner", "Daemon exited unexpectedly, restarting...");
710
+ } catch (error) {
711
+ logger.error(
712
+ "daemon-runner",
713
+ `Daemon startup failed (attempt ${startupAttempts}/${maxStartupAttempts})`,
714
+ error
715
+ );
716
+ if (startupAttempts >= maxStartupAttempts) {
717
+ logger.error("daemon-runner", "Max startup attempts reached, exiting");
718
+ process.exit(1);
719
+ }
720
+ const delay = Math.min(5e3 * Math.pow(2, startupAttempts - 1), 6e4);
721
+ logger.info("daemon-runner", `Retrying in ${delay}ms...`);
722
+ await new Promise((resolve) => setTimeout(resolve, delay));
723
+ }
724
+ }
725
+ }
726
+ process.on("uncaughtException", (error) => {
727
+ logger.error("daemon-runner", "Uncaught exception", error);
728
+ });
729
+ process.on("unhandledRejection", (reason) => {
730
+ const error = reason instanceof Error ? reason : new Error(String(reason));
731
+ logger.error("daemon-runner", "Unhandled rejection", error);
560
732
  });
733
+ startWithRetry();
package/dist/index.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  commitQueue,
4
4
  websocketService
5
- } from "./chunk-NODAAPCO.js";
5
+ } from "./chunk-IFPIIRU3.js";
6
6
  import {
7
7
  apiService
8
- } from "./chunk-IBGWKTT7.js";
8
+ } from "./chunk-4WAUHGFS.js";
9
9
  import {
10
10
  getPidFilePath,
11
11
  gitService,
@@ -14,14 +14,14 @@ import {
14
14
  projectService,
15
15
  spawnDetached,
16
16
  validatePidFile
17
- } from "./chunk-XRNTJYCQ.js";
17
+ } from "./chunk-LU6CQVSL.js";
18
18
  import {
19
19
  __commonJS,
20
20
  __toESM,
21
21
  authService,
22
22
  config,
23
23
  logger
24
- } from "./chunk-HPHXBSGB.js";
24
+ } from "./chunk-PWOHR6IZ.js";
25
25
 
26
26
  // node_modules/semver/internal/constants.js
27
27
  var require_constants = __commonJS({
@@ -1274,7 +1274,7 @@ var require_range = __commonJS({
1274
1274
  var require_comparator = __commonJS({
1275
1275
  "node_modules/semver/classes/comparator.js"(exports, module) {
1276
1276
  "use strict";
1277
- var ANY = /* @__PURE__ */ Symbol("SemVer ANY");
1277
+ var ANY = Symbol("SemVer ANY");
1278
1278
  var Comparator = class _Comparator {
1279
1279
  static get ANY() {
1280
1280
  return ANY;
@@ -2657,7 +2657,7 @@ function registerStatusCommand(program2) {
2657
2657
  try {
2658
2658
  const { render } = await import("ink");
2659
2659
  const { createElement } = await import("react");
2660
- const { StatusDashboard } = await import("./StatusDashboard-AMDPK7EQ.js");
2660
+ const { StatusDashboard } = await import("./StatusDashboard-ROFBT73W.js");
2661
2661
  render(createElement(StatusDashboard, { cwd }));
2662
2662
  return;
2663
2663
  } catch (error) {
@@ -4846,7 +4846,7 @@ ${chalk14.bold("Config file:")} ${chalk14.cyan(configPath)}
4846
4846
  }
4847
4847
 
4848
4848
  // src/index.ts
4849
- var AGENT_VERSION = "1.2.37";
4849
+ var AGENT_VERSION = "1.2.39";
4850
4850
  var program = new Command();
4851
4851
  program.name("stint").description("Stint Agent - Local daemon for Stint Project Assistant").version(AGENT_VERSION, "-v, --version", "output the current version").addHelpText(
4852
4852
  "after",