@gowelle/stint-agent 1.0.8 → 1.2.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,384 @@
1
+ import {
2
+ authService,
3
+ config,
4
+ logger
5
+ } from "./chunk-RHMTZK2J.js";
6
+
7
+ // src/utils/circuit-breaker.ts
8
+ var CircuitBreaker = class {
9
+ constructor(options) {
10
+ this.options = options;
11
+ this.options.windowSize = options.windowSize || 6e4;
12
+ }
13
+ state = "CLOSED";
14
+ failures = 0;
15
+ successes = 0;
16
+ lastFailureTime = 0;
17
+ openedAt = 0;
18
+ failureTimestamps = [];
19
+ /**
20
+ * Execute an operation through the circuit breaker
21
+ */
22
+ async execute(operation, fallback) {
23
+ if (this.state === "OPEN") {
24
+ if (Date.now() - this.openedAt >= this.options.timeout) {
25
+ this.state = "HALF_OPEN";
26
+ this.successes = 0;
27
+ } else {
28
+ if (fallback) {
29
+ return fallback();
30
+ }
31
+ throw new Error("Circuit breaker is OPEN - service unavailable");
32
+ }
33
+ }
34
+ try {
35
+ const result = await operation();
36
+ this.onSuccess();
37
+ return result;
38
+ } catch (error) {
39
+ this.onFailure();
40
+ throw error;
41
+ }
42
+ }
43
+ /**
44
+ * Handle successful operation
45
+ */
46
+ onSuccess() {
47
+ this.failures = 0;
48
+ if (this.state === "HALF_OPEN") {
49
+ this.successes++;
50
+ if (this.successes >= this.options.successThreshold) {
51
+ this.state = "CLOSED";
52
+ this.successes = 0;
53
+ this.failureTimestamps = [];
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * Handle failed operation
59
+ */
60
+ onFailure() {
61
+ this.lastFailureTime = Date.now();
62
+ this.failureTimestamps.push(this.lastFailureTime);
63
+ const windowStart = this.lastFailureTime - this.options.windowSize;
64
+ this.failureTimestamps = this.failureTimestamps.filter((ts) => ts > windowStart);
65
+ this.failures = this.failureTimestamps.length;
66
+ if (this.state === "HALF_OPEN") {
67
+ this.state = "OPEN";
68
+ this.openedAt = Date.now();
69
+ this.successes = 0;
70
+ } else if (this.state === "CLOSED") {
71
+ if (this.failures >= this.options.failureThreshold) {
72
+ this.state = "OPEN";
73
+ this.openedAt = Date.now();
74
+ }
75
+ }
76
+ }
77
+ /**
78
+ * Get current state
79
+ */
80
+ getState() {
81
+ return this.state;
82
+ }
83
+ /**
84
+ * Get failure count in current window
85
+ */
86
+ getFailureCount() {
87
+ return this.failures;
88
+ }
89
+ /**
90
+ * Reset the circuit breaker to CLOSED state
91
+ */
92
+ reset() {
93
+ this.state = "CLOSED";
94
+ this.failures = 0;
95
+ this.successes = 0;
96
+ this.failureTimestamps = [];
97
+ }
98
+ };
99
+
100
+ // src/services/api.ts
101
+ var AGENT_VERSION = "1.2.0";
102
+ var ApiServiceImpl = class {
103
+ sessionId = null;
104
+ circuitBreaker = new CircuitBreaker({
105
+ failureThreshold: 5,
106
+ successThreshold: 2,
107
+ timeout: 3e4,
108
+ // 30s before trying half-open
109
+ windowSize: 6e4
110
+ // 60s failure window
111
+ });
112
+ /**
113
+ * Get authentication headers for API requests
114
+ * @returns Headers object with Authorization and Content-Type
115
+ * @throws Error if no authentication token is found
116
+ */
117
+ async getHeaders() {
118
+ const token = await authService.getToken();
119
+ if (!token) {
120
+ throw new Error('No authentication token found. Please run "stint login" first.');
121
+ }
122
+ return {
123
+ Authorization: `Bearer ${token}`,
124
+ "Content-Type": "application/json"
125
+ };
126
+ }
127
+ /**
128
+ * Make an HTTP request to the API with circuit breaker protection
129
+ * @param endpoint - API endpoint path (e.g., '/api/user')
130
+ * @param options - Fetch options (method, body, headers, etc.)
131
+ * @returns Parsed JSON response
132
+ * @throws Error if request fails or circuit breaker is open
133
+ */
134
+ async request(endpoint, options = {}) {
135
+ const url = `${config.getApiUrl()}${endpoint}`;
136
+ const headers = await this.getHeaders();
137
+ return this.circuitBreaker.execute(async () => {
138
+ try {
139
+ const response = await fetch(url, {
140
+ ...options,
141
+ headers: {
142
+ ...headers,
143
+ ...options.headers
144
+ }
145
+ });
146
+ if (!response.ok) {
147
+ const errorText = await response.text();
148
+ throw new Error(`API request failed: ${response.status} ${errorText}`);
149
+ }
150
+ return await response.json();
151
+ } catch (error) {
152
+ logger.error("api", `Request to ${endpoint} failed`, error);
153
+ throw error;
154
+ }
155
+ });
156
+ }
157
+ /**
158
+ * Retry wrapper with exponential backoff
159
+ * @param operation - Async operation to retry
160
+ * @param operationName - Name for logging
161
+ * @param maxRetries - Maximum number of retry attempts (default: 3)
162
+ * @returns Result of the operation
163
+ * @throws Last error if all retries fail
164
+ */
165
+ async withRetry(operation, operationName, maxRetries = 3) {
166
+ let lastError;
167
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
168
+ try {
169
+ return await operation();
170
+ } catch (error) {
171
+ lastError = error;
172
+ if (lastError.message.includes("401") || lastError.message.includes("403")) {
173
+ throw lastError;
174
+ }
175
+ if (attempt < maxRetries) {
176
+ const delay = Math.min(1e3 * Math.pow(2, attempt - 1), 5e3);
177
+ logger.warn("api", `${operationName} failed, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})`);
178
+ await new Promise((resolve) => setTimeout(resolve, delay));
179
+ } else {
180
+ logger.error("api", `${operationName} failed after ${maxRetries} attempts`);
181
+ }
182
+ }
183
+ }
184
+ throw lastError;
185
+ }
186
+ /**
187
+ * Connect agent session to the API
188
+ * @returns Agent session data including session ID
189
+ * @throws Error if connection fails
190
+ */
191
+ async connect() {
192
+ logger.info("api", "Connecting agent session...");
193
+ const os = `${process.platform}-${process.arch}`;
194
+ return this.withRetry(async () => {
195
+ const response = await this.request("/api/agent/connect", {
196
+ method: "POST",
197
+ body: JSON.stringify({
198
+ machine_id: authService.getMachineId(),
199
+ machine_name: authService.getMachineName(),
200
+ os,
201
+ agent_version: AGENT_VERSION
202
+ })
203
+ });
204
+ const session = response.data;
205
+ this.sessionId = session.id;
206
+ logger.success("api", `Agent session connected: ${session.id}`);
207
+ return session;
208
+ }, "Connect");
209
+ }
210
+ /**
211
+ * Disconnect agent session from the API
212
+ * @param reason - Optional reason for disconnection
213
+ */
214
+ async disconnect(reason) {
215
+ if (!this.sessionId) {
216
+ return;
217
+ }
218
+ logger.info("api", "Disconnecting agent session...");
219
+ await this.request("/api/agent/disconnect", {
220
+ method: "POST",
221
+ body: JSON.stringify({
222
+ session_id: this.sessionId,
223
+ reason: reason || "Agent disconnected"
224
+ })
225
+ });
226
+ this.sessionId = null;
227
+ logger.success("api", "Agent session disconnected");
228
+ }
229
+ /**
230
+ * Send heartbeat to keep session alive
231
+ * @throws Error if no active session
232
+ */
233
+ async heartbeat() {
234
+ if (!this.sessionId) {
235
+ throw new Error("No active session");
236
+ }
237
+ await this.withRetry(async () => {
238
+ await this.request("/api/agent/heartbeat", {
239
+ method: "POST",
240
+ body: JSON.stringify({
241
+ session_id: this.sessionId
242
+ })
243
+ });
244
+ logger.debug("api", "Heartbeat sent");
245
+ }, "Heartbeat");
246
+ }
247
+ /**
248
+ * Get pending commits for a project
249
+ * @param projectId - Project ID
250
+ * @returns Array of pending commits
251
+ */
252
+ async getPendingCommits(projectId) {
253
+ logger.info("api", `Fetching pending commits for project ${projectId}`);
254
+ const response = await this.request(
255
+ `/api/agent/pending-commits?project_id=${projectId}`
256
+ );
257
+ const commits = response.data.map((item) => {
258
+ const projectId2 = item.project_id || item.projectId;
259
+ const createdAt = item.created_at || item.createdAt;
260
+ if (!projectId2 || !createdAt) {
261
+ throw new Error(`Invalid commit data received from API: ${JSON.stringify(item)}`);
262
+ }
263
+ return {
264
+ id: item.id,
265
+ projectId: projectId2,
266
+ message: item.message,
267
+ files: item.files,
268
+ createdAt
269
+ };
270
+ });
271
+ logger.info("api", `Found ${commits.length} pending commits`);
272
+ return commits;
273
+ }
274
+ /**
275
+ * Mark a commit as successfully executed
276
+ * @param commitId - Commit ID
277
+ * @param sha - Git commit SHA
278
+ * @returns Updated commit data
279
+ */
280
+ async markCommitExecuted(commitId, sha) {
281
+ logger.info("api", `Marking commit ${commitId} as executed (SHA: ${sha})`);
282
+ return this.withRetry(async () => {
283
+ const response = await this.request(
284
+ `/api/agent/commits/${commitId}/executed`,
285
+ {
286
+ method: "POST",
287
+ body: JSON.stringify({ sha })
288
+ }
289
+ );
290
+ const data = response.data;
291
+ const projectId = data.project_id || data.projectId;
292
+ const createdAt = data.created_at || data.createdAt;
293
+ const executedAt = data.executed_at || data.executedAt;
294
+ if (!projectId || !createdAt) {
295
+ throw new Error(`Invalid commit data received from API: ${JSON.stringify(data)}`);
296
+ }
297
+ const commit = {
298
+ id: data.id,
299
+ projectId,
300
+ message: data.message,
301
+ sha: data.sha,
302
+ status: data.status,
303
+ createdAt,
304
+ executedAt,
305
+ error: data.error
306
+ };
307
+ logger.success("api", `Commit ${commitId} marked as executed`);
308
+ return commit;
309
+ }, "Mark commit executed");
310
+ }
311
+ /**
312
+ * Mark a commit as failed
313
+ * @param commitId - Commit ID
314
+ * @param error - Error message
315
+ */
316
+ async markCommitFailed(commitId, error) {
317
+ logger.error("api", `Marking commit ${commitId} as failed: ${error}`);
318
+ await this.withRetry(async () => {
319
+ await this.request(`/api/agent/commits/${commitId}/failed`, {
320
+ method: "POST",
321
+ body: JSON.stringify({ error })
322
+ });
323
+ }, "Mark commit failed");
324
+ }
325
+ /**
326
+ * Sync project repository information with the API
327
+ * @param projectId - Project ID
328
+ * @param data - Repository information (path, remote URL, branches)
329
+ */
330
+ async syncProject(projectId, data) {
331
+ logger.info("api", `Syncing project ${projectId}`);
332
+ await this.withRetry(async () => {
333
+ const payload = {
334
+ repo_path: data.repoPath,
335
+ remote_url: data.remoteUrl,
336
+ default_branch: data.defaultBranch,
337
+ current_branch: data.currentBranch
338
+ };
339
+ await this.request(`/api/agent/projects/${projectId}/sync`, {
340
+ method: "POST",
341
+ body: JSON.stringify(payload)
342
+ });
343
+ logger.success("api", `Project ${projectId} synced`);
344
+ }, "Sync project");
345
+ }
346
+ /**
347
+ * Test API connectivity
348
+ */
349
+ async ping() {
350
+ try {
351
+ await this.request("/api/agent/ping");
352
+ } catch (error) {
353
+ throw new Error(`Failed to connect to API: ${error.message}`);
354
+ }
355
+ }
356
+ async getLinkedProjects() {
357
+ logger.info("api", "Fetching linked projects");
358
+ const response = await this.request("/api/agent/projects");
359
+ const projects = response.data;
360
+ logger.info("api", `Found ${projects.length} linked projects`);
361
+ return projects;
362
+ }
363
+ async getCurrentUser() {
364
+ logger.info("api", "Fetching current user");
365
+ const user = await this.request("/api/user");
366
+ logger.info("api", `Fetched user: ${user.email}`);
367
+ return user;
368
+ }
369
+ async createProject(data) {
370
+ logger.info("api", `Creating project: ${data.name}`);
371
+ const response = await this.request("/api/agent/projects", {
372
+ method: "POST",
373
+ body: JSON.stringify(data)
374
+ });
375
+ const project = response.data;
376
+ logger.success("api", `Created project: ${project.name} (${project.id})`);
377
+ return project;
378
+ }
379
+ };
380
+ var apiService = new ApiServiceImpl();
381
+
382
+ export {
383
+ apiService
384
+ };
@@ -1,227 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  commitQueue,
4
+ websocketService
5
+ } from "../chunk-IJUJ6NEL.js";
6
+ import {
7
+ apiService
8
+ } from "../chunk-W4JGOGR7.js";
9
+ import {
4
10
  gitService,
5
11
  projectService,
6
12
  removePidFile,
7
13
  writePidFile
8
- } from "../chunk-56UZEICO.js";
14
+ } from "../chunk-FBQA4K5J.js";
9
15
  import {
10
- apiService,
11
16
  authService,
12
- config,
13
17
  logger
14
- } from "../chunk-5OKRSNU4.js";
18
+ } from "../chunk-RHMTZK2J.js";
15
19
 
