@gowelle/stint-agent 1.0.8 → 1.1.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/README.md CHANGED
@@ -113,32 +113,30 @@ stint status
113
113
 
114
114
  ## Troubleshooting
115
115
 
116
- ### "Not authenticated" error
116
+ For comprehensive troubleshooting help, see the **[Troubleshooting Guide](docs/TROUBLESHOOTING.md)**.
117
117
 
118
- Run `stint login` to authenticate with your Stint account.
119
-
120
- ### "Repository has uncommitted changes"
121
-
122
- The agent requires a clean repository to execute commits:
118
+ ### Quick Tips
123
119
 
120
+ **"Not authenticated" error**
124
121
  ```bash
125
- git stash # Temporarily stash changes
126
- # or
127
- git add . && git commit -m "message"
122
+ stint login
128
123
  ```
129
124
 
130
- ### Daemon won't start
131
-
125
+ **Daemon won't start**
132
126
  ```bash
133
127
  stint daemon status # Check if already running
134
128
  stint daemon logs # Check logs for errors
135
- stint daemon stop # Stop first
136
- stint daemon start # Then start again
129
+ stint daemon restart # Restart daemon
137
130
  ```
138
131
 
139
- ### WebSocket connection issues
132
+ **For detailed solutions**, including:
133
+ - Connection issues (WebSocket, API, Circuit Breaker)
134
+ - Daemon problems (crashes, autostart)
135
+ - Authentication errors
136
+ - Git operation failures
137
+ - Platform-specific issues (Windows, macOS, Linux)
140
138
 
141
- Check your network connection and firewall settings.
139
+ See the **[Troubleshooting Guide](docs/TROUBLESHOOTING.md)**.
142
140
 
143
141
  ## Logging
144
142
 
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  apiService
3
- } from "./chunk-5OKRSNU4.js";
3
+ } from "./chunk-IAERVP6F.js";
4
4
  export {
5
5
  apiService
6
6
  };
@@ -254,7 +254,7 @@ var AuthServiceImpl = class {
254
254
  return null;
255
255
  }
