bosun 0.28.0 → 0.28.2

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/desktop/main.mjs CHANGED
@@ -1,8 +1,10 @@
1
- import { app, BrowserWindow } from "electron";
1
+ import { app, BrowserWindow, session } from "electron";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { execFileSync, spawn } from "node:child_process";
6
+ import { request as httpRequest } from "node:http";
7
+ import { request as httpsRequest } from "node:https";
6
8
  import { homedir } from "node:os";
7
9
 
8
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -12,9 +14,23 @@ let shuttingDown = false;
12
14
  let uiServerStarted = false;
13
15
  let uiOrigin = null;
14
16
  let uiApi = null;
17
+ let runtimeConfigLoaded = false;
15
18
 
16
19
  const DAEMON_PID_FILE = resolve(homedir(), ".cache", "bosun", "daemon.pid");
17
20
 
21
+ // Local/private-network patterns — TLS cert bypass for the embedded UI server
22
+ const LOCAL_HOSTNAME_RE = [
23
+ /^127\./,
24
+ /^192\.168\./,
25
+ /^10\./,
26
+ /^172\.(1[6-9]|2[0-9]|3[01])\./,
27
+ /^::1$/,
28
+ /^localhost$/i,
29
+ ];
30
+ function isLocalHost(hostname) {
31
+ return LOCAL_HOSTNAME_RE.some((re) => re.test(hostname));
32
+ }
33
+
18
34
  function parseBoolEnv(value, fallback) {
19
35
  if (value === undefined || value === null) return fallback;
20
36
  const normalized = String(value).trim().toLowerCase();
@@ -86,16 +102,81 @@ async function loadBosunModule(file) {
86
102
  return import(pathToFileURL(modulePath).href);
87
103
  }
88
104
 
105
+ async function loadRuntimeConfig() {
106
+ if (runtimeConfigLoaded) return;
107
+ try {
108
+ const config = await loadBosunModule("config.mjs");
109
+ if (typeof config?.loadConfig === "function") {
110
+ config.loadConfig(["node", "desktop"], { reloadEnv: true });
111
+ }
112
+ } catch (err) {
113
+ console.warn("[desktop] failed to load config env", err?.message || err);
114
+ }
115
+ runtimeConfigLoaded = true;
116
+ }
117
+
89
118
  async function loadUiServerModule() {
90
119
  if (uiApi) return uiApi;
91
120
  uiApi = await loadBosunModule("ui-server.mjs");
92
121
  return uiApi;
93
122
  }
94
123
 
124
+ function buildDaemonUiBaseUrl() {
125
+ const rawPort = Number(process.env.TELEGRAM_UI_PORT || "0");
126
+ if (!Number.isFinite(rawPort) || rawPort <= 0) return null;
127
+ const tlsDisabled = parseBoolEnv(process.env.TELEGRAM_UI_TLS_DISABLE, false);
128
+ const protocol = tlsDisabled ? "http" : "https";
129
+ const host =
130
+ process.env.TELEGRAM_UI_DESKTOP_HOST ||
131
+ process.env.TELEGRAM_UI_HOST ||
132
+ "127.0.0.1";
133
+ return `${protocol}://${host}:${rawPort}`;
134
+ }
135
+
136
+ async function probeUiServer(url) {
137
+ return new Promise((resolve) => {
138
+ try {
139
+ const isHttps = url.startsWith("https://");
140
+ const req = (isHttps ? httpsRequest : httpRequest)(
141
+ `${url}/api/status`,
142
+ {
143
+ method: "GET",
144
+ timeout: 1500,
145
+ rejectUnauthorized: false,
146
+ },
147
+ (res) => {
148
+ res.resume();
149
+ resolve(Boolean(res.statusCode && res.statusCode < 500));
150
+ },
151
+ );
152
+ req.on("error", () => resolve(false));
153
+ req.on("timeout", () => {
154
+ req.destroy();
155
+ resolve(false);
156
+ });
157
+ req.end();
158
+ } catch {
159
+ resolve(false);
160
+ }
161
+ });
162
+ }
163
+
164
+ async function resolveDaemonUiUrl() {
165
+ const useDaemon = parseBoolEnv(
166
+ process.env.BOSUN_DESKTOP_USE_DAEMON_UI,
167
+ true,
168
+ );
169
+ if (!useDaemon) return null;
170
+ const base = buildDaemonUiBaseUrl();
171
+ if (!base) return null;
172
+ const ok = await probeUiServer(base);
173
+ return ok ? base : null;
174
+ }
175
+
95
176
  async function ensureDaemonRunning() {
96
177
  const autoStart = parseBoolEnv(
97
178
  process.env.BOSUN_DESKTOP_AUTO_START_DAEMON,
98
- true,
179
+ false,
99
180
  );
100
181
  if (!autoStart) return;
101
182
 
@@ -148,6 +229,13 @@ async function startUiServer() {
148
229
  }
149
230
 
150
231
  async function buildUiUrl() {
232
+ await loadRuntimeConfig();
233
+ const daemonUrl = await resolveDaemonUiUrl();
234
+ if (daemonUrl) {
235
+ uiOrigin = new URL(daemonUrl).origin;
236
+ return daemonUrl;
237
+ }
238
+ await startUiServer();
151
239
  const api = await loadUiServerModule();
152
240
  const uiServerUrl = api.getTelegramUiUrl();
153
241
  if (!uiServerUrl) {
@@ -164,6 +252,7 @@ async function buildUiUrl() {
164
252
 
165
253
  async function createMainWindow() {
166
254
  if (mainWindow) return;
255
+ const iconPath = resolveBosunRuntimePath("logo.png");
167
256
 
168
257
  mainWindow = new BrowserWindow({
169
258
  width: 1280,
@@ -171,6 +260,7 @@ async function createMainWindow() {
171
260
  minWidth: 960,
172
261
  minHeight: 640,
173
262
  backgroundColor: "#0b0b0c",
263
+ ...(existsSync(iconPath) ? { icon: iconPath } : {}),
174
264
  show: false,
175
265
  webPreferences: {
176
266
  contextIsolation: true,
@@ -194,10 +284,35 @@ async function createMainWindow() {
194
284
 
195
285
  async function bootstrap() {
196
286
  try {
287
+ if (process.env.ELECTRON_DISABLE_SANDBOX === "1") {
288
+ app.commandLine.appendSwitch("no-sandbox");
289
+ app.commandLine.appendSwitch("disable-gpu-sandbox");
290
+ }
197
291
  app.setAppUserModelId("com.virtengine.bosun");
292
+ const iconPath = resolveBosunRuntimePath("logo.png");
293
+ if (existsSync(iconPath)) {
294
+ try {
295
+ app.setIcon(iconPath);
296
+ } catch {
297
+ /* best effort */
298
+ }
299
+ }
198
300
  process.chdir(resolveBosunRoot());
301
+ await loadRuntimeConfig();
302
+
303
+ // Bypass TLS verification for the local embedded UI server.
304
+ // setCertificateVerifyProc works at the OpenSSL level — it fires before
305
+ // the higher-level `certificate-error` event and stops the repeated
306
+ // "handshake failed" logs from Chromium's ssl_client_socket_impl.
307
+ session.defaultSession.setCertificateVerifyProc((request, callback) => {
308
+ if (isLocalHost(request.hostname)) {
309
+ callback(0); // 0 = verified OK
310
+ return;
311
+ }
312
+ callback(-3); // -3 = use Chromium default chain verification
313
+ });
314
+
199
315
  await ensureDaemonRunning();
200
- await startUiServer();
201
316
  await createMainWindow();
202
317
  await maybeAutoUpdate();
203
318
  } catch (error) {
@@ -231,8 +346,10 @@ async function shutdown(reason) {
231
346
  }
232
347
 
233
348
  try {
234
- const api = await loadUiServerModule();
235
- api.stopTelegramUiServer();
349
+ if (uiServerStarted) {
350
+ const api = await loadUiServerModule();
351
+ api.stopTelegramUiServer();
352
+ }
236
353
  } catch (error) {
237
354
  console.error("[desktop] failed to stop ui-server", error);
238
355
  }
@@ -243,7 +360,7 @@ async function shutdown(reason) {
243
360
  app.on("before-quit", () => {
244
361
  shuttingDown = true;
245
362
  try {
246
- if (uiApi?.stopTelegramUiServer) {
363
+ if (uiServerStarted && uiApi?.stopTelegramUiServer) {
247
364
  uiApi.stopTelegramUiServer();
248
365
  }
249
366
  } catch (error) {
@@ -254,10 +371,15 @@ app.on("before-quit", () => {
254
371
  app.on(
255
372
  "certificate-error",
256
373
  (event, _webContents, url, _error, _certificate, callback) => {
257
- if (uiOrigin && url.startsWith(uiOrigin)) {
258
- event.preventDefault();
259
- callback(true);
260
- return;
374
+ try {
375
+ const hostname = new URL(url).hostname;
376
+ if ((uiOrigin && url.startsWith(uiOrigin)) || isLocalHost(hostname)) {
377
+ event.preventDefault();
378
+ callback(true);
379
+ return;
380
+ }
381
+ } catch {
382
+ // malformed URL — fall through
261
383
  }
262
384
  callback(false);
263
385
  },
@@ -3,6 +3,14 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
+ "author": "VirtEngine Maintainers <hello@virtengine.com>",
7
+ "homepage": "https://bosun.virtengine.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/virtengine/bosun.git",
11
+ "directory": "/desktop"
12
+ },
13
+
6
14
  "main": "main.mjs",
7
15
  "description": "Electron wrapper for the Bosun desktop portal",
8
16
  "scripts": {
@@ -9,7 +9,7 @@
9
9
  * - Linux: .desktop entry
10
10
  */
11
11
 
12
- import { spawnSync } from "node:child_process";
12
+ import { spawnSync, execSync } from "node:child_process";
13
13
  import {
14
14
  existsSync,
15
15
  readFileSync,
@@ -182,22 +182,41 @@ function installMacShortcut(desktopDir) {
182
182
 
183
183
  function installLinuxShortcut(desktopDir) {
184
184
  const desktopPath = resolve(desktopDir, `${APP_NAME}.desktop`);
185
+ const appDir = resolve(homedir(), ".local", "share", "applications");
186
+ const appPath = resolve(appDir, `${APP_NAME}.desktop`);
187
+ const iconPath = resolve(__dirname, "logo.png");
185
188
  const content = [
186
189
  "[Desktop Entry]",
187
190
  "Type=Application",
188
191
  `Name=${APP_NAME}`,
189
192
  "Comment=Bosun Desktop Portal",
193
+ `Icon=${iconPath}`,
190
194
  `Exec=${buildShellCommand()}`,
191
195
  `Path=${getWorkingDirectory()}`,
192
196
  "Terminal=false",
193
197
  "StartupNotify=true",
194
198
  "Categories=Development;Utility;",
199
+ "NoDisplay=false",
195
200
  "",
196
201
  ].join("\n");
197
202
 
198
203
  try {
199
204
  writeFileSync(desktopPath, content, "utf8");
200
205
  chmodSync(desktopPath, 0o755);
206
+ mkdirSync(appDir, { recursive: true });
207
+ writeFileSync(appPath, content, "utf8");
208
+ chmodSync(appPath, 0o755);
209
+
210
+ try {
211
+ execSync(`gio set "${desktopPath}" metadata::trusted true`, {
212
+ stdio: "ignore",
213
+ });
214
+ execSync(`gio set "${appPath}" metadata::trusted true`, {
215
+ stdio: "ignore",
216
+ });
217
+ } catch {
218
+ /* best effort */
219
+ }
201
220
  return {
202
221
  success: true,
203
222
  method: "Linux desktop entry",
package/monitor.mjs CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  initStatusBoard,
50
50
  pushStatusBoardUpdate,
51
51
  } from "./telegram-bot.mjs";
52
+ import { startAnalyzer, stopAnalyzer } from "./agent-work-analyzer.mjs";
52
53
  import { PRCleanupDaemon } from "./pr-cleanup-daemon.mjs";
53
54
  import {
54
55
  execPrimaryPrompt,
@@ -227,6 +228,123 @@ const ANOMALY_SIGNAL_PATH = resolve(
227
228
  "anomaly-signals.json",
228
229
  );
229
230
 
231
+ const AGENT_ALERT_POLL_MS = 10_000;
232
+ let agentWorkAnalyzerActive = false;
233
+ let agentAlertsOffset = 0;
234
+ let agentAlertsTimer = null;
235
+ const agentAlertsDedup = new Map();
236
+
237
+ function getAgentAlertsPath() {
238
+ return resolve(repoRoot, ".cache", "agent-work-logs", "agent-alerts.jsonl");
239
+ }
240
+
241
+ function rememberAlert(key) {
242
+ agentAlertsDedup.set(key, Date.now());
243
+ if (agentAlertsDedup.size > 200) {
244
+ const sorted = [...agentAlertsDedup.entries()].sort((a, b) => a[1] - b[1]);
245
+ for (const [oldKey] of sorted.slice(0, 100)) {
246
+ agentAlertsDedup.delete(oldKey);
247
+ }
248
+ }
249
+ }
250
+
251
+ function formatAgentAlert(alert) {
252
+ const severity = String(alert.severity || "medium").toUpperCase();
253
+ const type = alert.type || "alert";
254
+ const lines = [
255
+ `🔎 Agent Analyzer: ${severity} ${type}`,
256
+ `Attempt: ${alert.attempt_id || "unknown"}`,
257
+ ];
258
+ if (alert.task_id) lines.push(`Task: ${alert.task_id}`);
259
+ if (alert.executor) lines.push(`Executor: ${alert.executor}`);
260
+ if (alert.recommendation) lines.push(`Recommendation: ${alert.recommendation}`);
261
+ if (alert.error_count) lines.push(`Errors: ${alert.error_count}`);
262
+ if (alert.idle_time_ms) {
263
+ lines.push(`Idle: ${Math.round(alert.idle_time_ms / 1000)}s`);
264
+ }
265
+ if (alert.cost_usd) lines.push(`Cost: $${Number(alert.cost_usd).toFixed(3)}`);
266
+ return lines.join("\n");
267
+ }
268
+
269
+ async function pollAgentAlerts() {
270
+ if (process.env.VITEST) return;
271
+ const path = getAgentAlertsPath();
272
+ if (!existsSync(path)) return;
273
+ let data;
274
+ try {
275
+ data = await readFile(path, "utf8");
276
+ } catch {
277
+ return;
278
+ }
279
+ if (!data || data.length <= agentAlertsOffset) return;
280
+ const chunk = data.slice(agentAlertsOffset);
281
+ agentAlertsOffset = data.length;
282
+ const lines = chunk.split(/\r?\n/);
283
+ for (const line of lines) {
284
+ const trimmed = line.trim();
285
+ if (!trimmed) continue;
286
+ let alert;
287
+ try {
288
+ alert = JSON.parse(trimmed);
289
+ } catch {
290
+ continue;
291
+ }
292
+ const dedupKey = `${alert.type || "alert"}:${alert.attempt_id || "unknown"}:${alert.timestamp || ""}`;
293
+ if (agentAlertsDedup.has(dedupKey)) continue;
294
+ rememberAlert(dedupKey);
295
+ console.warn(
296
+ `[agent-work-analyzer] ${alert.severity || "medium"} ${alert.type || "alert"} ${alert.attempt_id || ""}`,
297
+ );
298
+ if (
299
+ telegramToken &&
300
+ telegramChatId &&
301
+ process.env.AGENT_ALERTS_NOTIFY === "true"
302
+ ) {
303
+ void sendTelegramMessage(formatAgentAlert(alert), {
304
+ dedupKey: `agent-alert:${alert.type || "alert"}:${alert.attempt_id || "unknown"}`,
305
+ }).catch(() => {});
306
+ }
307
+ }
308
+ }
309
+
310
+ function startAgentWorkAnalyzer() {
311
+ if (agentWorkAnalyzerActive) return;
312
+ if (process.env.AGENT_WORK_ANALYZER_ENABLED === "false") return;
313
+ try {
314
+ void startAnalyzer();
315
+ agentWorkAnalyzerActive = true;
316
+ console.log("[monitor] agent-work analyzer started");
317
+ } catch (err) {
318
+ console.warn(`[monitor] agent-work analyzer failed to start: ${err.message}`);
319
+ }
320
+ }
321
+
322
+ function stopAgentWorkAnalyzer() {
323
+ if (!agentWorkAnalyzerActive) return;
324
+ try {
325
+ stopAnalyzer();
326
+ } catch {
327
+ /* ignore */
328
+ }
329
+ agentWorkAnalyzerActive = false;
330
+ }
331
+
332
+ function startAgentAlertTailer() {
333
+ if (agentAlertsTimer) return;
334
+ agentAlertsTimer = setInterval(() => {
335
+ void pollAgentAlerts();
336
+ }, AGENT_ALERT_POLL_MS);
337
+ agentAlertsTimer.unref?.();
338
+ void pollAgentAlerts();
339
+ }
340
+
341
+ function stopAgentAlertTailer() {
342
+ if (agentAlertsTimer) {
343
+ clearInterval(agentAlertsTimer);
344
+ agentAlertsTimer = null;
345
+ }
346
+ }
347
+
230
348
  /**
231
349
  * Write an anomaly signal to the shared signal file for the orchestrator to pick up.
232
350
  * The orchestrator reads this file in Process-AnomalySignals and acts accordingly.
@@ -1157,6 +1275,8 @@ function restartSelf(reason) {
1157
1275
  stopTaskPlannerStatusLoop();
1158
1276
  stopMonitorMonitorSupervisor({ preserveRunning: true });
1159
1277
  stopAutoUpdateLoop();
1278
+ stopAgentAlertTailer();
1279
+ stopAgentWorkAnalyzer();
1160
1280
  stopSelfWatcher();
1161
1281
  stopWatcher();
1162
1282
  stopEnvWatchers();
@@ -7900,6 +8020,27 @@ async function sendTelegramMessage(text, options = {}) {
7900
8020
  });
7901
8021
  }
7902
8022
 
8023
+ globalThis.__bosunNotifyAnomaly = (anomaly) => {
8024
+ if (process.env.BOSUN_INTERNAL_ANOMALY_NOTIFY === "false") return;
8025
+ if (!telegramToken || !telegramChatId) return;
8026
+ const icon =
8027
+ anomaly.severity === "CRITICAL"
8028
+ ? "🔴"
8029
+ : anomaly.severity === "HIGH"
8030
+ ? "🟠"
8031
+ : anomaly.severity === "MEDIUM"
8032
+ ? "🟡"
8033
+ : "⚪️";
8034
+ const lines = [
8035
+ `${icon} Internal Anomaly: ${anomaly.type}`,
8036
+ `Attempt: ${anomaly.processId || anomaly.shortId || "unknown"}`,
8037
+ anomaly.message ? `Message: ${anomaly.message}` : null,
8038
+ ].filter(Boolean);
8039
+ void sendTelegramMessage(lines.join("\n"), {
8040
+ dedupKey: `internal-anomaly:${anomaly.type}:${anomaly.processId || anomaly.shortId || "unknown"}`,
8041
+ }).catch(() => {});
8042
+ };
8043
+
7903
8044
  function enqueueTelegramCommand(handler) {
7904
8045
  telegramCommandQueue.push(handler);
7905
8046
  void drainTelegramCommandQueue();
@@ -10775,6 +10916,8 @@ function selfRestartForSourceChange(filename) {
10775
10916
  stopTaskPlannerStatusLoop();
10776
10917
  stopMonitorMonitorSupervisor({ preserveRunning: true });
10777
10918
  stopAutoUpdateLoop();
10919
+ stopAgentAlertTailer();
10920
+ stopAgentWorkAnalyzer();
10778
10921
  stopSelfWatcher();
10779
10922
  stopWatcher();
10780
10923
  stopEnvWatchers();
@@ -11262,6 +11405,8 @@ process.on("SIGINT", async () => {
11262
11405
  prCleanupDaemon.stop();
11263
11406
  }
11264
11407
  stopAutoUpdateLoop();
11408
+ stopAgentAlertTailer();
11409
+ stopAgentWorkAnalyzer();
11265
11410
  stopSelfWatcher();
11266
11411
  stopEnvWatchers();
11267
11412
  if (watcher) {
@@ -11304,6 +11449,8 @@ process.on("exit", () => {
11304
11449
  stopTaskPlannerStatusLoop();
11305
11450
  stopGitHubReconciler();
11306
11451
  stopMonitorMonitorSupervisor();
11452
+ stopAgentAlertTailer();
11453
+ stopAgentWorkAnalyzer();
11307
11454
  if (vkLogStream) {
11308
11455
  vkLogStream.stop();
11309
11456
  vkLogStream = null;
@@ -11323,6 +11470,8 @@ process.on("SIGTERM", async () => {
11323
11470
  vkLogStream = null;
11324
11471
  }
11325
11472
  stopAutoUpdateLoop();
11473
+ stopAgentAlertTailer();
11474
+ stopAgentWorkAnalyzer();
11326
11475
  stopSelfWatcher();
11327
11476
  stopEnvWatchers();
11328
11477
  if (watcher) {
@@ -12225,6 +12374,8 @@ void restoreLiveDigest()
12225
12374
  .then(() => initStatusBoard().catch(() => {}));
12226
12375
 
12227
12376
  // ── Start long-running devmode monitor-monitor supervisor ───────────────────
12377
+ startAgentWorkAnalyzer();
12378
+ startAgentAlertTailer();
12228
12379
  startMonitorMonitorSupervisor();
12229
12380
  startTaskPlannerStatusLoop();
12230
12381
  restartGitHubReconciler();
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.28.0",
3
+ "version": "0.28.2",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
7
- "author": "VirtEngine Maintainers <maintainers@virtengine.com>",
7
+ "author": "VirtEngine Maintainers <hello@virtengine.com>",
8
8
  "homepage": "https://bosun.virtengine.com",
9
9
  "repository": {
10
10
  "type": "git",
@@ -189,6 +189,7 @@
189
189
  "hook-profiles.mjs",
190
190
  "agent-hook-bridge.mjs",
191
191
  "agent-supervisor.mjs",
192
+ "agent-work-analyzer.mjs",
192
193
  "startup-service.mjs",
193
194
  "telegram-sentinel.mjs",
194
195
  "whatsapp-channel.mjs",
@@ -15,11 +15,12 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
15
15
  LOG_DIR="$REPO_ROOT/.cache/agent-work-logs"
16
16
  ARCHIVE_DIR="$LOG_DIR/archive"
17
17
 
18
- # Retention periods
19
- STREAM_RETENTION_DAYS=30
20
- ERROR_RETENTION_DAYS=90
21
- SESSION_RETENTION_COUNT=100
22
- ARCHIVE_RETENTION_DAYS=180
18
+ # Retention periods (override via env)
19
+ STREAM_RETENTION_DAYS="${AGENT_WORK_STREAM_RETENTION_DAYS:-30}"
20
+ ERROR_RETENTION_DAYS="${AGENT_WORK_ERROR_RETENTION_DAYS:-90}"
21
+ SESSION_RETENTION_COUNT="${AGENT_WORK_SESSION_RETENTION_COUNT:-100}"
22
+ ARCHIVE_RETENTION_DAYS="${AGENT_WORK_ARCHIVE_RETENTION_DAYS:-180}"
23
+ METRICS_ROTATION_ENABLED="${AGENT_WORK_METRICS_ROTATION_ENABLED:-true}"
23
24
 
24
25
  # ── Functions ───────────────────────────────────────────────────────────────
25
26
 
@@ -111,10 +112,10 @@ if [ -f "$LOG_DIR/agent-alerts.jsonl" ]; then
111
112
  rotate_file "$LOG_DIR/agent-alerts.jsonl" "$ALERTS_ARCHIVE" "$STREAM_RETENTION_DAYS"
112
113
  fi
113
114
 
114
- # Metrics log is kept indefinitely (compressed monthly)
115
+ # Metrics log is kept indefinitely (compressed monthly unless disabled)
115
116
  if [ -f "$LOG_DIR/agent-metrics.jsonl" ]; then
116
117
  # Only rotate on first day of month
117
- if [ "$(date +%d)" = "01" ]; then
118
+ if [ "$METRICS_ROTATION_ENABLED" != "false" ] && [ "$(date +%d)" = "01" ]; then
118
119
  METRICS_ARCHIVE="agent-metrics-$(date -d 'last month' +%Y%m).jsonl.gz"
119
120
  rotate_file "$LOG_DIR/agent-metrics.jsonl" "$METRICS_ARCHIVE" ""
120
121
  fi