@gowelle/stint-agent 1.2.20 → 1.2.22
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 +8 -23
- package/dist/{StatusDashboard-UXWLHFTD.js → StatusDashboard-LYZL3IQH.js} +2 -2
- package/dist/api-U7WJAW3E.js +7 -0
- package/dist/{chunk-GOS22L3R.js → chunk-7H6J2KCA.js} +2 -4
- package/dist/{chunk-L7PFNRLV.js → chunk-KMYPMLEH.js} +8 -4
- package/dist/{chunk-ABDHDLEJ.js → chunk-TQHWJWBZ.js} +68 -1
- package/dist/chunk-ZNBNEPPK.js +648 -0
- package/dist/daemon/runner.js +6 -41
- package/dist/index.js +2093 -8
- package/package.json +6 -1
- package/dist/api-RQW35VGT.js +0 -7
- package/dist/chunk-BH4OTBYH.js +0 -2618
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import {
|
|
2
|
+
apiService
|
|
3
|
+
} from "./chunk-KMYPMLEH.js";
|
|
4
|
+
import {
|
|
5
|
+
gitService,
|
|
6
|
+
projectService
|
|
7
|
+
} from "./chunk-TQHWJWBZ.js";
|
|
8
|
+
import {
|
|
9
|
+
authService,
|
|
10
|
+
config,
|
|
11
|
+
logger
|
|
12
|
+
} from "./chunk-7H6J2KCA.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/services/hook.ts
|
|
47
|
+
import { exec } from "child_process";
|
|
48
|
+
var HookService = class {
|
|
49
|
+
/**
|
|
50
|
+
* Execute a list of hooks sequentially
|
|
51
|
+
* @param hooks List of hooks to execute
|
|
52
|
+
* @param cwd Current working directory (project root)
|
|
53
|
+
* @returns List of results
|
|
54
|
+
*/
|
|
55
|
+
async executeHooks(hooks, cwd) {
|
|
56
|
+
const results = [];
|
|
57
|
+
const enabledHooks = hooks.filter((h) => h.enabled);
|
|
58
|
+
if (enabledHooks.length === 0) {
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
logger.info("hooks", `Running ${enabledHooks.length} pre-commit hooks...`);
|
|
62
|
+
for (const hook of enabledHooks) {
|
|
63
|
+
try {
|
|
64
|
+
const result = await this.executeSingleHook(hook, cwd);
|
|
65
|
+
results.push(result);
|
|
66
|
+
if (!result.success && hook.blockOnFailure) {
|
|
67
|
+
logger.warn("hooks", `Hook "${hook.name}" failed and is blocking. Stopping execution.`);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
72
|
+
logger.error("hooks", `Unexpected error running hook "${hook.name}"`, error);
|
|
73
|
+
results.push({
|
|
74
|
+
hookId: hook.id,
|
|
75
|
+
hookName: hook.name,
|
|
76
|
+
success: false,
|
|
77
|
+
output: "",
|
|
78
|
+
error: `Internal error: ${errorMessage}`,
|
|
79
|
+
duration: 0
|
|
80
|
+
});
|
|
81
|
+
if (hook.blockOnFailure) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Execute a single hook with timeout
|
|
90
|
+
*/
|
|
91
|
+
executeSingleHook(hook, cwd) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const startTime = Date.now();
|
|
94
|
+
logger.debug("hooks", `Executing hook: ${hook.name} (${hook.command})`);
|
|
95
|
+
const timeout = hook.timeout || 3e4;
|
|
96
|
+
exec(hook.command, {
|
|
97
|
+
cwd,
|
|
98
|
+
timeout,
|
|
99
|
+
maxBuffer: 10 * 1024 * 1024
|
|
100
|
+
// 10MB output buffer
|
|
101
|
+
}, (error, stdout, stderr) => {
|
|
102
|
+
const duration = Date.now() - startTime;
|
|
103
|
+
const combinedOutput = (stdout + "\n" + stderr).trim();
|
|
104
|
+
if (error) {
|
|
105
|
+
if (error.signal === "SIGTERM" && duration >= timeout) {
|
|
106
|
+
resolve({
|
|
107
|
+
hookId: hook.id,
|
|
108
|
+
hookName: hook.name,
|
|
109
|
+
success: false,
|
|
110
|
+
output: combinedOutput,
|
|
111
|
+
error: `Timed out after ${timeout}ms`,
|
|
112
|
+
duration
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
resolve({
|
|
117
|
+
hookId: hook.id,
|
|
118
|
+
hookName: hook.name,
|
|
119
|
+
success: false,
|
|
120
|
+
output: combinedOutput,
|
|
121
|
+
error: error.message,
|
|
122
|
+
duration
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
resolve({
|
|
126
|
+
hookId: hook.id,
|
|
127
|
+
hookName: hook.name,
|
|
128
|
+
success: true,
|
|
129
|
+
output: combinedOutput,
|
|
130
|
+
duration
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
var hookService = new HookService();
|
|
138
|
+
|
|
139
|
+
// src/daemon/queue.ts
|
|
140
|
+
var CommitQueueProcessor = class {
|
|
141
|
+
queue = [];
|
|
142
|
+
isProcessing = false;
|
|
143
|
+
/**
|
|
144
|
+
* Add commit to processing queue
|
|
145
|
+
*/
|
|
146
|
+
addToQueue(commit, project) {
|
|
147
|
+
this.queue.push({ commit, project });
|
|
148
|
+
logger.info("queue", `Added commit ${commit.id} to queue (position: ${this.queue.length})`);
|
|
149
|
+
if (!this.isProcessing) {
|
|
150
|
+
this.processQueue();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Process commits sequentially
|
|
155
|
+
*/
|
|
156
|
+
async processQueue() {
|
|
157
|
+
if (this.isProcessing) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.isProcessing = true;
|
|
161
|
+
while (this.queue.length > 0) {
|
|
162
|
+
const item = this.queue.shift();
|
|
163
|
+
if (!item) break;
|
|
164
|
+
try {
|
|
165
|
+
await this.executeCommit(item.commit, item.project);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
logger.error("queue", `Failed to execute commit ${item.commit.id}`, error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this.isProcessing = false;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Execute a single commit
|
|
174
|
+
*/
|
|
175
|
+
async executeCommit(commit, project, onProgress, options) {
|
|
176
|
+
logger.info("queue", `Processing commit: ${commit.id} - ${commit.message}`);
|
|
177
|
+
try {
|
|
178
|
+
onProgress?.("Finding project directory...");
|
|
179
|
+
const projectPath = this.findProjectPath(project.id);
|
|
180
|
+
if (!projectPath) {
|
|
181
|
+
throw new Error(`Project ${project.id} is not linked to any local directory`);
|
|
182
|
+
}
|
|
183
|
+
logger.info("queue", `Executing in directory: ${projectPath}`);
|
|
184
|
+
onProgress?.("Validating repository...");
|
|
185
|
+
const isRepo = await gitService.isRepo(projectPath);
|
|
186
|
+
if (!isRepo) {
|
|
187
|
+
throw new Error(`Directory ${projectPath} is not a git repository`);
|
|
188
|
+
}
|
|
189
|
+
if (project.commitSettings?.hooks?.length) {
|
|
190
|
+
onProgress?.("Running pre-commit hooks...");
|
|
191
|
+
logger.info("queue", "Executing pre-commit hooks...");
|
|
192
|
+
const hookResults = await hookService.executeHooks(project.commitSettings.hooks, projectPath);
|
|
193
|
+
const failedBlockingHook = hookResults.find(
|
|
194
|
+
(r) => !r.success && project.commitSettings?.hooks.find((h) => h.id === r.hookId)?.blockOnFailure
|
|
195
|
+
);
|
|
196
|
+
if (failedBlockingHook) {
|
|
197
|
+
const errorMsg = failedBlockingHook.error || "Unknown error";
|
|
198
|
+
logger.error("queue", `Blocking hook "${failedBlockingHook.hookName}" failed: ${errorMsg}`);
|
|
199
|
+
if (failedBlockingHook.output) {
|
|
200
|
+
logger.warn("queue", `Hook output:
|
|
201
|
+
${failedBlockingHook.output}`);
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Pre-commit hook "${failedBlockingHook.hookName}" failed: ${errorMsg}`);
|
|
204
|
+
}
|
|
205
|
+
const failedNonBlockingHooks = hookResults.filter((r) => !r.success);
|
|
206
|
+
if (failedNonBlockingHooks.length > 0) {
|
|
207
|
+
logger.warn("queue", `${failedNonBlockingHooks.length} hooks failed but were not blocking: ${failedNonBlockingHooks.map((h) => h.hookName).join(", ")}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
onProgress?.("Checking repository status...");
|
|
211
|
+
let status = await gitService.getStatus(projectPath);
|
|
212
|
+
if (commit.files && commit.files.length > 0) {
|
|
213
|
+
onProgress?.(`Staging ${commit.files.length} specified files...`);
|
|
214
|
+
await gitService.stageFiles(projectPath, commit.files);
|
|
215
|
+
status = await gitService.getStatus(projectPath);
|
|
216
|
+
logger.info("queue", `Auto-staged files: ${commit.files.join(", ")}`);
|
|
217
|
+
} else if (status.staged.length === 0) {
|
|
218
|
+
const hasChanges = status.unstaged.length > 0 || status.untracked.length > 0;
|
|
219
|
+
if (hasChanges) {
|
|
220
|
+
onProgress?.("Staging all changes...");
|
|
221
|
+
await gitService.stageAll(projectPath);
|
|
222
|
+
status = await gitService.getStatus(projectPath);
|
|
223
|
+
logger.info("queue", `Auto-staged all changes`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (status.staged.length === 0) {
|
|
227
|
+
throw new Error("No changes to commit. The working directory is clean.");
|
|
228
|
+
}
|
|
229
|
+
logger.info("queue", `Committing ${status.staged.length} staged files.`);
|
|
230
|
+
onProgress?.("Creating commit...");
|
|
231
|
+
logger.info("queue", `Creating commit with message: "${commit.message}"`);
|
|
232
|
+
const sha = await gitService.commit(projectPath, commit.message);
|
|
233
|
+
logger.success("queue", `Commit created successfully: ${sha}`);
|
|
234
|
+
let pushed = false;
|
|
235
|
+
let pushError;
|
|
236
|
+
const shouldPush = options?.push !== false;
|
|
237
|
+
if (shouldPush) {
|
|
238
|
+
try {
|
|
239
|
+
onProgress?.("Pushing to remote...");
|
|
240
|
+
await gitService.push(projectPath);
|
|
241
|
+
pushed = true;
|
|
242
|
+
logger.success("queue", `Pushed commit ${sha} to remote`);
|
|
243
|
+
notify({
|
|
244
|
+
title: `Commit Pushed - ${project.name}`,
|
|
245
|
+
message: `Commit "${commit.message}" successfully pushed.`
|
|
246
|
+
});
|
|
247
|
+
} catch (error) {
|
|
248
|
+
pushError = error.message;
|
|
249
|
+
const isConflict = pushError.includes("rejected") || pushError.includes("non-fast-forward") || pushError.includes("failed to push") || pushError.includes("Updates were rejected");
|
|
250
|
+
if (isConflict) {
|
|
251
|
+
logger.warn("queue", `Push failed due to remote conflict: ${pushError}`);
|
|
252
|
+
notify({
|
|
253
|
+
title: `Push Conflict - ${project.name}`,
|
|
254
|
+
message: `Commit "${commit.message}" created but push failed.
|
|
255
|
+
Run "git pull --rebase" to resolve.`
|
|
256
|
+
});
|
|
257
|
+
} else {
|
|
258
|
+
logger.error("queue", `Push failed: ${pushError}`);
|
|
259
|
+
notify({
|
|
260
|
+
title: `Push Failed - ${project.name}`,
|
|
261
|
+
message: `Commit created but push failed: ${pushError}`
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
onProgress?.("Reporting to server...");
|
|
267
|
+
await this.reportSuccess(commit.id, sha, pushed, pushError);
|
|
268
|
+
if (!pushed && !pushError) {
|
|
269
|
+
notify({
|
|
270
|
+
title: `Commit Created - ${project.name}`,
|
|
271
|
+
message: `Commit "${commit.message}" created locally.`
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return sha;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const msg = error.message;
|
|
277
|
+
logger.error("queue", `Commit execution failed: ${msg}`);
|
|
278
|
+
notify({
|
|
279
|
+
title: `Commit Failed - ${project.name}`,
|
|
280
|
+
message: `Failed to execute commit "${commit.message}": ${msg}`
|
|
281
|
+
});
|
|
282
|
+
await this.reportFailure(commit.id, msg);
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Report successful execution to API
|
|
288
|
+
* @param commitId - Commit ID
|
|
289
|
+
* @param sha - Git commit SHA
|
|
290
|
+
* @param pushed - Whether the commit was pushed to remote
|
|
291
|
+
* @param pushError - Error message if push failed
|
|
292
|
+
*/
|
|
293
|
+
async reportSuccess(commitId, sha, pushed = true, pushError) {
|
|
294
|
+
try {
|
|
295
|
+
await apiService.markCommitExecuted(commitId, sha, pushed, pushError);
|
|
296
|
+
const status = pushed ? "executed" : "committed (push failed)";
|
|
297
|
+
logger.success("queue", `Reported commit ${status} to API: ${commitId} -> ${sha}`);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
logger.error("queue", "Failed to report commit success to API", error);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Report failed execution to API
|
|
304
|
+
*/
|
|
305
|
+
async reportFailure(commitId, error) {
|
|
306
|
+
try {
|
|
307
|
+
await apiService.markCommitFailed(commitId, error);
|
|
308
|
+
logger.info("queue", `Reported commit failure to API: ${commitId}`);
|
|
309
|
+
} catch (apiError) {
|
|
310
|
+
logger.error("queue", "Failed to report commit failure to API", apiError);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Find local path for a project ID
|
|
315
|
+
*/
|
|
316
|
+
findProjectPath(projectId) {
|
|
317
|
+
const allProjects = projectService.getAllLinkedProjects();
|
|
318
|
+
for (const [path3, linkedProject] of Object.entries(allProjects)) {
|
|
319
|
+
if (linkedProject.projectId === projectId) {
|
|
320
|
+
return path3;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Check if queue is currently processing
|
|
327
|
+
*/
|
|
328
|
+
isCurrentlyProcessing() {
|
|
329
|
+
return this.isProcessing;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Get queue length
|
|
333
|
+
*/
|
|
334
|
+
getQueueLength() {
|
|
335
|
+
return this.queue.length;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
var commitQueue = new CommitQueueProcessor();
|
|
339
|
+
|
|
340
|
+
// src/services/websocket.ts
|
|
341
|
+
import Echo from "laravel-echo";
|
|
342
|
+
import Pusher from "pusher-js";
|
|
343
|
+
import fs from "fs";
|
|
344
|
+
import os from "os";
|
|
345
|
+
import path2 from "path";
|
|
346
|
+
var STATUS_FILE_PATH = path2.join(os.homedir(), ".config", "stint", "daemon.status.json");
|
|
347
|
+
function writeStatus(update) {
|
|
348
|
+
try {
|
|
349
|
+
const dir = path2.dirname(STATUS_FILE_PATH);
|
|
350
|
+
if (!fs.existsSync(dir)) {
|
|
351
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
352
|
+
}
|
|
353
|
+
let status = { websocket: { connected: false } };
|
|
354
|
+
if (fs.existsSync(STATUS_FILE_PATH)) {
|
|
355
|
+
try {
|
|
356
|
+
status = JSON.parse(fs.readFileSync(STATUS_FILE_PATH, "utf8"));
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
status.websocket = { ...status.websocket, ...update };
|
|
361
|
+
fs.writeFileSync(STATUS_FILE_PATH, JSON.stringify(status, null, 2));
|
|
362
|
+
} catch {
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
var WebSocketServiceImpl = class {
|
|
366
|
+
echo = null;
|
|
367
|
+
userId = null;
|
|
368
|
+
reconnectAttempts = 0;
|
|
369
|
+
maxReconnectAttempts = 10;
|
|
370
|
+
reconnectTimer = null;
|
|
371
|
+
isManualDisconnect = false;
|
|
372
|
+
// Event handlers
|
|
373
|
+
commitApprovedHandlers = [];
|
|
374
|
+
commitPendingHandlers = [];
|
|
375
|
+
suggestionCreatedHandlers = [];
|
|
376
|
+
projectUpdatedHandlers = [];
|
|
377
|
+
disconnectHandlers = [];
|
|
378
|
+
agentDisconnectedHandlers = [];
|
|
379
|
+
syncRequestedHandlers = [];
|
|
380
|
+
/**
|
|
381
|
+
* Connect to the WebSocket server using Laravel Echo
|
|
382
|
+
* @throws Error if connection fails or no auth token available
|
|
383
|
+
*/
|
|
384
|
+
async connect() {
|
|
385
|
+
try {
|
|
386
|
+
const token = await authService.getToken();
|
|
387
|
+
if (!token) {
|
|
388
|
+
throw new Error("No authentication token available");
|
|
389
|
+
}
|
|
390
|
+
const reverbAppKey = config.getReverbAppKey();
|
|
391
|
+
if (!reverbAppKey) {
|
|
392
|
+
throw new Error("Reverb app key not configured");
|
|
393
|
+
}
|
|
394
|
+
const apiUrl = config.getApiUrl();
|
|
395
|
+
const environment = config.getEnvironment();
|
|
396
|
+
let wsHost;
|
|
397
|
+
let wsPort;
|
|
398
|
+
let forceTLS;
|
|
399
|
+
if (environment === "development") {
|
|
400
|
+
wsHost = "localhost";
|
|
401
|
+
wsPort = 8080;
|
|
402
|
+
forceTLS = false;
|
|
403
|
+
} else {
|
|
404
|
+
wsHost = "stint.codes";
|
|
405
|
+
wsPort = 443;
|
|
406
|
+
forceTLS = true;
|
|
407
|
+
}
|
|
408
|
+
logger.info("websocket", `Connecting to ${wsHost}:${wsPort} with key ${reverbAppKey}...`);
|
|
409
|
+
const pusherClient = new Pusher(reverbAppKey, {
|
|
410
|
+
wsHost,
|
|
411
|
+
wsPort,
|
|
412
|
+
forceTLS,
|
|
413
|
+
enabledTransports: ["ws", "wss"],
|
|
414
|
+
disableStats: true,
|
|
415
|
+
cluster: "",
|
|
416
|
+
// Required but unused for Reverb
|
|
417
|
+
authorizer: (channel) => ({
|
|
418
|
+
authorize: async (socketId, callback) => {
|
|
419
|
+
try {
|
|
420
|
+
const response = await fetch(`${apiUrl}/api/broadcasting/auth`, {
|
|
421
|
+
method: "POST",
|
|
422
|
+
headers: {
|
|
423
|
+
"Authorization": `Bearer ${token}`,
|
|
424
|
+
"Accept": "application/json",
|
|
425
|
+
"Content-Type": "application/json"
|
|
426
|
+
},
|
|
427
|
+
body: JSON.stringify({
|
|
428
|
+
socket_id: socketId,
|
|
429
|
+
channel_name: channel.name
|
|
430
|
+
})
|
|
431
|
+
});
|
|
432
|
+
if (!response.ok) {
|
|
433
|
+
const errorText = await response.text();
|
|
434
|
+
logger.error("websocket", `Auth failed (${response.status}): ${errorText}`);
|
|
435
|
+
callback(new Error(`Auth failed: ${response.status}`));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const data = await response.json();
|
|
439
|
+
callback(null, data);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
logger.error("websocket", "Channel auth error", error);
|
|
442
|
+
callback(error);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
});
|
|
447
|
+
this.echo = new Echo({
|
|
448
|
+
broadcaster: "reverb",
|
|
449
|
+
key: reverbAppKey,
|
|
450
|
+
wsHost,
|
|
451
|
+
wsPort,
|
|
452
|
+
forceTLS,
|
|
453
|
+
disableStats: true,
|
|
454
|
+
enabledTransports: ["ws", "wss"],
|
|
455
|
+
authEndpoint: `${apiUrl}/api/broadcasting/auth`,
|
|
456
|
+
auth: {
|
|
457
|
+
headers: {
|
|
458
|
+
Authorization: `Bearer ${token}`,
|
|
459
|
+
Accept: "application/json"
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
client: pusherClient
|
|
463
|
+
});
|
|
464
|
+
logger.info("websocket", "Echo instance created, setting up connection handlers...");
|
|
465
|
+
return new Promise((resolve, reject) => {
|
|
466
|
+
if (!this.echo) {
|
|
467
|
+
reject(new Error("Echo not initialized"));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const connectionTimeout = setTimeout(() => {
|
|
471
|
+
const state = this.echo?.connector.pusher.connection.state || "unknown";
|
|
472
|
+
logger.error("websocket", `Connection timeout after 15s (state: ${state})`);
|
|
473
|
+
reject(new Error(`Connection timeout - stuck in state: ${state}`));
|
|
474
|
+
}, 15e3);
|
|
475
|
+
this.echo.connector.pusher.connection.bind("state_change", (states) => {
|
|
476
|
+
logger.info("websocket", `Connection state: ${states.previous} -> ${states.current}`);
|
|
477
|
+
});
|
|
478
|
+
this.echo.connector.pusher.connection.bind("connected", () => {
|
|
479
|
+
clearTimeout(connectionTimeout);
|
|
480
|
+
logger.success("websocket", "\u2705 Connected to Broadcaster via Sanctum");
|
|
481
|
+
writeStatus({ connected: true });
|
|
482
|
+
this.reconnectAttempts = 0;
|
|
483
|
+
this.isManualDisconnect = false;
|
|
484
|
+
resolve();
|
|
485
|
+
});
|
|
486
|
+
this.echo.connector.pusher.connection.bind("error", (error) => {
|
|
487
|
+
clearTimeout(connectionTimeout);
|
|
488
|
+
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error) || "Unknown connection error";
|
|
489
|
+
logger.error("websocket", `WebSocket error: ${errorMessage}`);
|
|
490
|
+
reject(new Error(errorMessage));
|
|
491
|
+
});
|
|
492
|
+
this.echo.connector.pusher.connection.bind("disconnected", () => {
|
|
493
|
+
logger.warn("websocket", "WebSocket disconnected");
|
|
494
|
+
writeStatus({ connected: false });
|
|
495
|
+
this.handleDisconnect();
|
|
496
|
+
});
|
|
497
|
+
this.echo.connector.pusher.connection.bind("failed", () => {
|
|
498
|
+
clearTimeout(connectionTimeout);
|
|
499
|
+
logger.error("websocket", "WebSocket connection failed");
|
|
500
|
+
reject(new Error("WebSocket connection failed"));
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
} catch (error) {
|
|
504
|
+
logger.error("websocket", "Failed to connect", error);
|
|
505
|
+
throw error;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Disconnect from the WebSocket server
|
|
510
|
+
* Prevents automatic reconnection
|
|
511
|
+
*/
|
|
512
|
+
disconnect() {
|
|
513
|
+
this.isManualDisconnect = true;
|
|
514
|
+
if (this.reconnectTimer) {
|
|
515
|
+
clearTimeout(this.reconnectTimer);
|
|
516
|
+
this.reconnectTimer = null;
|
|
517
|
+
}
|
|
518
|
+
if (this.echo) {
|
|
519
|
+
if (this.userId) {
|
|
520
|
+
this.echo.leave(`user.${this.userId}`);
|
|
521
|
+
}
|
|
522
|
+
this.echo.disconnect();
|
|
523
|
+
this.echo = null;
|
|
524
|
+
logger.info("websocket", "WebSocket disconnected");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Check if WebSocket is currently connected
|
|
529
|
+
* @returns True if connected and ready
|
|
530
|
+
*/
|
|
531
|
+
isConnected() {
|
|
532
|
+
return this.echo !== null && this.echo.connector.pusher.connection.state === "connected";
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Subscribe to user-specific private channel for real-time updates
|
|
536
|
+
* @param userId - User ID to subscribe to
|
|
537
|
+
*/
|
|
538
|
+
async subscribeToUserChannel(userId) {
|
|
539
|
+
this.userId = userId;
|
|
540
|
+
if (!this.echo) {
|
|
541
|
+
logger.warn("websocket", "Cannot subscribe: not connected");
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (!this.isConnected()) {
|
|
545
|
+
logger.warn("websocket", "Cannot subscribe: not connected");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const channel = `user.${userId}`;
|
|
549
|
+
logger.info("websocket", `Subscribing to private channel: ${channel}`);
|
|
550
|
+
const privateChannel = this.echo.private(channel);
|
|
551
|
+
writeStatus({ channel });
|
|
552
|
+
privateChannel.listen(".commit.approved", (data) => {
|
|
553
|
+
logger.info("websocket", `Commit approved: ${data.pendingCommit.id}`);
|
|
554
|
+
writeStatus({ lastEvent: "commit.approved", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
|
|
555
|
+
this.commitApprovedHandlers.forEach(
|
|
556
|
+
(handler) => handler(data.pendingCommit, data.pendingCommit.project)
|
|
557
|
+
);
|
|
558
|
+
}).listen(".commit.pending", (data) => {
|
|
559
|
+
logger.info("websocket", `Commit pending: ${data.pendingCommit.id}`);
|
|
560
|
+
writeStatus({ lastEvent: "commit.pending", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
|
|
561
|
+
this.commitPendingHandlers.forEach((handler) => handler(data.pendingCommit));
|
|
562
|
+
}).listen(".suggestion.created", (data) => {
|
|
563
|
+
logger.info("websocket", `Suggestion created: ${data.suggestion.id}`);
|
|
564
|
+
writeStatus({ lastEvent: "suggestion.created", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
|
|
565
|
+
this.suggestionCreatedHandlers.forEach((handler) => handler(data.suggestion));
|
|
566
|
+
}).listen(".project.updated", (data) => {
|
|
567
|
+
logger.info("websocket", `Project updated: ${data.project.id}`);
|
|
568
|
+
writeStatus({ lastEvent: "project.updated", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
|
|
569
|
+
this.projectUpdatedHandlers.forEach((handler) => handler(data.project));
|
|
570
|
+
}).listen(".sync.requested", (data) => {
|
|
571
|
+
logger.info("websocket", `Sync requested for project: ${data.project.id}`);
|
|
572
|
+
writeStatus({ lastEvent: "sync.requested", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
|
|
573
|
+
this.syncRequestedHandlers.forEach((handler) => handler(data.project.id));
|
|
574
|
+
}).listen(".agent.disconnected", (data) => {
|
|
575
|
+
const reason = data.reason ?? "Server requested disconnect";
|
|
576
|
+
logger.warn("websocket", `Agent disconnected by server: ${reason}`);
|
|
577
|
+
writeStatus({ lastEvent: "agent.disconnected", lastEventTime: (/* @__PURE__ */ new Date()).toISOString() });
|
|
578
|
+
this.agentDisconnectedHandlers.forEach((handler) => handler(reason));
|
|
579
|
+
});
|
|
580
|
+
logger.success("websocket", `Subscribed to private channel: ${channel}`);
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Register handler for commit approved events
|
|
584
|
+
* @param handler - Callback function
|
|
585
|
+
*/
|
|
586
|
+
onCommitApproved(handler) {
|
|
587
|
+
this.commitApprovedHandlers.push(handler);
|
|
588
|
+
}
|
|
589
|
+
onCommitPending(handler) {
|
|
590
|
+
this.commitPendingHandlers.push(handler);
|
|
591
|
+
}
|
|
592
|
+
onSuggestionCreated(handler) {
|
|
593
|
+
this.suggestionCreatedHandlers.push(handler);
|
|
594
|
+
}
|
|
595
|
+
onProjectUpdated(handler) {
|
|
596
|
+
this.projectUpdatedHandlers.push(handler);
|
|
597
|
+
}
|
|
598
|
+
onDisconnect(handler) {
|
|
599
|
+
this.disconnectHandlers.push(handler);
|
|
600
|
+
}
|
|
601
|
+
onAgentDisconnected(handler) {
|
|
602
|
+
this.agentDisconnectedHandlers.push(handler);
|
|
603
|
+
}
|
|
604
|
+
onSyncRequested(handler) {
|
|
605
|
+
this.syncRequestedHandlers.push(handler);
|
|
606
|
+
}
|
|
607
|
+
handleDisconnect() {
|
|
608
|
+
this.disconnectHandlers.forEach((handler) => handler());
|
|
609
|
+
if (this.isManualDisconnect) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
613
|
+
const delay = this.getReconnectDelay();
|
|
614
|
+
this.reconnectAttempts++;
|
|
615
|
+
logger.info("websocket", `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
616
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
617
|
+
try {
|
|
618
|
+
await this.connect();
|
|
619
|
+
if (this.userId) {
|
|
620
|
+
await this.subscribeToUserChannel(this.userId);
|
|
621
|
+
}
|
|
622
|
+
} catch (error) {
|
|
623
|
+
logger.error("websocket", "Reconnection failed", error);
|
|
624
|
+
}
|
|
625
|
+
}, delay);
|
|
626
|
+
} else {
|
|
627
|
+
logger.error("websocket", "Max reconnection attempts reached");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Get reconnect delay with exponential backoff and jitter
|
|
632
|
+
* Jitter prevents thundering herd problem when many clients reconnect simultaneously
|
|
633
|
+
*/
|
|
634
|
+
getReconnectDelay() {
|
|
635
|
+
const delays = [1e3, 2e3, 4e3, 8e3, 16e3, 3e4];
|
|
636
|
+
const index = Math.min(this.reconnectAttempts, delays.length - 1);
|
|
637
|
+
const baseDelay = delays[index];
|
|
638
|
+
const jitter = baseDelay * (Math.random() * 0.3);
|
|
639
|
+
return Math.floor(baseDelay + jitter);
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
var websocketService = new WebSocketServiceImpl();
|
|
643
|
+
|
|
644
|
+
export {
|
|
645
|
+
notify,
|
|
646
|
+
commitQueue,
|
|
647
|
+
websocketService
|
|
648
|
+
};
|