@gowelle/stint-agent 1.0.7 → 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-DPKCS26C.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-DA6X5ZWT.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.7";
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-DPKCS26C.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);
@@ -229,6 +240,15 @@ var GitServiceImpl = class {
229
240
  throw new Error(`Failed to get git status: ${error.message}`);
230
241
  }
231
242
  }
243
+ async getRepoRoot(path3) {
244
+ try {
245
+ const git = this.getGit(path3);
246
+ const root = await git.revparse(["--show-toplevel"]);
247
+ return root.trim();
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
232
252
  };
233
253
  var gitService = new GitServiceImpl();
234
254
 
@@ -238,16 +258,20 @@ var ProjectServiceImpl = class {
238
258
  async linkProject(projectPath, projectId) {
239
259
  try {
240
260
  const absolutePath = path2.resolve(projectPath);
241
- const isRepo = await gitService.isRepo(absolutePath);
242
- if (!isRepo) {
243
- throw new Error(`${absolutePath} is not a git repository`);
261
+ const repoRoot = await gitService.getRepoRoot(absolutePath);
262
+ const linkPath = repoRoot || absolutePath;
263
+ if (!repoRoot) {
264
+ const isRepo = await gitService.isRepo(absolutePath);
265
+ if (!isRepo) {
266
+ throw new Error(`${absolutePath} is not a git repository`);
267
+ }
244
268
  }
245
269
  const linkedProject = {
246
270
  projectId,
247
271
  linkedAt: (/* @__PURE__ */ new Date()).toISOString()
248
272
  };
249
- config.setProject(absolutePath, linkedProject);
250
- logger.success("project", `Linked ${absolutePath} to project ${projectId}`);
273
+ config.setProject(linkPath, linkedProject);
274
+ logger.success("project", `Linked ${linkPath} must be to project ${projectId}`);
251
275
  } catch (error) {
252
276
  logger.error("project", "Failed to link project", error);
253
277
  throw error;
@@ -256,20 +280,29 @@ var ProjectServiceImpl = class {
256
280
  async unlinkProject(projectPath) {
257
281
  try {
258
282
  const absolutePath = path2.resolve(projectPath);
259
- const linkedProject = this.getLinkedProject(absolutePath);
283
+ const repoRoot = await gitService.getRepoRoot(absolutePath);
284
+ const lookupPath = repoRoot || absolutePath;
285
+ const linkedProject = config.getProject(lookupPath);
260
286
  if (!linkedProject) {
261
287
  throw new Error(`${absolutePath} is not linked to any project`);
262
288
  }
263
- config.removeProject(absolutePath);
264
- logger.success("project", `Unlinked ${absolutePath}`);
289
+ config.removeProject(lookupPath);
290
+ logger.success("project", `Unlinked ${lookupPath}`);
265
291
  } catch (error) {
266
292
  logger.error("project", "Failed to unlink project", error);
267
293
  throw error;
268
294
  }
269
295
  }
270
- getLinkedProject(projectPath) {
296
+ async getLinkedProject(projectPath) {
271
297
  const absolutePath = path2.resolve(projectPath);
272
- return config.getProject(absolutePath) || null;
298
+ let project = config.getProject(absolutePath);
299
+ if (project) return project;
300
+ const repoRoot = await gitService.getRepoRoot(absolutePath);
301
+ if (repoRoot) {
302
+ project = config.getProject(repoRoot);
303
+ if (project) return project;
304
+ }
305
+ return null;
273
306
  }
274
307
  getAllLinkedProjects() {
275
308
  return config.getProjects();
@@ -5,13 +5,13 @@ import {
5
5
  projectService,
6
6
  removePidFile,
7
7
  writePidFile
8
- } from "../chunk-EEO2GKXF.js";
8
+ } from "../chunk-OHOFKJL7.js";
9
9
  import {
10
10
  apiService,
11
11
  authService,
12
12
  config,
13
13
  logger
14
- } from "../chunk-DPKCS26C.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-EEO2GKXF.js";
11
+ } from "./chunk-OHOFKJL7.js";
12
12
  import {
13
13
  apiService,
14
14
  authService,
15
15
  config,
16
16
  logger
17
- } from "./chunk-DPKCS26C.js";
17
+ } from "./chunk-IAERVP6F.js";
18
18
 
19
19
  // src/index.ts
20
20
  import "dotenv/config";
@@ -456,7 +456,7 @@ function registerLinkCommand(program2) {
456
456
  const spinner = ora4("Checking directory...").start();
457
457
  try {
458
458
  const cwd = process2.cwd();
459
- const existingLink = projectService.getLinkedProject(cwd);
459
+ const existingLink = await projectService.getLinkedProject(cwd);
460
460
  if (existingLink) {
461
461
  spinner.warn("Directory already linked");
462
462
  console.log(chalk4.yellow(`
@@ -562,7 +562,7 @@ function registerUnlinkCommand(program2) {
562
562
  const spinner = ora5("Checking directory...").start();
563
563
  try {
564
564
  const cwd = process3.cwd();
565
- const linkedProject = projectService.getLinkedProject(cwd);
565
+ const linkedProject = await projectService.getLinkedProject(cwd);
566
566
  if (!linkedProject) {
567
567
  spinner.info("Not linked");
568
568
  console.log(chalk5.yellow("\n\u26A0 This directory is not linked to any project.\n"));
@@ -613,7 +613,7 @@ function registerStatusCommand(program2) {
613
613
  const spinner = ora6("Gathering status...").start();
614
614
  try {
615
615
  const cwd = process4.cwd();
616
- const linkedProject = projectService.getLinkedProject(cwd);
616
+ const linkedProject = await projectService.getLinkedProject(cwd);
617
617
  const user = await authService.validateToken();
618
618
  spinner.stop();
619
619
  console.log(chalk6.blue("\n\u{1F4E6} Project Status:"));
@@ -698,7 +698,7 @@ function registerSyncCommand(program2) {
698
698
  const spinner = ora7("Checking directory...").start();
699
699
  try {
700
700
  const cwd = process5.cwd();
701
- const linkedProject = projectService.getLinkedProject(cwd);
701
+ const linkedProject = await projectService.getLinkedProject(cwd);
702
702
  if (!linkedProject) {
703
703
  spinner.fail("Not linked");
704
704
  console.log(chalk7.yellow("\n\u26A0 This directory is not linked to any project."));
@@ -937,7 +937,7 @@ function registerCommitCommands(program2) {
937
937
  const spinner = ora9("Loading pending commits...").start();
938
938
  try {
939
939
  const cwd = process6.cwd();
940
- const linkedProject = projectService.getLinkedProject(cwd);
940
+ const linkedProject = await projectService.getLinkedProject(cwd);
941
941
  if (!linkedProject) {
942
942
  spinner.fail("Not linked");
943
943
  console.log(chalk9.yellow("\n\u26A0 This directory is not linked to any project."));
@@ -979,7 +979,7 @@ function registerCommitCommands(program2) {
979
979
  const spinner = ora9("Checking repository status...").start();
980
980
  try {
981
981
  const cwd = process6.cwd();
982
- const linkedProject = projectService.getLinkedProject(cwd);
982
+ const linkedProject = await projectService.getLinkedProject(cwd);
983
983
  if (!linkedProject) {
984
984
  spinner.fail("Not linked");
985
985
  console.log(chalk9.yellow("\n\u26A0 This directory is not linked to any project."));
@@ -1328,9 +1328,9 @@ Current version: ${currentVersion}`));
1328
1328
  }
1329
1329
 
1330
1330
  // src/index.ts
1331
- var AGENT_VERSION = "1.0.7";
1331
+ var AGENT_VERSION = "1.1.0";
1332
1332
  var program = new Command();
1333
- program.name("stint").description("Stint Agent - Local daemon for Stint Project Assistant").version(AGENT_VERSION, "-v, -V, --version", "output the current version").addHelpText("after", `
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:")}
1335
1335
  ${chalk12.cyan("$")} stint login ${chalk12.gray("# Authenticate with Stint")}
1336
1336
  ${chalk12.cyan("$")} stint install ${chalk12.gray("# Install agent to run on startup")}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gowelle/stint-agent",
3
- "version": "1.0.7",
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",