16
20
  // src/daemon/runner.ts
17
21
  import "dotenv/config";
18
22
 
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
- commitPendingHandlers = [];
31
- suggestionCreatedHandlers = [];
32
- projectUpdatedHandlers = [];
33
- disconnectHandlers = [];
34
- agentDisconnectedHandlers = [];
35
- syncRequestedHandlers = [];
36
- async connect() {
37
- try {
38
- const token = await authService.getToken();
39
- if (!token) {
40
- throw new Error("No authentication token available");
41
- }
42
- const wsUrl = config.getWsUrl();
43
- const url = `${wsUrl}?token=${encodeURIComponent(token)}`;
44
- logger.info("websocket", `Connecting to ${wsUrl}...`);
45
- this.ws = new WebSocket(url);
46
- return new Promise((resolve, reject) => {
47
- if (!this.ws) {
48
- reject(new Error("WebSocket not initialized"));
49
- return;
50
- }
51
- this.ws.on("open", () => {
52
- logger.success("websocket", "WebSocket connected");
53
- this.reconnectAttempts = 0;
54
- this.isManualDisconnect = false;
55
- resolve();
56
- });
57
- this.ws.on("message", (data) => {
58
- this.handleMessage(data);
59
- });
60
- this.ws.on("close", () => {
61
- logger.warn("websocket", "WebSocket disconnected");
62
- this.handleDisconnect();
63
- });
64
- this.ws.on("error", (error) => {
65
- logger.error("websocket", "WebSocket error", error);
66
- reject(error);
67
- });
68
- });
69
- } catch (error) {
70
- logger.error("websocket", "Failed to connect", error);
71
- throw error;
72
- }
73
- }
74
- disconnect() {
75
- this.isManualDisconnect = true;
76
- if (this.reconnectTimer) {
77
- clearTimeout(this.reconnectTimer);
78
- this.reconnectTimer = null;
79
- }
80
- if (this.ws) {
81
- if (this.userId) {
82
- this.sendMessage({
83
- event: "pusher:unsubscribe",
84
- data: {
85
- channel: `private-user.${this.userId}`
86
- }
87
- });
88
- }
89
- this.ws.close();
90
- this.ws = null;
91
- logger.info("websocket", "WebSocket disconnected");
92
- }
93
- }
94
- isConnected() {
95
- return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
96
- }
97
- subscribeToUserChannel(userId) {
98
- this.userId = userId;
99
- if (!this.isConnected()) {
100
- logger.warn("websocket", "Cannot subscribe: not connected");
101
- return;
102
- }
103
- const channel = `private-user.${userId}`;
104
- logger.info("websocket", `Subscribing to channel: ${channel}`);
105
- this.sendMessage({
106
- event: "pusher:subscribe",
107
- data: {
108
- channel
109
- }
110
- });
111
- }
112
- onCommitApproved(handler) {
113
- this.commitApprovedHandlers.push(handler);
114
- }
115
- onCommitPending(handler) {
116
- this.commitPendingHandlers.push(handler);
117
- }
118
- onSuggestionCreated(handler) {
119
- this.suggestionCreatedHandlers.push(handler);
120
- }
121
- onProjectUpdated(handler) {
122
- this.projectUpdatedHandlers.push(handler);
123
- }
124
- onDisconnect(handler) {
125
- this.disconnectHandlers.push(handler);
126
- }
127
- onAgentDisconnected(handler) {
128
- this.agentDisconnectedHandlers.push(handler);
129
- }
130
- onSyncRequested(handler) {
131
- this.syncRequestedHandlers.push(handler);
132
- }
133
- sendMessage(message) {
134
- if (!this.isConnected()) {
135
- logger.warn("websocket", "Cannot send message: not connected");
136
- return;
137
- }
138
- this.ws.send(JSON.stringify(message));
139
- }
140
- handleMessage(data) {
141
- try {
142
- const message = JSON.parse(data.toString());
143
- logger.debug("websocket", `Received message: ${message.event}`);
144
- if (message.event === "pusher:connection_established") {
145
- logger.success("websocket", "Connection established");
146
- return;
147
- }
148
- if (message.event === "pusher_internal:subscription_succeeded") {
149
- logger.success("websocket", `Subscribed to channel: ${message.channel}`);
150
- return;
151
- }
152
- if (message.event === "commit.approved") {
153
- const { commit, project } = message.data;
154
- logger.info("websocket", `Commit approved: ${commit.id}`);
155
- this.commitApprovedHandlers.forEach((handler) => handler(commit, project));
156
- return;
157
- }
158
- if (message.event === "commit.pending") {
159
- const { pendingCommit } = message.data;
160
- logger.info("websocket", `Commit pending: ${pendingCommit.id}`);
161
- this.commitPendingHandlers.forEach((handler) => handler(pendingCommit));
162
- return;
163
- }
164
- if (message.event === "suggestion.created") {
165
- const { suggestion } = message.data;
166
- logger.info("websocket", `Suggestion created: ${suggestion.id}`);
167
- this.suggestionCreatedHandlers.forEach((handler) => handler(suggestion));
168
- return;
169
- }
170
- if (message.event === "project.updated") {
171
- const { project } = message.data;
172
- logger.info("websocket", `Project updated: ${project.id}`);
173
- this.projectUpdatedHandlers.forEach((handler) => handler(project));
174
- return;
175
- }
176
- if (message.event === "sync.requested") {
177
- const { projectId } = message.data;
178
- logger.info("websocket", `Sync requested for project: ${projectId}`);
179
- this.syncRequestedHandlers.forEach((handler) => handler(projectId));
180
- return;
181
- }
182
- if (message.event === "agent.disconnected") {
183
- const reason = message.data?.reason || "Server requested disconnect";
184
- logger.warn("websocket", `Agent disconnected by server: ${reason}`);
185
- this.agentDisconnectedHandlers.forEach((handler) => handler(reason));
186
- return;
187
- }
188
- logger.debug("websocket", `Unhandled event: ${message.event}`);
189
- } catch (error) {
190
- logger.error("websocket", "Failed to parse message", error);
191
- }
192
- }
193
- handleDisconnect() {
194
- this.ws = null;
195
- this.disconnectHandlers.forEach((handler) => handler());
196
- if (this.isManualDisconnect) {
197
- return;
198
- }
199
- if (this.reconnectAttempts < this.maxReconnectAttempts) {
200
- const delay = this.getReconnectDelay();
201
- this.reconnectAttempts++;
202
- logger.info("websocket", `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
203
- this.reconnectTimer = setTimeout(async () => {
204
- try {
205
- await this.connect();
206
- if (this.userId) {
207
- this.subscribeToUserChannel(this.userId);
208
- }
209
- } catch (error) {
210
- logger.error("websocket", "Reconnection failed", error);
211
- }
212
- }, delay);
213
- } else {
214
- logger.error("websocket", "Max reconnection attempts reached");
215
- }
216
- }
217
- getReconnectDelay() {
218
- const delays = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
219
- const index = Math.min(this.reconnectAttempts, delays.length - 1);
220
- return delays[index];
221
- }
222
- };
223
- var websocketService = new WebSocketServiceImpl();
224
-
225
23
  // src/daemon/watcher.ts
226
24
  import fs from "fs";
227
25
  var FileWatcher = class {