@chrysb/alphaclaw 0.2.2 → 0.3.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.
Files changed (65) hide show
  1. package/bin/alphaclaw.js +79 -0
  2. package/lib/public/css/shell.css +57 -2
  3. package/lib/public/css/theme.css +184 -0
  4. package/lib/public/js/app.js +330 -89
  5. package/lib/public/js/components/action-button.js +92 -0
  6. package/lib/public/js/components/channels.js +16 -7
  7. package/lib/public/js/components/confirm-dialog.js +25 -19
  8. package/lib/public/js/components/credentials-modal.js +32 -23
  9. package/lib/public/js/components/device-pairings.js +15 -2
  10. package/lib/public/js/components/envars.js +22 -65
  11. package/lib/public/js/components/features.js +1 -1
  12. package/lib/public/js/components/gateway.js +139 -32
  13. package/lib/public/js/components/global-restart-banner.js +31 -0
  14. package/lib/public/js/components/google.js +9 -9
  15. package/lib/public/js/components/icons.js +19 -0
  16. package/lib/public/js/components/info-tooltip.js +18 -0
  17. package/lib/public/js/components/loading-spinner.js +32 -0
  18. package/lib/public/js/components/modal-shell.js +42 -0
  19. package/lib/public/js/components/models.js +34 -29
  20. package/lib/public/js/components/onboarding/welcome-form-step.js +45 -32
  21. package/lib/public/js/components/onboarding/welcome-pairing-step.js +2 -2
  22. package/lib/public/js/components/onboarding/welcome-setup-step.js +7 -24
  23. package/lib/public/js/components/page-header.js +13 -0
  24. package/lib/public/js/components/pairings.js +15 -2
  25. package/lib/public/js/components/providers.js +216 -142
  26. package/lib/public/js/components/scope-picker.js +1 -1
  27. package/lib/public/js/components/secret-input.js +1 -0
  28. package/lib/public/js/components/telegram-workspace.js +37 -49
  29. package/lib/public/js/components/toast.js +34 -5
  30. package/lib/public/js/components/toggle-switch.js +25 -0
  31. package/lib/public/js/components/update-action-button.js +13 -53
  32. package/lib/public/js/components/watchdog-tab.js +312 -0
  33. package/lib/public/js/components/webhooks.js +981 -0
  34. package/lib/public/js/components/welcome.js +2 -1
  35. package/lib/public/js/lib/api.js +102 -1
  36. package/lib/public/js/lib/model-config.js +0 -5
  37. package/lib/public/login.html +1 -0
  38. package/lib/public/setup.html +1 -0
  39. package/lib/server/alphaclaw-version.js +5 -3
  40. package/lib/server/constants.js +33 -0
  41. package/lib/server/discord-api.js +48 -0
  42. package/lib/server/gateway.js +64 -4
  43. package/lib/server/log-writer.js +102 -0
  44. package/lib/server/onboarding/github.js +21 -1
  45. package/lib/server/openclaw-version.js +2 -6
  46. package/lib/server/restart-required-state.js +86 -0
  47. package/lib/server/routes/auth.js +9 -4
  48. package/lib/server/routes/proxy.js +12 -14
  49. package/lib/server/routes/system.js +61 -15
  50. package/lib/server/routes/telegram.js +17 -48
  51. package/lib/server/routes/watchdog.js +68 -0
  52. package/lib/server/routes/webhooks.js +214 -0
  53. package/lib/server/telegram-api.js +11 -0
  54. package/lib/server/watchdog-db.js +148 -0
  55. package/lib/server/watchdog-notify.js +93 -0
  56. package/lib/server/watchdog.js +585 -0
  57. package/lib/server/webhook-middleware.js +195 -0
  58. package/lib/server/webhooks-db.js +265 -0
  59. package/lib/server/webhooks.js +238 -0
  60. package/lib/server.js +119 -4
  61. package/lib/setup/core-prompts/AGENTS.md +84 -0
  62. package/lib/setup/core-prompts/TOOLS.md +13 -0
  63. package/lib/setup/core-prompts/UI-DRY-OPPORTUNITIES.md +50 -0
  64. package/lib/setup/gitignore +2 -0
  65. package/package.json +2 -1