256
256
  try {
257
- const { apiService: apiService2 } = await import("./api-2MI5XBJX.js");
257
+ const { apiService: apiService2 } = await import("./api-YI2HWZGL.js");
258
258
  const user = await apiService2.getCurrentUser();
259
259
  logger.info("auth", `Token validated for user: ${user.email}`);
260
260
  return user;
@@ -273,10 +273,116 @@ var AuthServiceImpl = class {
273
273
  };
274
274
  var authService = new AuthServiceImpl();
275
275
 
276
+ // src/utils/circuit-breaker.ts
277
+ var CircuitBreaker = class {
278
+ constructor(options) {
279
+ this.options = options;
280
+ this.options.windowSize = options.windowSize || 6e4;
281
+ }
282
+ state = "CLOSED";
283
+ failures = 0;
284
+ successes = 0;
285
+ lastFailureTime = 0;
286
+ openedAt = 0;
287
+ failureTimestamps = [];
288
+ /**
289
+ * Execute an operation through the circuit breaker
290
+ */
291
+ async execute(operation, fallback) {
292
+ if (this.state === "OPEN") {
293
+ if (Date.now() - this.openedAt >= this.options.timeout) {
294
+ this.state = "HALF_OPEN";
295
+ this.successes = 0;
296
+ } else {
297
+ if (fallback) {
298
+ return fallback();
299
+ }
300
+ throw new Error("Circuit breaker is OPEN - service unavailable");
301
+ }
302
+ }
303
+ try {
304
+ const result = await operation();
305
+ this.onSuccess();
306
+ return result;
307
+ } catch (error) {
308
+ this.onFailure();
309
+ throw error;
310
+ }
311
+ }
312
+ /**
313
+ * Handle successful operation
314
+ */
315
+ onSuccess() {
316
+ this.failures = 0;
317
+ if (this.state === "HALF_OPEN") {
318
+ this.successes++;
319
+ if (this.successes >= this.options.successThreshold) {
320
+ this.state = "CLOSED";
321
+ this.successes = 0;
322
+ this.failureTimestamps = [];
323
+ }
324
+ }
325
+ }
326
+ /**
327
+ * Handle failed operation
328
+ */
329
+ onFailure() {
330
+ this.lastFailureTime = Date.now();
331
+ this.failureTimestamps.push(this.lastFailureTime);
332
+ const windowStart = this.lastFailureTime - this.options.windowSize;
333
+ this.failureTimestamps = this.failureTimestamps.filter((ts) => ts > windowStart);
334
+ this.failures = this.failureTimestamps.length;
335
+ if (this.state === "HALF_OPEN") {
336
+ this.state = "OPEN";
337
+ this.openedAt = Date.now();
338
+ this.successes = 0;
339
+ } else if (this.state === "CLOSED") {
340
+ if (this.failures >= this.options.failureThreshold) {
341
+ this.state = "OPEN";
342
+ this.openedAt = Date.now();
343
+ }
344
+ }
345
+ }
346
+ /**
347
+ * Get current state
348
+ */
349
+ getState() {
350
+ return this.state;
351
+ }
352
+ /**
353
+ * Get failure count in current window
354
+ */
355
+ getFailureCount() {
356
+ return this.failures;
357
+ }
358
+ /**
359
+ * Reset the circuit breaker to CLOSED state
360
+ */
361
+ reset() {
362
+ this.state = "CLOSED";
363
+ this.failures = 0;
364
+ this.successes = 0;
365
+ this.failureTimestamps = [];
366
+ }
367
+ };
368
+
276
369
  // src/services/api.ts
277
- var AGENT_VERSION = "1.0.8";
370
+ var AGENT_VERSION = "1.1.0";
278
371
  var ApiServiceImpl = class {
279
372
  sessionId = null;
373
+ circuitBreaker = new CircuitBreaker({
374
+ failureThreshold: 5,
375
+ successThreshold: 2,
376
+ timeout: 3e4,
377
+ // 30s before trying half-open
378
+ windowSize: 6e4
379
+ // 60s failure window
380
+ });
381
+ /**
382
+ * Get authentication headers for API requests
383
+ * @returns Headers object with Authorization and Content-Type
384
+ * @throws Error if no authentication token is found
385
+ */
280
386
  async getHeaders() {
281
387
  const token = await authService.getToken();
282
388
  if (!token) {
@@ -287,29 +393,43 @@ var ApiServiceImpl = class {
287
393
  "Content-Type": "application/json"
288
394
  };
289
395
  }
396
+ /**
397
+ * Make an HTTP request to the API with circuit breaker protection
398
+ * @param endpoint - API endpoint path (e.g., '/api/user')
399
+ * @param options - Fetch options (method, body, headers, etc.)
400
+ * @returns Parsed JSON response
401
+ * @throws Error if request fails or circuit breaker is open
402
+ */
290
403
  async request(endpoint, options = {}) {
291
404
  const url = `${config.getApiUrl()}${endpoint}`;
292
405
  const headers = await this.getHeaders();
293
- try {
294
- const response = await fetch(url, {
295
- ...options,
296
- headers: {
297
- ...headers,
298
- ...options.headers
406
+ return this.circuitBreaker.execute(async () => {
407
+ try {
408
+ const response = await fetch(url, {
409
+ ...options,
410
+ headers: {
411
+ ...headers,
412
+ ...options.headers
413
+ }
414
+ });
415
+ if (!response.ok) {
416
+ const errorText = await response.text();
417
+ throw new Error(`API request failed: ${response.status} ${errorText}`);
299
418
  }
300
- });
301
- if (!response.ok) {
302
- const errorText = await response.text();
303
- throw new Error(`API request failed: ${response.status} ${errorText}`);
419
+ return await response.json();
420
+ } catch (error) {
421
+ logger.error("api", `Request to ${endpoint} failed`, error);
422
+ throw error;
304
423
  }
305
- return await response.json();
306
- } catch (error) {
307
- logger.error("api", `Request to ${endpoint} failed`, error);
308
- throw error;
309
- }
424
+ });
310
425
  }
311
426
  /**
312
427
  * Retry wrapper with exponential backoff
428
+ * @param operation - Async operation to retry
429
+ * @param operationName - Name for logging
430
+ * @param maxRetries - Maximum number of retry attempts (default: 3)
431
+ * @returns Result of the operation
432
+ * @throws Last error if all retries fail
313
433
  */
314
434
  async withRetry(operation, operationName, maxRetries = 3) {
315
435
  let lastError;
@@ -332,6 +452,11 @@ var ApiServiceImpl = class {
332
452
  }
333
453
  throw lastError;
334
454
  }
455
+ /**
456
+ * Connect agent session to the API
457
+ * @returns Agent session data including session ID
458
+ * @throws Error if connection fails
459
+ */
335
460
  async connect() {
336
461
  logger.info("api", "Connecting agent session...");
337
462
  const os4 = `${process.platform}-${process.arch}`;
@@ -351,6 +476,10 @@ var ApiServiceImpl = class {
351
476
  return session;
352
477
  }, "Connect");
353
478
  }
479
+ /**
480
+ * Disconnect agent session from the API
481
+ * @param reason - Optional reason for disconnection
482
+ */
354
483
  async disconnect(reason) {
355
484
  if (!this.sessionId) {
356
485
  return;
@@ -366,6 +495,10 @@ var ApiServiceImpl = class {
366
495
  this.sessionId = null;
367
496
  logger.success("api", "Agent session disconnected");
368
497
  }
498
+ /**
499
+ * Send heartbeat to keep session alive
500
+ * @throws Error if no active session
501
+ */
369
502
  async heartbeat() {
370
503
  if (!this.sessionId) {
371
504
  throw new Error("No active session");
@@ -380,6 +513,11 @@ var ApiServiceImpl = class {
380
513
  logger.debug("api", "Heartbeat sent");
381
514
  }, "Heartbeat");
382
515
  }
516
+ /**
517
+ * Get pending commits for a project
518
+ * @param projectId - Project ID
519
+ * @returns Array of pending commits
520
+ */
383
521
  async getPendingCommits(projectId) {
384
522
  logger.info("api", `Fetching pending commits for project ${projectId}`);
385
523
  const response = await this.request(
@@ -395,6 +533,12 @@ var ApiServiceImpl = class {
395
533
  logger.info("api", `Found ${commits.length} pending commits`);
396
534
  return commits;
397
535
  }
536
+ /**
537
+ * Mark a commit as successfully executed
538
+ * @param commitId - Commit ID
539
+ * @param sha - Git commit SHA
540
+ * @returns Updated commit data
541
+ */
398
542
  async markCommitExecuted(commitId, sha) {
399
543
  logger.info("api", `Marking commit ${commitId} as executed (SHA: ${sha})`);
400
544
  return this.withRetry(async () => {
@@ -420,6 +564,11 @@ var ApiServiceImpl = class {
420
564
  return commit;
421
565
  }, "Mark commit executed");
422
566
  }
567
+ /**
568
+ * Mark a commit as failed
569
+ * @param commitId - Commit ID
570
+ * @param error - Error message
571
+ */
423
572
  async markCommitFailed(commitId, error) {
424
573
  logger.error("api", `Marking commit ${commitId} as failed: ${error}`);
425
574
  await this.withRetry(async () => {
@@ -429,6 +578,11 @@ var ApiServiceImpl = class {
429
578
  });
430
579
  }, "Mark commit failed");
431
580
  }
581
+ /**
582
+ * Sync project repository information with the API
583
+ * @param projectId - Project ID
584
+ * @param data - Repository information (path, remote URL, branches)
585
+ */
432
586
  async syncProject(projectId, data) {
433
587
  logger.info("api", `Syncing project ${projectId}`);
434
588
  await this.withRetry(async () => {
@@ -2,7 +2,7 @@ import {
2
2
  apiService,
3
3
  config,
4
4
  logger
5
- } from "./chunk-5OKRSNU4.js";
5
+ } from "./chunk-IAERVP6F.js";
6
6
 
7
7
  // src/utils/process.ts
8
8
  import fs from "fs";
@@ -104,6 +104,11 @@ var GitServiceImpl = class {
104
104
  getGit(path3) {
105
105
  return simpleGit(path3);
106
106
  }
107
+ /**
108
+ * Check if a directory is a git repository
109
+ * @param path - Directory path to check
110
+ * @returns True if directory is a git repository
111
+ */
107
112
  async isRepo(path3) {
108
113
  try {
109
114
  const git = this.getGit(path3);
@@ -113,6 +118,12 @@ var GitServiceImpl = class {
113
118
  return false;
114
119
  }
115
120
  }
121
+ /**
122
+ * Get comprehensive repository information
123
+ * @param path - Repository path
124
+ * @returns Repository info including branches, status, and last commit
125
+ * @throws Error if not a valid repository or no commits found
126
+ */
116
127
  async getRepoInfo(path3) {
117
128
  try {
118
129
  const git = this.getGit(path3);
@@ -5,13 +5,13 @@ import {
5
5
  projectService,
6
6
  removePidFile,
7
7
  writePidFile
8
- } from "../chunk-56UZEICO.js";
8
+ } from "../chunk-OHOFKJL7.js";
9
9
  import {
10
10
  apiService,
11
11
  authService,
12
12
  config,
13
13
  logger
14
- } from "../chunk-5OKRSNU4.js";
14
+ } from "../chunk-IAERVP6F.js";
15
15
 
16
16
  // src/daemon/runner.ts
17
17
  import "dotenv/config";
@@ -33,6 +33,10 @@ var WebSocketServiceImpl = class {
33
33
  disconnectHandlers = [];
34
34
  agentDisconnectedHandlers = [];
35
35
  syncRequestedHandlers = [];
36
+ /**
37
+ * Connect to the WebSocket server
38
+ * @throws Error if connection fails or no auth token available
39
+ */
36
40
  async connect() {
37
41
  try {
38
42
  const token = await authService.getToken();
@@ -71,6 +75,10 @@ var WebSocketServiceImpl = class {
71
75
  throw error;
72
76
  }
73
77
  }
78
+ /**
79
+ * Disconnect from the WebSocket server
80
+ * Prevents automatic reconnection
81
+ */
74
82
  disconnect() {
75
83
  this.isManualDisconnect = true;
76
84
  if (this.reconnectTimer) {
@@ -91,9 +99,17 @@ var WebSocketServiceImpl = class {
91
99
  logger.info("websocket", "WebSocket disconnected");
92
100
  }
93
101
  }
102
+ /**
103
+ * Check if WebSocket is currently connected
104
+ * @returns True if connected and ready
105
+ */
94
106
  isConnected() {
95
107
  return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
96
108
  }
109
+ /**
110
+ * Subscribe to user-specific channel for real-time updates
111
+ * @param userId - User ID to subscribe to
112
+ */
97
113
  subscribeToUserChannel(userId) {
98
114
  this.userId = userId;
99
115
  if (!this.isConnected()) {
@@ -109,6 +125,10 @@ var WebSocketServiceImpl = class {
109
125
  }
110
126
  });
111
127
  }
128
+ /**
129
+ * Register handler for commit approved events
130
+ * @param handler - Callback function
131
+ */
112
132
  onCommitApproved(handler) {
113
133
  this.commitApprovedHandlers.push(handler);
114
134
  }
@@ -214,10 +234,16 @@ var WebSocketServiceImpl = class {
214
234
  logger.error("websocket", "Max reconnection attempts reached");
215
235
  }
216
236
  }
237
+ /**
238
+ * Get reconnect delay with exponential backoff and jitter
239
+ * Jitter prevents thundering herd problem when many clients reconnect simultaneously
240
+ */
217
241
  getReconnectDelay() {
218
242
  const delays = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
219
243
  const index = Math.min(this.reconnectAttempts, delays.length - 1);
220
- return delays[index];
244
+ const baseDelay = delays[index];
245
+ const jitter = baseDelay * (Math.random() * 0.3);
246
+ return Math.floor(baseDelay + jitter);
221
247
  }
222
248
  };
223
249
  var websocketService = new WebSocketServiceImpl();
package/dist/index.js CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  projectService,
9
9
  spawnDetached,
10
10
  validatePidFile
11
- } from "./chunk-56UZEICO.js";
11
+ } from "./chunk-OHOFKJL7.js";
12
12
  import {
13
13
  apiService,
14
14
  authService,
15
15
  config,
16
16
  logger
17
- } from "./chunk-5OKRSNU4.js";
17
+ } from "./chunk-IAERVP6F.js";
18
18
 
19
19
  // src/index.ts
20
20
  import "dotenv/config";
@@ -1328,7 +1328,7 @@ Current version: ${currentVersion}`));
1328
1328
  }
1329
1329
 
1330
1330
  // src/index.ts
1331
- var AGENT_VERSION = "1.0.8";
1331
+ var AGENT_VERSION = "1.1.0";
1332
1332
  var program = new Command();
1333
1333
  program.name("stint").description("Stint Agent - Local daemon for Stint Project Assistant").version(AGENT_VERSION, "-v, --version", "output the current version").addHelpText("after", `
1334
1334
  ${chalk12.bold("Examples:")}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gowelle/stint-agent",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Local agent for Stint - Project Assistant",
5
5
  "author": "Gowelle John <gowelle.john@icloud.com>",
6
6
  "license": "MIT",
@@ -54,6 +54,7 @@
54
54
  "@types/ws": "^8.5.10",
55
55
  "@typescript-eslint/eslint-plugin": "^8.50.0",
56
56
  "@typescript-eslint/parser": "^8.50.0",
57
+ "@vitest/coverage-v8": "^4.0.16",
57
58
  "eslint": "^8.56.0",
58
59
  "tsup": "^8.0.1",
59
60
  "typescript": "^5.3.3",