@@ -0,0 +1,585 @@
1
+ const {
2
+ kWatchdogCheckIntervalMs,
3
+ kWatchdogMaxRepairAttempts,
4
+ kWatchdogCrashLoopWindowMs,
5
+ kWatchdogCrashLoopThreshold,
6
+ } = require("./constants");
7
+
8
+ const kHealthStartupGraceMs = 30 * 1000;
9
+ const kBootstrapHealthCheckMs = 5 * 1000;
10
+ const kExpectedRestartWindowMs = 45 * 1000;
11
+
12
+ const isTruthy = (value) =>
13
+ ["1", "true", "yes", "on"].includes(String(value || "").trim().toLowerCase());
14
+
15
+ const parseHealthResult = (result) => {
16
+ if (!result?.ok) return { ok: false, reason: result?.stderr || "health command failed" };
17
+ const raw = String(result.stdout || "").trim();
18
+ if (!raw) return { ok: true };
19
+ try {
20
+ const parsed = JSON.parse(raw);
21
+ if (parsed?.ok === false || parsed?.status === "unhealthy") {
22
+ return { ok: false, reason: parsed?.error || "gateway unhealthy" };
23
+ }
24
+ return { ok: true, details: parsed };
25
+ } catch {
26
+ const firstBrace = raw.indexOf("{");
27
+ const lastBrace = raw.lastIndexOf("}");
28
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
29
+ try {
30
+ const parsed = JSON.parse(raw.slice(firstBrace, lastBrace + 1));
31
+ if (parsed?.ok === false || parsed?.status === "unhealthy") {
32
+ return { ok: false, reason: parsed?.error || "gateway unhealthy" };
33
+ }
34
+ return { ok: true, details: parsed };
35
+ } catch {}
36
+ }
37
+ }
38
+ return { ok: true };
39
+ };
40
+
41
+ const createWatchdog = ({
42
+ clawCmd,
43
+ launchGatewayProcess,
44
+ insertWatchdogEvent,
45
+ notifier,
46
+ readEnvFile,
47
+ writeEnvFile,
48
+ reloadEnv,
49
+ resolveSetupUrl,
50
+ }) => {
51
+ const state = {
52
+ lifecycle: "stopped",
53
+ health: "unknown",
54
+ uptimeStartedAt: null,
55
+ lastHealthCheckAt: null,
56
+ repairAttempts: 0,
57
+ crashTimestamps: [],
58
+ autoRepair: isTruthy(process.env.WATCHDOG_AUTO_REPAIR),
59
+ notificationsDisabled: isTruthy(process.env.WATCHDOG_NOTIFICATIONS_DISABLED),
60
+ operationInProgress: false,
61
+ gatewayStartedAt: null,
62
+ crashRecoveryActive: false,
63
+ expectedRestartInProgress: false,
64
+ expectedRestartUntilMs: 0,
65
+ pendingRecoveryNoticeSource: "",
66
+ };
67
+ let healthTimer = null;
68
+ let bootstrapHealthTimer = null;
69
+
70
+ const clearExpectedRestartWindow = () => {
71
+ state.expectedRestartInProgress = false;
72
+ state.expectedRestartUntilMs = 0;
73
+ };
74
+
75
+ const markExpectedRestartWindow = (durationMs = kExpectedRestartWindowMs) => {
76
+ const safeDuration = Math.max(5000, Number(durationMs) || kExpectedRestartWindowMs);
77
+ state.expectedRestartInProgress = true;
78
+ state.expectedRestartUntilMs = Date.now() + safeDuration;
79
+ };
80
+
81
+ const startRegularHealthChecks = () => {
82
+ if (healthTimer) return;
83
+ healthTimer = setInterval(() => {
84
+ void runHealthCheck();
85
+ }, kWatchdogCheckIntervalMs);
86
+ if (typeof healthTimer.unref === "function") healthTimer.unref();
87
+ };
88
+
89
+ const startBootstrapHealthChecks = () => {
90
+ if (bootstrapHealthTimer) return;
91
+ const runBootstrapCheck = async () => {
92
+ const healthy = await runHealthCheck();
93
+ // Bootstrap checks are only for the "initializing" phase. As soon as we
94
+ // either become healthy or transition into any non-unknown state
95
+ // (degraded/unhealthy/etc.), stop 5s polling and fall back to normal
96
+ // interval checks to avoid noisy health-check spam.
97
+ if (healthy || state.health !== "unknown") {
98
+ if (bootstrapHealthTimer) {
99
+ clearTimeout(bootstrapHealthTimer);
100
+ bootstrapHealthTimer = null;
101
+ }
102
+ startRegularHealthChecks();
103
+ return;
104
+ }
105
+ bootstrapHealthTimer = setTimeout(() => {
106
+ void runBootstrapCheck();
107
+ }, kBootstrapHealthCheckMs);
108
+ if (typeof bootstrapHealthTimer.unref === "function") {
109
+ bootstrapHealthTimer.unref();
110
+ }
111
+ };
112
+ void runBootstrapCheck();
113
+ };
114
+
115
+ const trimCrashWindow = () => {
116
+ const threshold = Date.now() - kWatchdogCrashLoopWindowMs;
117
+ state.crashTimestamps = state.crashTimestamps.filter((ts) => ts >= threshold);
118
+ };
119
+
120
+ const createCorrelationId = () =>
121
+ `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
122
+
123
+ const logEvent = (eventType, source, status, details = null, correlationId = "") => {
124
+ try {
125
+ insertWatchdogEvent({
126
+ eventType,
127
+ source,
128
+ status,
129
+ details,
130
+ correlationId,
131
+ });
132
+ } catch (err) {
133
+ console.error(`[watchdog] failed to log event: ${err.message}`);
134
+ }
135
+ };
136
+
137
+ const notify = async (message, correlationId = "") => {
138
+ if (state.notificationsDisabled) {
139
+ return { ok: false, skipped: true, reason: "notifications_disabled" };
140
+ }
141
+ if (!notifier?.notify) return { ok: false, reason: "notifier_unavailable" };
142
+ const result = await notifier.notify(message);
143
+ logEvent("notification", "watchdog", result.ok ? "ok" : "failed", result, correlationId);
144
+ return result;
145
+ };
146
+
147
+ const getWatchdogSetupUrl = () => {
148
+ try {
149
+ const base =
150
+ typeof resolveSetupUrl === "function" ? String(resolveSetupUrl() || "") : "";
151
+ if (base) return `${base.replace(/\/+$/, "")}/#/watchdog`;
152
+ const fallbackPort = Number.parseInt(String(process.env.PORT || "3000"), 10) || 3000;
153
+ return `http://localhost:${fallbackPort}/#/watchdog`;
154
+ } catch {
155
+ return "";
156
+ }
157
+ };
158
+
159
+ const withViewLogsSuffix = (line) => {
160
+ const setupUrl = getWatchdogSetupUrl();
161
+ if (!setupUrl) return line;
162
+ return `${line} - [View logs](${setupUrl})`;
163
+ };
164
+
165
+ const asInlineCode = (value) => `\`${String(value || "").replace(/`/g, "")}\``;
166
+
167
+ const notifyAutoRepairOutcome = async ({
168
+ source,
169
+ correlationId,
170
+ ok,
171
+ verifiedHealthy = null,
172
+ attempts = 0,
173
+ }) => {
174
+ if (source === "manual") return;
175
+ const title = ok
176
+ ? verifiedHealthy
177
+ ? "🟢 Auto-repair complete, gateway healthy"
178
+ : "🟡 Auto-repair started, awaiting health check"
179
+ : "🔴 Auto-repair failed";
180
+ await notify(
181
+ [
182
+ "🐺 *AlphaClaw Watchdog*",
183
+ withViewLogsSuffix(title),
184
+ `Trigger: ${asInlineCode(source)}`,
185
+ ...(attempts > 0 ? [`Attempt count: ${attempts}`] : []),
186
+ ].join("\n"),
187
+ correlationId,
188
+ );
189
+ };
190
+
191
+ const getSettings = () => ({
192
+ autoRepair: state.autoRepair,
193
+ notificationsEnabled: !state.notificationsDisabled,
194
+ });
195
+
196
+ const updateSettings = ({ autoRepair, notificationsEnabled } = {}) => {
197
+ const hasAutoRepair = typeof autoRepair === "boolean";
198
+ const hasNotificationsEnabled = typeof notificationsEnabled === "boolean";
199
+ if (!hasAutoRepair && !hasNotificationsEnabled) {
200
+ throw new Error("Expected autoRepair and/or notificationsEnabled boolean");
201
+ }
202
+ const envVars = readEnvFile();
203
+ if (hasAutoRepair) {
204
+ const existingIdx = envVars.findIndex((item) => item.key === "WATCHDOG_AUTO_REPAIR");
205
+ const nextValue = autoRepair ? "true" : "false";
206
+ if (existingIdx >= 0) {
207
+ envVars[existingIdx] = { ...envVars[existingIdx], value: nextValue };
208
+ } else {
209
+ envVars.push({ key: "WATCHDOG_AUTO_REPAIR", value: nextValue });
210
+ }
211
+ }
212
+ if (hasNotificationsEnabled) {
213
+ const existingIdx = envVars.findIndex(
214
+ (item) => item.key === "WATCHDOG_NOTIFICATIONS_DISABLED",
215
+ );
216
+ const nextValue = notificationsEnabled ? "false" : "true";
217
+ if (existingIdx >= 0) {
218
+ envVars[existingIdx] = { ...envVars[existingIdx], value: nextValue };
219
+ } else {
220
+ envVars.push({
221
+ key: "WATCHDOG_NOTIFICATIONS_DISABLED",
222
+ value: nextValue,
223
+ });
224
+ }
225
+ }
226
+ writeEnvFile(envVars);
227
+ reloadEnv();
228
+ state.autoRepair = isTruthy(process.env.WATCHDOG_AUTO_REPAIR);
229
+ state.notificationsDisabled = isTruthy(process.env.WATCHDOG_NOTIFICATIONS_DISABLED);
230
+ return getSettings();
231
+ };
232
+
233
+ const runRepair = async ({ source, correlationId, force = false }) => {
234
+ if (!force && !state.autoRepair) {
235
+ return { ok: false, skipped: true, reason: "auto_repair_disabled" };
236
+ }
237
+ if (state.operationInProgress) {
238
+ return { ok: false, skipped: true, reason: "operation_in_progress" };
239
+ }
240
+
241
+ state.operationInProgress = true;
242
+ try {
243
+ const result = await clawCmd("doctor --fix --yes", { quiet: true });
244
+ const ok = !!result?.ok;
245
+ logEvent("repair", source, ok ? "ok" : "failed", result, correlationId);
246
+ if (ok) {
247
+ let launchedGateway = false;
248
+ try {
249
+ const child = launchGatewayProcess();
250
+ launchedGateway = !!child;
251
+ if (launchedGateway) {
252
+ logEvent("restart", "repair", "ok", { pid: child.pid }, correlationId);
253
+ } else {
254
+ logEvent(
255
+ "restart",
256
+ "repair",
257
+ "failed",
258
+ { reason: "launchGatewayProcess returned no child" },
259
+ correlationId,
260
+ );
261
+ }
262
+ } catch (err) {
263
+ logEvent("restart", "repair", "failed", { error: err.message }, correlationId);
264
+ }
265
+ state.health = "unknown";
266
+ state.lifecycle = "running";
267
+ state.repairAttempts = 0;
268
+ state.crashTimestamps = [];
269
+ const verifiedHealthy = await runHealthCheck({
270
+ allowDuringOperation: true,
271
+ source: "repair_verify",
272
+ allowAutoRepair: false,
273
+ });
274
+ await notifyAutoRepairOutcome({
275
+ source,
276
+ correlationId,
277
+ ok: true,
278
+ verifiedHealthy,
279
+ attempts: state.repairAttempts,
280
+ });
281
+ if (!verifiedHealthy && source !== "manual") {
282
+ state.pendingRecoveryNoticeSource = source;
283
+ } else {
284
+ state.pendingRecoveryNoticeSource = "";
285
+ }
286
+ return { ok: true, verifiedHealthy, launchedGateway, result };
287
+ }
288
+
289
+ state.repairAttempts += 1;
290
+ state.health = "unhealthy";
291
+ await notifyAutoRepairOutcome({
292
+ source,
293
+ correlationId,
294
+ ok: false,
295
+ attempts: state.repairAttempts,
296
+ });
297
+ if (state.repairAttempts >= kWatchdogMaxRepairAttempts) {
298
+ await notify(
299
+ [
300
+ "🐺 *AlphaClaw Watchdog*",
301
+ "🔴 Auto-repair failed repeatedly",
302
+ `Attempts: ${state.repairAttempts}`,
303
+ withViewLogsSuffix("Auto-repair paused until manual action."),
304
+ ].join("\n"),
305
+ correlationId,
306
+ );
307
+ }
308
+ return { ok: false, result };
309
+ } finally {
310
+ state.operationInProgress = false;
311
+ }
312
+ };
313
+
314
+ const runHealthCheck = async ({
315
+ allowDuringOperation = false,
316
+ source = "health_timer",
317
+ allowAutoRepair = true,
318
+ } = {}) => {
319
+ if (state.expectedRestartInProgress && Date.now() >= state.expectedRestartUntilMs) {
320
+ clearExpectedRestartWindow();
321
+ }
322
+ if (state.operationInProgress && !allowDuringOperation) return false;
323
+ const gatewayStartedAtAtStart = state.gatewayStartedAt;
324
+ const correlationId = createCorrelationId();
325
+ state.lastHealthCheckAt = new Date().toISOString();
326
+ const result = await clawCmd("health --json", { quiet: true });
327
+ const parsed = parseHealthResult(result);
328
+ const staleAfterRestart =
329
+ gatewayStartedAtAtStart != null &&
330
+ state.gatewayStartedAt != null &&
331
+ state.gatewayStartedAt !== gatewayStartedAtAtStart;
332
+ const restartWindowActive =
333
+ state.expectedRestartInProgress && Date.now() < state.expectedRestartUntilMs;
334
+ if (staleAfterRestart) {
335
+ return false;
336
+ }
337
+ if (parsed.ok) {
338
+ const wasUnhealthy = state.health !== "healthy";
339
+ clearExpectedRestartWindow();
340
+ state.health = "healthy";
341
+ if (state.lifecycle !== "crash_loop") state.lifecycle = "running";
342
+ if (!state.uptimeStartedAt || wasUnhealthy) state.uptimeStartedAt = Date.now();
343
+ state.repairAttempts = 0;
344
+ state.crashRecoveryActive = false;
345
+ if (state.pendingRecoveryNoticeSource) {
346
+ const recoverySource = state.pendingRecoveryNoticeSource;
347
+ state.pendingRecoveryNoticeSource = "";
348
+ await notify(
349
+ [
350
+ "🐺 *AlphaClaw Watchdog*",
351
+ withViewLogsSuffix("🟢 Gateway healthy again"),
352
+ `Trigger: ${asInlineCode(recoverySource)}`,
353
+ ].join("\n"),
354
+ correlationId,
355
+ );
356
+ }
357
+ logEvent("health_check", source, "ok", parsed.details || result, correlationId);
358
+ return true;
359
+ }
360
+ if (restartWindowActive) {
361
+ logEvent(
362
+ "health_check",
363
+ source,
364
+ "ok",
365
+ {
366
+ reason: parsed.reason,
367
+ result,
368
+ skipped: true,
369
+ expectedRestartActive: true,
370
+ expectedRestartUntilMs: state.expectedRestartUntilMs,
371
+ },
372
+ correlationId,
373
+ );
374
+ return false;
375
+ }
376
+
377
+ const withinStartupGrace =
378
+ !!state.gatewayStartedAt &&
379
+ Date.now() - state.gatewayStartedAt < kHealthStartupGraceMs &&
380
+ state.lifecycle === "running" &&
381
+ !state.crashRecoveryActive;
382
+ if (withinStartupGrace) {
383
+ logEvent(
384
+ "health_check",
385
+ source,
386
+ "ok",
387
+ {
388
+ reason: parsed.reason,
389
+ result,
390
+ skipped: true,
391
+ startupGraceActive: true,
392
+ startupGraceMs: kHealthStartupGraceMs,
393
+ },
394
+ correlationId,
395
+ );
396
+ return false;
397
+ }
398
+
399
+ state.health = "degraded";
400
+ logEvent(
401
+ "health_check",
402
+ source,
403
+ "failed",
404
+ { reason: parsed.reason, result },
405
+ correlationId,
406
+ );
407
+ if (!state.autoRepair || !allowAutoRepair) return false;
408
+ await runRepair({ source, correlationId });
409
+ return false;
410
+ };
411
+
412
+ const restartAfterCrash = async (correlationId) => {
413
+ if (state.operationInProgress) return;
414
+ state.operationInProgress = true;
415
+ try {
416
+ const child = launchGatewayProcess();
417
+ if (child) {
418
+ logEvent("restart", "exit_event", "ok", { pid: child.pid }, correlationId);
419
+ } else {
420
+ logEvent(
421
+ "restart",
422
+ "exit_event",
423
+ "failed",
424
+ { reason: "launchGatewayProcess returned no child" },
425
+ correlationId,
426
+ );
427
+ }
428
+ } catch (err) {
429
+ logEvent("restart", "exit_event", "failed", { error: err.message }, correlationId);
430
+ } finally {
431
+ state.operationInProgress = false;
432
+ }
433
+ };
434
+
435
+ const onGatewayExit = ({ code, signal, expectedExit = false, stderrTail = [] } = {}) => {
436
+ const correlationId = createCorrelationId();
437
+ if (expectedExit) {
438
+ state.lifecycle = "restarting";
439
+ state.health = "unknown";
440
+ state.crashRecoveryActive = false;
441
+ markExpectedRestartWindow();
442
+ startBootstrapHealthChecks();
443
+ logEvent(
444
+ "restart",
445
+ "exit_event",
446
+ "ok",
447
+ { expectedExit: true, code: code ?? null, signal: signal ?? null },
448
+ correlationId,
449
+ );
450
+ return;
451
+ }
452
+
453
+ state.lifecycle = "crashed";
454
+ state.health = "unhealthy";
455
+ state.crashRecoveryActive = true;
456
+ state.crashTimestamps.push(Date.now());
457
+ trimCrashWindow();
458
+ logEvent(
459
+ "crash",
460
+ "exit_event",
461
+ "failed",
462
+ { code: code ?? null, signal: signal ?? null, stderrTail },
463
+ correlationId,
464
+ );
465
+
466
+ if (state.crashTimestamps.length >= kWatchdogCrashLoopThreshold) {
467
+ state.lifecycle = "crash_loop";
468
+ logEvent(
469
+ "crash_loop",
470
+ "exit_event",
471
+ "failed",
472
+ {
473
+ crashesInWindow: state.crashTimestamps.length,
474
+ windowMs: kWatchdogCrashLoopWindowMs,
475
+ },
476
+ correlationId,
477
+ );
478
+ void notify(
479
+ [
480
+ "🐺 *AlphaClaw Watchdog*",
481
+ withViewLogsSuffix(
482
+ state.autoRepair
483
+ ? "🔴 Crash loop detected, auto-repairing..."
484
+ : "🔴 Crash loop detected",
485
+ ),
486
+ `Crashes: ${state.crashTimestamps.length} in the last ${Math.floor(kWatchdogCrashLoopWindowMs / 1000)}s`,
487
+ `Last exit code: ${code ?? "unknown"}`,
488
+ ...(state.autoRepair ? [] : ["Auto-restart paused; manual action required."]),
489
+ ].join("\n"),
490
+ correlationId,
491
+ );
492
+ if (state.autoRepair) {
493
+ void runRepair({
494
+ source: "crash_loop",
495
+ correlationId,
496
+ });
497
+ return;
498
+ }
499
+ return;
500
+ }
501
+
502
+ void restartAfterCrash(correlationId);
503
+ };
504
+
505
+ const onGatewayLaunch = ({ startedAt = Date.now() } = {}) => {
506
+ state.lifecycle = "running";
507
+ state.health = "unknown";
508
+ state.crashRecoveryActive = false;
509
+ clearExpectedRestartWindow();
510
+ state.uptimeStartedAt = startedAt;
511
+ state.gatewayStartedAt = startedAt;
512
+ startBootstrapHealthChecks();
513
+ };
514
+
515
+ const onExpectedRestart = () => {
516
+ state.lifecycle = "restarting";
517
+ state.health = "unknown";
518
+ state.crashRecoveryActive = false;
519
+ markExpectedRestartWindow();
520
+ startBootstrapHealthChecks();
521
+ };
522
+
523
+ const triggerRepair = async () => {
524
+ const correlationId = createCorrelationId();
525
+ return runRepair({
526
+ source: "manual",
527
+ correlationId,
528
+ force: true,
529
+ });
530
+ };
531
+
532
+ const start = () => {
533
+ if (healthTimer || bootstrapHealthTimer) return;
534
+ state.lifecycle = "running";
535
+ state.health = "unknown";
536
+ state.uptimeStartedAt = Date.now();
537
+ state.gatewayStartedAt = Date.now();
538
+ startBootstrapHealthChecks();
539
+ };
540
+
541
+ const stop = () => {
542
+ if (bootstrapHealthTimer) {
543
+ clearTimeout(bootstrapHealthTimer);
544
+ bootstrapHealthTimer = null;
545
+ }
546
+ if (healthTimer) {
547
+ clearInterval(healthTimer);
548
+ healthTimer = null;
549
+ }
550
+ state.lifecycle = "stopped";
551
+ };
552
+
553
+ const getStatus = () => {
554
+ trimCrashWindow();
555
+ return {
556
+ lifecycle: state.lifecycle,
557
+ health: state.health,
558
+ uptimeMs: state.uptimeStartedAt ? Date.now() - state.uptimeStartedAt : 0,
559
+ uptimeStartedAt: state.uptimeStartedAt
560
+ ? new Date(state.uptimeStartedAt).toISOString()
561
+ : null,
562
+ lastHealthCheckAt: state.lastHealthCheckAt,
563
+ repairAttempts: state.repairAttempts,
564
+ autoRepair: state.autoRepair,
565
+ crashCountInWindow: state.crashTimestamps.length,
566
+ crashLoopThreshold: kWatchdogCrashLoopThreshold,
567
+ crashLoopWindowMs: kWatchdogCrashLoopWindowMs,
568
+ operationInProgress: state.operationInProgress,
569
+ };
570
+ };
571
+
572
+ return {
573
+ getStatus,
574
+ getSettings,
575
+ updateSettings,
576
+ triggerRepair,
577
+ onExpectedRestart,
578
+ onGatewayExit,
579
+ onGatewayLaunch,
580
+ start,
581
+ stop,
582
+ };
583
+ };
584
+
585
+ module.exports = { createWatchdog };