bosun 0.28.1 → 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/.env.example +7 -0
- package/README.md +12 -4
- package/desktop/launch.mjs +31 -2
- package/desktop/main.mjs +51 -6
- package/desktop/package.json +8 -0
- package/desktop-shortcut.mjs +20 -1
- package/package.json +2 -2
- package/rotate-agent-logs.sh +8 -7
- package/setup.mjs +162 -5
- package/task-executor.mjs +13 -3
- package/telegram-bot.mjs +118 -0
- package/ui/app.js +2 -0
- package/ui/demo.html +8 -0
- package/ui/modules/icons.js +14 -0
- package/ui/modules/router.js +1 -0
- package/ui/modules/settings-schema.js +9 -0
- package/ui/modules/state.js +59 -0
- package/ui/styles.css +88 -0
- package/ui/tabs/telemetry.js +167 -0
- package/ui-server.mjs +128 -0
package/.env.example
CHANGED
|
@@ -169,6 +169,13 @@ TELEGRAM_MINIAPP_ENABLED=false
|
|
|
169
169
|
# Priority threshold for immediate delivery: 1=critical only, 2=critical+errors (default: 1)
|
|
170
170
|
# TELEGRAM_IMMEDIATE_PRIORITY=1
|
|
171
171
|
|
|
172
|
+
# ─── Auto-Delete Old Messages ──────────────────────────────────────────────────────────
|
|
173
|
+
# Automatically delete bot messages older than N days to keep chat tidy.
|
|
174
|
+
# Set to 0 to disable. Default: 3 days.
|
|
175
|
+
# Note: Telegram’s API may silently skip messages older than 48 h in private
|
|
176
|
+
# chats — those will just remain; no error is raised.
|
|
177
|
+
# TELEGRAM_HISTORY_RETENTION_DAYS=3
|
|
178
|
+
|
|
172
179
|
# ─── Presence & Multi-Instance Coordination ──────────────────────────────────
|
|
173
180
|
# Presence heartbeat allows discovering multiple bosun instances.
|
|
174
181
|
# Heartbeat interval in seconds (default: 60)
|
package/README.md
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
|
|
6
6
|
Bosun is a production-grade supervisor for AI coding agents. It routes tasks across executors, automates PR lifecycles, and keeps operators in control through Telegram, the Mini App dashboard, and optional WhatsApp notifications.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://bosun.virtengine.com">Website</a> · <a href="https://bosun.virtengine.com/docs/">Docs</a> · <a href="https://github.com/virtengine/bosun?tab=readme-ov-file#bosun">GitHub</a> · <a href="https://www.npmjs.com/package/bosun">npm</a> · <a href="https://github.com/virtengine/bosun/issues">Issues</a>
|
|
10
|
+
</p>
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
<p align="center">
|
|
13
|
+
<img src="site/social-banner.png" alt="bosun — AI agent supervisor" width="100%" />
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<a href="https://github.com/virtengine/bosun/actions/workflows/ci.yaml"><img src="https://github.com/virtengine/bosun/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI" /></a>
|
|
18
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License" /></a>
|
|
19
|
+
<a href="https://www.npmjs.com/package/bosun"><img src="https://img.shields.io/npm/v/bosun.svg" alt="npm" /></a>
|
|
20
|
+
</p>
|
|
13
21
|
|
|
14
22
|
---
|
|
15
23
|
|
package/desktop/launch.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { spawnSync, spawn } from "node:child_process";
|
|
@@ -7,6 +7,28 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
7
7
|
const desktopDir = resolve(__dirname);
|
|
8
8
|
const binName = process.platform === "win32" ? "electron.cmd" : "electron";
|
|
9
9
|
const electronBin = resolve(desktopDir, "node_modules", ".bin", binName);
|
|
10
|
+
const chromeSandbox = resolve(
|
|
11
|
+
desktopDir,
|
|
12
|
+
"node_modules",
|
|
13
|
+
"electron",
|
|
14
|
+
"dist",
|
|
15
|
+
"chrome-sandbox",
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
function shouldDisableSandbox() {
|
|
19
|
+
if (process.env.BOSUN_DESKTOP_DISABLE_SANDBOX === "1") return true;
|
|
20
|
+
if (process.platform !== "linux") return false;
|
|
21
|
+
if (!existsSync(chromeSandbox)) return true;
|
|
22
|
+
try {
|
|
23
|
+
const stats = statSync(chromeSandbox);
|
|
24
|
+
const mode = stats.mode & 0o7777;
|
|
25
|
+
const isRootOwned = stats.uid === 0;
|
|
26
|
+
const isSetuid = mode === 0o4755;
|
|
27
|
+
return !(isRootOwned && isSetuid);
|
|
28
|
+
} catch {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
10
32
|
|
|
11
33
|
function ensureElectronInstalled() {
|
|
12
34
|
if (existsSync(electronBin)) return true;
|
|
@@ -28,11 +50,18 @@ function launch() {
|
|
|
28
50
|
process.exit(1);
|
|
29
51
|
}
|
|
30
52
|
|
|
31
|
-
const
|
|
53
|
+
const disableSandbox = shouldDisableSandbox();
|
|
54
|
+
const args = [desktopDir];
|
|
55
|
+
if (disableSandbox) {
|
|
56
|
+
args.push("--no-sandbox", "--disable-gpu-sandbox");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const child = spawn(electronBin, args, {
|
|
32
60
|
stdio: "inherit",
|
|
33
61
|
env: {
|
|
34
62
|
...process.env,
|
|
35
63
|
BOSUN_DESKTOP: "1",
|
|
64
|
+
...(disableSandbox ? { ELECTRON_DISABLE_SANDBOX: "1" } : {}),
|
|
36
65
|
},
|
|
37
66
|
});
|
|
38
67
|
|
package/desktop/main.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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";
|
|
@@ -18,6 +18,19 @@ let runtimeConfigLoaded = false;
|
|
|
18
18
|
|
|
19
19
|
const DAEMON_PID_FILE = resolve(homedir(), ".cache", "bosun", "daemon.pid");
|
|
20
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
|
+
|
|
21
34
|
function parseBoolEnv(value, fallback) {
|
|
22
35
|
if (value === undefined || value === null) return fallback;
|
|
23
36
|
const normalized = String(value).trim().toLowerCase();
|
|
@@ -163,7 +176,7 @@ async function resolveDaemonUiUrl() {
|
|
|
163
176
|
async function ensureDaemonRunning() {
|
|
164
177
|
const autoStart = parseBoolEnv(
|
|
165
178
|
process.env.BOSUN_DESKTOP_AUTO_START_DAEMON,
|
|
166
|
-
|
|
179
|
+
false,
|
|
167
180
|
);
|
|
168
181
|
if (!autoStart) return;
|
|
169
182
|
|
|
@@ -239,6 +252,7 @@ async function buildUiUrl() {
|
|
|
239
252
|
|
|
240
253
|
async function createMainWindow() {
|
|
241
254
|
if (mainWindow) return;
|
|
255
|
+
const iconPath = resolveBosunRuntimePath("logo.png");
|
|
242
256
|
|
|
243
257
|
mainWindow = new BrowserWindow({
|
|
244
258
|
width: 1280,
|
|
@@ -246,6 +260,7 @@ async function createMainWindow() {
|
|
|
246
260
|
minWidth: 960,
|
|
247
261
|
minHeight: 640,
|
|
248
262
|
backgroundColor: "#0b0b0c",
|
|
263
|
+
...(existsSync(iconPath) ? { icon: iconPath } : {}),
|
|
249
264
|
show: false,
|
|
250
265
|
webPreferences: {
|
|
251
266
|
contextIsolation: true,
|
|
@@ -269,9 +284,34 @@ async function createMainWindow() {
|
|
|
269
284
|
|
|
270
285
|
async function bootstrap() {
|
|
271
286
|
try {
|
|
287
|
+
if (process.env.ELECTRON_DISABLE_SANDBOX === "1") {
|
|
288
|
+
app.commandLine.appendSwitch("no-sandbox");
|
|
289
|
+
app.commandLine.appendSwitch("disable-gpu-sandbox");
|
|
290
|
+
}
|
|
272
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
|
+
}
|
|
273
300
|
process.chdir(resolveBosunRoot());
|
|
274
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
|
+
|
|
275
315
|
await ensureDaemonRunning();
|
|
276
316
|
await createMainWindow();
|
|
277
317
|
await maybeAutoUpdate();
|
|
@@ -331,10 +371,15 @@ app.on("before-quit", () => {
|
|
|
331
371
|
app.on(
|
|
332
372
|
"certificate-error",
|
|
333
373
|
(event, _webContents, url, _error, _certificate, callback) => {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
338
383
|
}
|
|
339
384
|
callback(false);
|
|
340
385
|
},
|
package/desktop/package.json
CHANGED
|
@@ -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": {
|
package/desktop-shortcut.mjs
CHANGED
|
@@ -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/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.28.
|
|
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 <
|
|
7
|
+
"author": "VirtEngine Maintainers <hello@virtengine.com>",
|
|
8
8
|
"homepage": "https://bosun.virtengine.com",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
package/rotate-agent-logs.sh
CHANGED
|
@@ -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
|
package/setup.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
} from "./hook-profiles.mjs";
|
|
47
47
|
import { detectLegacySetup, applyAllCompatibility } from "./compat.mjs";
|
|
48
48
|
import { DEFAULT_MODEL_PROFILES } from "./task-complexity.mjs";
|
|
49
|
+
import { pullWorkspaceRepos, listWorkspaces } from "./workspace-manager.mjs";
|
|
49
50
|
|
|
50
51
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
51
52
|
|
|
@@ -729,6 +730,61 @@ function printExecutorModelReference() {
|
|
|
729
730
|
console.log();
|
|
730
731
|
}
|
|
731
732
|
|
|
733
|
+
function buildRepositoryChoices(configJson, repoRoot) {
|
|
734
|
+
const choices = [];
|
|
735
|
+
const seen = new Set();
|
|
736
|
+
|
|
737
|
+
const pushChoice = (input) => {
|
|
738
|
+
if (!input) return;
|
|
739
|
+
const name = String(input.name || "").trim();
|
|
740
|
+
const slug = String(input.slug || "").trim();
|
|
741
|
+
const workspace = String(input.workspace || input.workspaceId || "").trim();
|
|
742
|
+
if (!name && !slug) return;
|
|
743
|
+
const key = slug || name;
|
|
744
|
+
if (seen.has(`${workspace}:${key}`)) return;
|
|
745
|
+
seen.add(`${workspace}:${key}`);
|
|
746
|
+
const labelParts = [];
|
|
747
|
+
if (workspace) labelParts.push(`ws:${workspace}`);
|
|
748
|
+
labelParts.push(name || slug);
|
|
749
|
+
if (slug && name && slug !== name) labelParts.push(`(${slug})`);
|
|
750
|
+
const label = labelParts.join(" ");
|
|
751
|
+
choices.push({
|
|
752
|
+
label,
|
|
753
|
+
name,
|
|
754
|
+
slug,
|
|
755
|
+
workspace,
|
|
756
|
+
value: key,
|
|
757
|
+
});
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
if (Array.isArray(configJson?.workspaces)) {
|
|
761
|
+
for (const ws of configJson.workspaces) {
|
|
762
|
+
const wsId = String(ws?.id || "").trim();
|
|
763
|
+
const wsName = String(ws?.name || wsId || "").trim();
|
|
764
|
+
const wsLabel = wsName || wsId;
|
|
765
|
+
for (const repo of ws?.repos || []) {
|
|
766
|
+
pushChoice({
|
|
767
|
+
name: repo?.name,
|
|
768
|
+
slug: repo?.slug,
|
|
769
|
+
workspace: wsLabel,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (Array.isArray(configJson?.repositories)) {
|
|
776
|
+
for (const repo of configJson.repositories) {
|
|
777
|
+
pushChoice(repo);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (choices.length === 0 && repoRoot) {
|
|
782
|
+
pushChoice({ name: basename(repoRoot) });
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return choices;
|
|
786
|
+
}
|
|
787
|
+
|
|
732
788
|
function defaultVariantForExecutor(executor) {
|
|
733
789
|
const normalized = String(executor || "").trim().toUpperCase();
|
|
734
790
|
if (normalized === "CODEX") return "DEFAULT";
|
|
@@ -2113,6 +2169,7 @@ async function main() {
|
|
|
2113
2169
|
|
|
2114
2170
|
const prompt = createPrompt();
|
|
2115
2171
|
let aborted = false;
|
|
2172
|
+
let cloneWorkspacesAfterSetup = false;
|
|
2116
2173
|
|
|
2117
2174
|
try {
|
|
2118
2175
|
// ── Step 2: Setup Mode + Project Identity ─────────────
|
|
@@ -2220,6 +2277,11 @@ async function main() {
|
|
|
2220
2277
|
if (configJson.workspaces.length > 0) {
|
|
2221
2278
|
configJson.activeWorkspace = configJson.workspaces[0].id;
|
|
2222
2279
|
}
|
|
2280
|
+
|
|
2281
|
+
cloneWorkspacesAfterSetup = await prompt.confirm(
|
|
2282
|
+
"Clone/pull workspace repos now (recommended)?",
|
|
2283
|
+
true,
|
|
2284
|
+
);
|
|
2223
2285
|
} else {
|
|
2224
2286
|
// Single-repo mode (classic) — still works as before
|
|
2225
2287
|
const multiRepo = isAdvancedSetup
|
|
@@ -2725,6 +2787,52 @@ async function main() {
|
|
|
2725
2787
|
|
|
2726
2788
|
// ── Step 7: Kanban + Execution ─────────────────────────
|
|
2727
2789
|
headingStep(7, "Kanban & Execution", markSetupProgress);
|
|
2790
|
+
const repoChoices = buildRepositoryChoices(configJson, repoRoot);
|
|
2791
|
+
let selectedRepoChoice = repoChoices[0] || null;
|
|
2792
|
+
if (repoChoices.length > 1) {
|
|
2793
|
+
console.log(
|
|
2794
|
+
chalk.dim(
|
|
2795
|
+
" Multiple repositories detected. Select which repo this bosun instance should manage tasks for.",
|
|
2796
|
+
),
|
|
2797
|
+
);
|
|
2798
|
+
const repoLabels = repoChoices.map((choice) => choice.label);
|
|
2799
|
+
repoLabels.push("Decide later (skip)");
|
|
2800
|
+
const defaultRepoIdx = (() => {
|
|
2801
|
+
const slugDefault =
|
|
2802
|
+
process.env.GITHUB_REPOSITORY || env.GITHUB_REPO || "";
|
|
2803
|
+
if (slugDefault) {
|
|
2804
|
+
const matchIdx = repoChoices.findIndex(
|
|
2805
|
+
(choice) => choice.slug === slugDefault,
|
|
2806
|
+
);
|
|
2807
|
+
if (matchIdx >= 0) return matchIdx;
|
|
2808
|
+
}
|
|
2809
|
+
const primaryIdx = repoChoices.findIndex(
|
|
2810
|
+
(choice) => choice.slug && choice.slug === configJson?.repositories?.find((repo) => repo.primary)?.slug,
|
|
2811
|
+
);
|
|
2812
|
+
return primaryIdx >= 0 ? primaryIdx : 0;
|
|
2813
|
+
})();
|
|
2814
|
+
const selectedIdx = await prompt.choose(
|
|
2815
|
+
"Primary repo for task board",
|
|
2816
|
+
repoLabels,
|
|
2817
|
+
Math.min(defaultRepoIdx, repoChoices.length - 1),
|
|
2818
|
+
);
|
|
2819
|
+
if (selectedIdx >= 0 && selectedIdx < repoChoices.length) {
|
|
2820
|
+
selectedRepoChoice = repoChoices[selectedIdx];
|
|
2821
|
+
if (selectedRepoChoice?.value) {
|
|
2822
|
+
configJson.defaultRepository = selectedRepoChoice.value;
|
|
2823
|
+
}
|
|
2824
|
+
} else {
|
|
2825
|
+
selectedRepoChoice = null;
|
|
2826
|
+
}
|
|
2827
|
+
console.log();
|
|
2828
|
+
info(
|
|
2829
|
+
"If you need different task backends per repo, run separate bosun instances with different configs.",
|
|
2830
|
+
);
|
|
2831
|
+
console.log();
|
|
2832
|
+
} else if (selectedRepoChoice?.value) {
|
|
2833
|
+
configJson.defaultRepository = selectedRepoChoice.value;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2728
2836
|
const backendDefault = String(
|
|
2729
2837
|
process.env.KANBAN_BACKEND || configJson.kanban?.backend || "internal",
|
|
2730
2838
|
)
|
|
@@ -2918,7 +3026,9 @@ async function main() {
|
|
|
2918
3026
|
|
|
2919
3027
|
if (selectedKanbanBackend === "github") {
|
|
2920
3028
|
const primaryRepoSlug =
|
|
2921
|
-
|
|
3029
|
+
selectedRepoChoice?.slug ||
|
|
3030
|
+
configJson.repositories?.find((repo) => repo.primary && repo.slug)?.slug ||
|
|
3031
|
+
"";
|
|
2922
3032
|
const repoSlugDefaults = [
|
|
2923
3033
|
process.env.GITHUB_REPOSITORY,
|
|
2924
3034
|
process.env.GITHUB_REPO,
|
|
@@ -2933,10 +3043,35 @@ async function main() {
|
|
|
2933
3043
|
info(
|
|
2934
3044
|
"Pick the repo that should receive tasks (issues/projects). If you have multiple orgs, use the owner for that repo.",
|
|
2935
3045
|
);
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
3046
|
+
let repoInput = repoSlugDefault;
|
|
3047
|
+
if (repoChoices.length > 1) {
|
|
3048
|
+
const repoLabels = repoChoices.map((choice) => choice.label);
|
|
3049
|
+
repoLabels.push("Enter manually");
|
|
3050
|
+
const repoIdx = await prompt.choose(
|
|
3051
|
+
"Select GitHub repo for tasks",
|
|
3052
|
+
repoLabels,
|
|
3053
|
+
repoChoices.findIndex(
|
|
3054
|
+
(choice) => choice.slug && choice.slug === repoSlugDefault,
|
|
3055
|
+
) >= 0
|
|
3056
|
+
? repoChoices.findIndex(
|
|
3057
|
+
(choice) => choice.slug && choice.slug === repoSlugDefault,
|
|
3058
|
+
)
|
|
3059
|
+
: 0,
|
|
3060
|
+
);
|
|
3061
|
+
if (repoIdx >= 0 && repoIdx < repoChoices.length) {
|
|
3062
|
+
repoInput = repoChoices[repoIdx]?.slug || repoChoices[repoIdx]?.name || repoSlugDefault;
|
|
3063
|
+
} else {
|
|
3064
|
+
repoInput = await prompt.ask(
|
|
3065
|
+
"GitHub repository for tasks (owner/repo or URL)",
|
|
3066
|
+
repoSlugDefault,
|
|
3067
|
+
);
|
|
3068
|
+
}
|
|
3069
|
+
} else {
|
|
3070
|
+
repoInput = await prompt.ask(
|
|
3071
|
+
"GitHub repository for tasks (owner/repo or URL)",
|
|
3072
|
+
repoSlugDefault,
|
|
3073
|
+
);
|
|
3074
|
+
}
|
|
2940
3075
|
const parsedRepoSlug = parseRepoSlugFromUrl(repoInput || repoSlugDefault);
|
|
2941
3076
|
if (parsedRepoSlug) {
|
|
2942
3077
|
const [repoOwner, repoName] = parsedRepoSlug.split("/", 2);
|
|
@@ -4153,6 +4288,28 @@ async function main() {
|
|
|
4153
4288
|
normalizeSetupConfiguration({ env, configJson, repoRoot, slug, configDir });
|
|
4154
4289
|
await writeConfigFiles({ env, configJson, repoRoot, configDir });
|
|
4155
4290
|
clearSetupProgress(configDir);
|
|
4291
|
+
|
|
4292
|
+
if (cloneWorkspacesAfterSetup && Array.isArray(configJson.workspaces) && configJson.workspaces.length > 0) {
|
|
4293
|
+
heading("Cloning Workspace Repos");
|
|
4294
|
+
for (const ws of configJson.workspaces) {
|
|
4295
|
+
const wsId = ws?.id;
|
|
4296
|
+
if (!wsId) continue;
|
|
4297
|
+
try {
|
|
4298
|
+
const results = pullWorkspaceRepos(configDir, wsId);
|
|
4299
|
+
for (const result of results) {
|
|
4300
|
+
if (result.success) {
|
|
4301
|
+
success(`Workspace ${wsId}: ${result.name} ready`);
|
|
4302
|
+
} else {
|
|
4303
|
+
warn(
|
|
4304
|
+
`Workspace ${wsId}: ${result.name} ${result.error ? `— ${result.error}` : "failed"}`,
|
|
4305
|
+
);
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
} catch (err) {
|
|
4309
|
+
warn(`Workspace ${wsId}: clone/pull failed — ${err.message || err}`);
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4156
4313
|
}
|
|
4157
4314
|
|
|
4158
4315
|
// ── Non-Interactive Mode ─────────────────────────────────────────────────────
|
package/task-executor.mjs
CHANGED
|
@@ -536,6 +536,11 @@ const agentWorkSessionStarts = new Map();
|
|
|
536
536
|
const attemptTelemetry = new Map();
|
|
537
537
|
const anomalyAbortTargets = new Map();
|
|
538
538
|
const internalAnomalyEnabled = process.env.BOSUN_INTERNAL_ANOMALY !== "false";
|
|
539
|
+
const TELEMETRY_SAMPLE_RATE = (() => {
|
|
540
|
+
const raw = Number(process.env.BOSUN_TELEMETRY_SAMPLE_RATE || "1");
|
|
541
|
+
if (!Number.isFinite(raw)) return 1;
|
|
542
|
+
return Math.min(1, Math.max(0, raw));
|
|
543
|
+
})();
|
|
539
544
|
const anomalyDetector = internalAnomalyEnabled
|
|
540
545
|
? createAnomalyDetector({
|
|
541
546
|
onAnomaly: (anomaly) => {
|
|
@@ -581,6 +586,14 @@ function ensureAgentWorkDirs() {
|
|
|
581
586
|
}
|
|
582
587
|
|
|
583
588
|
function writeAgentWorkEvent(entry) {
|
|
589
|
+
const eventType = String(entry?.event_type || "");
|
|
590
|
+
const shouldSample =
|
|
591
|
+
TELEMETRY_SAMPLE_RATE < 1 &&
|
|
592
|
+
["agent_output", "tool_call", "tool_result", "usage"].includes(eventType);
|
|
593
|
+
if (shouldSample && Math.random() > TELEMETRY_SAMPLE_RATE) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
584
597
|
ensureAgentWorkDirs();
|
|
585
598
|
const line = JSON.stringify(entry);
|
|
586
599
|
appendFileSync(AGENT_WORK_STREAM, `${line}\n`, "utf8");
|
|
@@ -3897,9 +3910,6 @@ class TaskExecutor {
|
|
|
3897
3910
|
taskMeta,
|
|
3898
3911
|
gitContext,
|
|
3899
3912
|
metrics: {
|
|
3900
|
-
tool_calls: Array.isArray(validatedResult?.items)
|
|
3901
|
-
? validatedResult.items.length
|
|
3902
|
-
: undefined,
|
|
3903
3913
|
success: !!validatedResult?.success,
|
|
3904
3914
|
retry_count: Math.max(0, (validatedResult?.attempts || 1) - 1),
|
|
3905
3915
|
attempts: validatedResult?.attempts || 1,
|
package/telegram-bot.mjs
CHANGED
|
@@ -132,6 +132,18 @@ const statusBoardStatePath = resolve(
|
|
|
132
132
|
const fwCooldownPath = resolve(repoRoot, ".cache", "ve-fw-cooldown.json");
|
|
133
133
|
const FW_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
134
134
|
|
|
135
|
+
// ── Message History Auto-Cleanup ──────────────────────────────────────────
|
|
136
|
+
const msgHistoryPath = resolve(repoRoot, ".cache", "ve-message-history.json");
|
|
137
|
+
// Days to keep bot messages in chat. 0 = disabled. Default: 3 days.
|
|
138
|
+
const HISTORY_RETENTION_DAYS = (() => {
|
|
139
|
+
const v = Number(process.env.TELEGRAM_HISTORY_RETENTION_DAYS ?? "3");
|
|
140
|
+
return Number.isFinite(v) && v > 0 ? v : 0;
|
|
141
|
+
})();
|
|
142
|
+
const HISTORY_RETENTION_MS = HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
143
|
+
const HISTORY_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // run every 6 hours
|
|
144
|
+
const HISTORY_INITIAL_DELAY_MS = 2 * 60 * 1000; // wait 2 min after boot
|
|
145
|
+
const HISTORY_MAX_TRACKED = 10_000; // safety cap
|
|
146
|
+
|
|
135
147
|
function resolveVeKanbanPs1Path() {
|
|
136
148
|
const modulePath = resolve(BosunDir, "ve-kanban.ps1");
|
|
137
149
|
if (existsSync(modulePath)) return modulePath;
|
|
@@ -1117,6 +1129,7 @@ async function sendDirect(chatId, text, options = {}) {
|
|
|
1117
1129
|
const data = await res.json();
|
|
1118
1130
|
if (data.ok && data.result?.message_id) {
|
|
1119
1131
|
lastMessageId = data.result.message_id;
|
|
1132
|
+
recordSentMessage(chatId, lastMessageId);
|
|
1120
1133
|
}
|
|
1121
1134
|
} catch (err) {
|
|
1122
1135
|
console.warn(`[telegram-bot] send JSON parse error: ${err.message}`);
|
|
@@ -1215,6 +1228,85 @@ async function deleteDirect(chatId, messageId) {
|
|
|
1215
1228
|
}
|
|
1216
1229
|
}
|
|
1217
1230
|
|
|
1231
|
+
// ── Message History Helpers ───────────────────────────────────────────────────
|
|
1232
|
+
|
|
1233
|
+
/** Lazy-loaded in-memory list of sent message records. */
|
|
1234
|
+
let _msgHistory = null;
|
|
1235
|
+
let _msgHistoryDirty = false;
|
|
1236
|
+
|
|
1237
|
+
function _loadMsgHistory() {
|
|
1238
|
+
if (_msgHistory !== null) return;
|
|
1239
|
+
try {
|
|
1240
|
+
const raw = readFileSync(msgHistoryPath, "utf8");
|
|
1241
|
+
const data = JSON.parse(raw);
|
|
1242
|
+
_msgHistory = Array.isArray(data.messages) ? data.messages : [];
|
|
1243
|
+
} catch {
|
|
1244
|
+
_msgHistory = [];
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function _saveMsgHistory() {
|
|
1249
|
+
if (!_msgHistory) return;
|
|
1250
|
+
try {
|
|
1251
|
+
mkdirSync(resolve(repoRoot, ".cache"), { recursive: true });
|
|
1252
|
+
writeFileSync(
|
|
1253
|
+
msgHistoryPath,
|
|
1254
|
+
JSON.stringify({ messages: _msgHistory }, null, 2),
|
|
1255
|
+
"utf8",
|
|
1256
|
+
);
|
|
1257
|
+
_msgHistoryDirty = false;
|
|
1258
|
+
} catch {
|
|
1259
|
+
/* best effort */
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Record a newly sent message ID so it can be cleaned up later.
|
|
1265
|
+
* No-op when HISTORY_RETENTION_MS is 0 (feature disabled).
|
|
1266
|
+
*/
|
|
1267
|
+
function recordSentMessage(chatId, messageId) {
|
|
1268
|
+
if (!HISTORY_RETENTION_MS || !messageId) return;
|
|
1269
|
+
_loadMsgHistory();
|
|
1270
|
+
_msgHistory.push({ chat_id: chatId, message_id: messageId, sent_at: Date.now() });
|
|
1271
|
+
if (_msgHistory.length > HISTORY_MAX_TRACKED) {
|
|
1272
|
+
_msgHistory = _msgHistory.slice(-HISTORY_MAX_TRACKED);
|
|
1273
|
+
}
|
|
1274
|
+
_msgHistoryDirty = true;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Delete all tracked bot messages older than HISTORY_RETENTION_MS.
|
|
1279
|
+
* Failures are silently swallowed — Telegram may refuse deletes for messages
|
|
1280
|
+
* older than 48 h in private chats; we just skip those gracefully.
|
|
1281
|
+
*/
|
|
1282
|
+
async function pruneMessageHistory() {
|
|
1283
|
+
if (!HISTORY_RETENTION_MS) return;
|
|
1284
|
+
_loadMsgHistory();
|
|
1285
|
+
if (_msgHistoryDirty) _saveMsgHistory();
|
|
1286
|
+
|
|
1287
|
+
const cutoff = Date.now() - HISTORY_RETENTION_MS;
|
|
1288
|
+
const toDelete = _msgHistory.filter((m) => m.sent_at < cutoff);
|
|
1289
|
+
if (toDelete.length === 0) return;
|
|
1290
|
+
|
|
1291
|
+
console.log(
|
|
1292
|
+
`[telegram-bot] auto-cleanup: deleting ${toDelete.length} message(s) older than ${HISTORY_RETENTION_DAYS}d`,
|
|
1293
|
+
);
|
|
1294
|
+
|
|
1295
|
+
let i = 0;
|
|
1296
|
+
for (const m of toDelete) {
|
|
1297
|
+
await deleteDirect(m.chat_id, m.message_id);
|
|
1298
|
+
i++;
|
|
1299
|
+
// Pace at ~20 deletes/s to stay well under Telegram’s 30 msg/s limit
|
|
1300
|
+
if (i % 20 === 0) await new Promise((r) => setTimeout(r, 1000));
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
_msgHistory = _msgHistory.filter((m) => m.sent_at >= cutoff);
|
|
1304
|
+
_saveMsgHistory();
|
|
1305
|
+
console.log(
|
|
1306
|
+
`[telegram-bot] auto-cleanup: done. ${_msgHistory.length} messages remaining in history.`,
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1218
1310
|
/**
|
|
1219
1311
|
* Answer a Telegram callback query (required to dismiss the "loading" indicator).
|
|
1220
1312
|
* @param {string} callbackQueryId - The callback_query.id from the update
|
|
@@ -10378,6 +10470,32 @@ export async function startTelegramBot() {
|
|
|
10378
10470
|
console.error(`[telegram-bot] fatal poll loop error: ${err.message}`);
|
|
10379
10471
|
polling = false;
|
|
10380
10472
|
});
|
|
10473
|
+
|
|
10474
|
+
// ── Message history auto-cleanup ──
|
|
10475
|
+
if (HISTORY_RETENTION_MS) {
|
|
10476
|
+
// Flush in-memory buffer to disk every 5 minutes
|
|
10477
|
+
const flushTimer = setInterval(() => {
|
|
10478
|
+
if (_msgHistoryDirty) _saveMsgHistory();
|
|
10479
|
+
}, 5 * 60 * 1000);
|
|
10480
|
+
flushTimer.unref();
|
|
10481
|
+
|
|
10482
|
+
// Run first prune after HISTORY_INITIAL_DELAY_MS (2 min), then every 6 h
|
|
10483
|
+
setTimeout(() => {
|
|
10484
|
+
pruneMessageHistory().catch((err) =>
|
|
10485
|
+
console.warn(`[telegram-bot] message history cleanup error: ${err.message}`),
|
|
10486
|
+
);
|
|
10487
|
+
const cleanupTimer = setInterval(
|
|
10488
|
+
() =>
|
|
10489
|
+
pruneMessageHistory().catch((err) =>
|
|
10490
|
+
console.warn(
|
|
10491
|
+
`[telegram-bot] message history cleanup error: ${err.message}`,
|
|
10492
|
+
),
|
|
10493
|
+
),
|
|
10494
|
+
HISTORY_CLEANUP_INTERVAL_MS,
|
|
10495
|
+
);
|
|
10496
|
+
cleanupTimer.unref();
|
|
10497
|
+
}, HISTORY_INITIAL_DELAY_MS);
|
|
10498
|
+
}
|
|
10381
10499
|
}
|
|
10382
10500
|
|
|
10383
10501
|
/**
|
package/ui/app.js
CHANGED
|
@@ -82,6 +82,7 @@ import { AgentsTab } from "./tabs/agents.js";
|
|
|
82
82
|
import { InfraTab } from "./tabs/infra.js";
|
|
83
83
|
import { ControlTab } from "./tabs/control.js";
|
|
84
84
|
import { LogsTab } from "./tabs/logs.js";
|
|
85
|
+
import { TelemetryTab } from "./tabs/telemetry.js";
|
|
85
86
|
import { SettingsTab } from "./tabs/settings.js";
|
|
86
87
|
|
|
87
88
|
/* ── Placeholder signals for connection quality (may be provided by api.js) ── */
|
|
@@ -203,6 +204,7 @@ const TAB_COMPONENTS = {
|
|
|
203
204
|
infra: InfraTab,
|
|
204
205
|
control: ControlTab,
|
|
205
206
|
logs: LogsTab,
|
|
207
|
+
telemetry: TelemetryTab,
|
|
206
208
|
settings: SettingsTab,
|
|
207
209
|
};
|
|
208
210
|
|
package/ui/demo.html
CHANGED
|
@@ -669,6 +669,14 @@
|
|
|
669
669
|
return { data: STATE.status };
|
|
670
670
|
if (route === '/api/executor')
|
|
671
671
|
return { data: { ...STATE.status, maxParallel: STATE.maxParallel, paused: STATE.paused, executors: STATE.executors } };
|
|
672
|
+
if (route === '/api/telemetry/summary')
|
|
673
|
+
return { data: { status: 'ok', updatedAt: Date.now(), totals: { tasks: STATE.tasks.length, executors: STATE.executors.length } } };
|
|
674
|
+
if (route === '/api/telemetry/errors')
|
|
675
|
+
return { data: [] };
|
|
676
|
+
if (route === '/api/telemetry/executors')
|
|
677
|
+
return { data: STATE.executors.map((e) => ({ ...e, status: e.enabled ? 'active' : 'disabled' })) };
|
|
678
|
+
if (route === '/api/telemetry/alerts')
|
|
679
|
+
return { data: [] };
|
|
672
680
|
if (route === '/api/executor/pause') {
|
|
673
681
|
STATE.paused = true; addLog('info', 'executor', 'Executor paused');
|
|
674
682
|
return { ok: true, paused: true };
|
package/ui/modules/icons.js
CHANGED
|
@@ -88,6 +88,20 @@ export const ICONS = {
|
|
|
88
88
|
<circle cx="19" cy="12" r="1.6" />
|
|
89
89
|
</svg>`,
|
|
90
90
|
|
|
91
|
+
chart: html`<svg
|
|
92
|
+
viewBox="0 0 24 24"
|
|
93
|
+
fill="none"
|
|
94
|
+
stroke="currentColor"
|
|
95
|
+
stroke-width="2"
|
|
96
|
+
stroke-linecap="round"
|
|
97
|
+
stroke-linejoin="round"
|
|
98
|
+
>
|
|
99
|
+
<line x1="4" y1="19" x2="20" y2="19" />
|
|
100
|
+
<rect x="6" y="10" width="3" height="9" />
|
|
101
|
+
<rect x="11" y="6" width="3" height="13" />
|
|
102
|
+
<rect x="16" y="13" width="3" height="6" />
|
|
103
|
+
</svg>`,
|
|
104
|
+
|
|
91
105
|
/* ── Status / Feedback ── */
|
|
92
106
|
check: html`<svg
|
|
93
107
|
viewBox="0 0 24 24"
|
package/ui/modules/router.js
CHANGED
|
@@ -82,5 +82,6 @@ export const TAB_CONFIG = [
|
|
|
82
82
|
{ id: "chat", label: "Chat", icon: "chat" },
|
|
83
83
|
{ id: "infra", label: "Infra", icon: "server" },
|
|
84
84
|
{ id: "logs", label: "Logs", icon: "terminal" },
|
|
85
|
+
{ id: "telemetry", label: "Telemetry", icon: "chart" },
|
|
85
86
|
{ id: "settings", label: "Settings", icon: "settings" },
|
|
86
87
|
];
|
|
@@ -53,6 +53,7 @@ export const SETTINGS_SCHEMA = [
|
|
|
53
53
|
{ key: "TELEGRAM_API_BASE_URL", label: "API Base URL", category: "telegram", type: "string", defaultVal: "https://api.telegram.org", description: "Override for Telegram API proxy.", advanced: true, validate: "^https?://" },
|
|
54
54
|
{ key: "TELEGRAM_HTTP_TIMEOUT_MS", label: "HTTP Timeout", category: "telegram", type: "number", defaultVal: 15000, min: 5000, max: 60000, unit: "ms", description: "Per-request timeout for Telegram API calls.", advanced: true },
|
|
55
55
|
{ key: "TELEGRAM_RETRY_ATTEMPTS", label: "Retry Attempts", category: "telegram", type: "number", defaultVal: 4, min: 0, max: 10, description: "Number of retry attempts for transient Telegram API failures.", advanced: true },
|
|
56
|
+
{ key: "TELEGRAM_HISTORY_RETENTION_DAYS", label: "Auto-Delete History", category: "telegram", type: "number", defaultVal: 3, min: 0, max: 365, unit: "days", description: "Automatically delete bot messages older than this many days to keep the chat tidy. 0 = disabled. Note: Telegram may silently skip messages older than 48 h in private chats.", restart: false },
|
|
56
57
|
{ key: "PROJECT_NAME", label: "Project Name", category: "telegram", type: "string", description: "Display name used in Telegram messages and logs. Auto-detected from package.json if not set." },
|
|
57
58
|
|
|
58
59
|
// ── Mini App / UI Server ──────────────────────────────────────
|
|
@@ -112,6 +113,14 @@ export const SETTINGS_SCHEMA = [
|
|
|
112
113
|
{ key: "TASK_PLANNER_DEDUP_HOURS", label: "Planner Dedup Window", category: "kanban", type: "number", defaultVal: 6, min: 1, max: 72, unit: "hours", description: "Hours to look back for duplicate task detection.", advanced: true },
|
|
113
114
|
{ key: "BOSUN_PROMPT_PLANNER", label: "Planner Prompt Path", category: "advanced", type: "string", description: "Override the task planner prompt file path.", advanced: true },
|
|
114
115
|
|
|
116
|
+
// ── Logging / Telemetry ──────────────────────────────────────
|
|
117
|
+
{ key: "BOSUN_TELEMETRY_SAMPLE_RATE", label: "Telemetry Sample Rate", category: "logging", type: "number", defaultVal: 1, min: 0, max: 1, description: "Sampling rate for high-volume telemetry events (agent_output/tool events). 1 = full, 0.1 = 10% sample.", advanced: true },
|
|
118
|
+
{ key: "AGENT_WORK_STREAM_RETENTION_DAYS", label: "Stream Retention", category: "logging", type: "number", defaultVal: 30, min: 1, max: 365, unit: "days", description: "How long to keep agent-work stream logs before rotation.", advanced: true },
|
|
119
|
+
{ key: "AGENT_WORK_ERROR_RETENTION_DAYS", label: "Error Retention", category: "logging", type: "number", defaultVal: 90, min: 1, max: 365, unit: "days", description: "How long to keep error logs before rotation.", advanced: true },
|
|
120
|
+
{ key: "AGENT_WORK_SESSION_RETENTION_COUNT", label: "Session Retention Count", category: "logging", type: "number", defaultVal: 100, min: 10, max: 10000, description: "Number of session log files to keep.", advanced: true },
|
|
121
|
+
{ key: "AGENT_WORK_ARCHIVE_RETENTION_DAYS", label: "Archive Retention", category: "logging", type: "number", defaultVal: 180, min: 30, max: 3650, unit: "days", description: "Retention window for compressed log archives.", advanced: true },
|
|
122
|
+
{ key: "AGENT_WORK_METRICS_ROTATION_ENABLED", label: "Rotate Metrics Log", category: "logging", type: "boolean", defaultVal: true, description: "Rotate metrics log monthly to keep file size bounded.", advanced: true },
|
|
123
|
+
|
|
115
124
|
// ── GitHub / Git ─────────────────────────────────────────
|
|
116
125
|
{ key: "GITHUB_TOKEN", label: "GitHub Token", category: "github", type: "secret", sensitive: true, description: "Personal access token or fine-grained token for GitHub API. Required for GitHub kanban backend." },
|
|
117
126
|
{ key: "GITHUB_REPOSITORY", label: "Repository", category: "github", type: "string", description: "GitHub repository in owner/repo format. Auto-detected from git remote if not set.", validate: "^[\\w.-]+/[\\w.-]+$" },
|
package/ui/modules/state.js
CHANGED
|
@@ -43,6 +43,7 @@ const CACHE_TTL = {
|
|
|
43
43
|
threads: 5000, logs: 15000, worktrees: 30000, workspaces: 30000,
|
|
44
44
|
presence: 30000, config: 60000, projects: 60000, git: 20000,
|
|
45
45
|
infra: 30000,
|
|
46
|
+
telemetry: 15000,
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
function _cacheKey(url) { return url; }
|
|
@@ -111,6 +112,12 @@ export const agentLogLines = signal(200);
|
|
|
111
112
|
export const agentLogQuery = signal("");
|
|
112
113
|
export const agentContext = signal(null);
|
|
113
114
|
|
|
115
|
+
// ── Telemetry
|
|
116
|
+
export const telemetrySummary = signal(null);
|
|
117
|
+
export const telemetryErrors = signal([]);
|
|
118
|
+
export const telemetryExecutors = signal({});
|
|
119
|
+
export const telemetryAlerts = signal([]);
|
|
120
|
+
|
|
114
121
|
// ── Config (routing, regions, etc.)
|
|
115
122
|
export const configData = signal(null);
|
|
116
123
|
|
|
@@ -590,6 +597,50 @@ export async function loadConfig() {
|
|
|
590
597
|
_markFresh("config");
|
|
591
598
|
}
|
|
592
599
|
|
|
600
|
+
export async function loadTelemetrySummary() {
|
|
601
|
+
const url = "/api/telemetry/summary";
|
|
602
|
+
if (_cacheFresh(url, "telemetry")) return;
|
|
603
|
+
const res = await apiFetch(url, { _silent: true }).catch(() => ({
|
|
604
|
+
ok: false,
|
|
605
|
+
}));
|
|
606
|
+
telemetrySummary.value = res?.data ?? res ?? null;
|
|
607
|
+
_cacheSet(url, telemetrySummary.value);
|
|
608
|
+
_markFresh("telemetry");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function loadTelemetryErrors() {
|
|
612
|
+
const url = "/api/telemetry/errors";
|
|
613
|
+
if (_cacheFresh(url, "telemetry")) return;
|
|
614
|
+
const res = await apiFetch(url, { _silent: true }).catch(() => ({
|
|
615
|
+
ok: false,
|
|
616
|
+
}));
|
|
617
|
+
telemetryErrors.value = res?.data ?? res ?? [];
|
|
618
|
+
_cacheSet(url, telemetryErrors.value);
|
|
619
|
+
_markFresh("telemetry");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export async function loadTelemetryExecutors() {
|
|
623
|
+
const url = "/api/telemetry/executors";
|
|
624
|
+
if (_cacheFresh(url, "telemetry")) return;
|
|
625
|
+
const res = await apiFetch(url, { _silent: true }).catch(() => ({
|
|
626
|
+
ok: false,
|
|
627
|
+
}));
|
|
628
|
+
telemetryExecutors.value = res?.data ?? res ?? {};
|
|
629
|
+
_cacheSet(url, telemetryExecutors.value);
|
|
630
|
+
_markFresh("telemetry");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export async function loadTelemetryAlerts() {
|
|
634
|
+
const url = "/api/telemetry/alerts";
|
|
635
|
+
if (_cacheFresh(url, "telemetry")) return;
|
|
636
|
+
const res = await apiFetch(url, { _silent: true }).catch(() => ({
|
|
637
|
+
ok: false,
|
|
638
|
+
}));
|
|
639
|
+
telemetryAlerts.value = res?.data ?? res ?? [];
|
|
640
|
+
_cacheSet(url, telemetryAlerts.value);
|
|
641
|
+
_markFresh("telemetry");
|
|
642
|
+
}
|
|
643
|
+
|
|
593
644
|
/* ═══════════════════════════════════════════════════════════════
|
|
594
645
|
* TAB REFRESH — map tab names to their required loaders
|
|
595
646
|
* ═══════════════════════════════════════════════════════════════ */
|
|
@@ -609,6 +660,13 @@ const TAB_LOADERS = {
|
|
|
609
660
|
control: () => Promise.all([loadExecutor(), loadConfig()]),
|
|
610
661
|
logs: () =>
|
|
611
662
|
Promise.all([loadLogs(), loadAgentLogFileList(), loadAgentLogTailData()]),
|
|
663
|
+
telemetry: () =>
|
|
664
|
+
Promise.all([
|
|
665
|
+
loadTelemetrySummary(),
|
|
666
|
+
loadTelemetryErrors(),
|
|
667
|
+
loadTelemetryExecutors(),
|
|
668
|
+
loadTelemetryAlerts(),
|
|
669
|
+
]),
|
|
612
670
|
settings: () => Promise.all([loadStatus(), loadConfig()]),
|
|
613
671
|
};
|
|
614
672
|
|
|
@@ -687,6 +745,7 @@ const WS_CHANNEL_MAP = {
|
|
|
687
745
|
infra: ["worktrees", "workspaces", "presence"],
|
|
688
746
|
control: ["executor", "overview"],
|
|
689
747
|
logs: ["*"],
|
|
748
|
+
telemetry: ["*"],
|
|
690
749
|
settings: ["overview"],
|
|
691
750
|
};
|
|
692
751
|
|
package/ui/styles.css
CHANGED
|
@@ -139,3 +139,91 @@
|
|
|
139
139
|
font-size: 12px;
|
|
140
140
|
text-align: center;
|
|
141
141
|
}
|
|
142
|
+
|
|
143
|
+
/* ─── Telemetry Tab ─── */
|
|
144
|
+
.telemetry-tab {
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-direction: column;
|
|
147
|
+
gap: 18px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.telemetry-tab .section-header {
|
|
151
|
+
display: flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
justify-content: space-between;
|
|
154
|
+
gap: 12px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.telemetry-summary .metric-grid {
|
|
158
|
+
display: grid;
|
|
159
|
+
gap: 14px;
|
|
160
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.metric-label {
|
|
164
|
+
font-size: 11px;
|
|
165
|
+
text-transform: uppercase;
|
|
166
|
+
letter-spacing: 0.08em;
|
|
167
|
+
color: var(--text-hint);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.metric-value {
|
|
171
|
+
font-size: 20px;
|
|
172
|
+
font-weight: 600;
|
|
173
|
+
color: var(--text-primary);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.telemetry-grid {
|
|
177
|
+
display: grid;
|
|
178
|
+
gap: 16px;
|
|
179
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.telemetry-list {
|
|
183
|
+
list-style: none;
|
|
184
|
+
padding: 0;
|
|
185
|
+
margin: 0;
|
|
186
|
+
display: flex;
|
|
187
|
+
flex-direction: column;
|
|
188
|
+
gap: 10px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.telemetry-list li {
|
|
192
|
+
display: flex;
|
|
193
|
+
align-items: center;
|
|
194
|
+
justify-content: space-between;
|
|
195
|
+
gap: 12px;
|
|
196
|
+
font-size: 13px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.telemetry-label {
|
|
200
|
+
color: var(--text-secondary);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.telemetry-count {
|
|
204
|
+
font-weight: 600;
|
|
205
|
+
color: var(--text-primary);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.telemetry-alerts {
|
|
209
|
+
list-style: none;
|
|
210
|
+
padding: 0;
|
|
211
|
+
margin: 0;
|
|
212
|
+
display: flex;
|
|
213
|
+
flex-direction: column;
|
|
214
|
+
gap: 12px;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.telemetry-alert-title {
|
|
218
|
+
display: flex;
|
|
219
|
+
align-items: center;
|
|
220
|
+
gap: 8px;
|
|
221
|
+
font-weight: 600;
|
|
222
|
+
font-size: 13px;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.telemetry-alert-meta {
|
|
226
|
+
font-size: 12px;
|
|
227
|
+
color: var(--text-hint);
|
|
228
|
+
margin-top: 4px;
|
|
229
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
* Tab: Telemetry — analytics, quality signals, alerts
|
|
3
|
+
* ────────────────────────────────────────────────────────────── */
|
|
4
|
+
import { h } from "preact";
|
|
5
|
+
import { useMemo } from "preact/hooks";
|
|
6
|
+
import htm from "htm";
|
|
7
|
+
|
|
8
|
+
const html = htm.bind(h);
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
telemetrySummary,
|
|
12
|
+
telemetryErrors,
|
|
13
|
+
telemetryExecutors,
|
|
14
|
+
telemetryAlerts,
|
|
15
|
+
loadTelemetrySummary,
|
|
16
|
+
loadTelemetryErrors,
|
|
17
|
+
loadTelemetryExecutors,
|
|
18
|
+
loadTelemetryAlerts,
|
|
19
|
+
scheduleRefresh,
|
|
20
|
+
} from "../modules/state.js";
|
|
21
|
+
import { Card, EmptyState, SkeletonCard, Badge } from "../components/shared.js";
|
|
22
|
+
|
|
23
|
+
function formatCount(value) {
|
|
24
|
+
if (value == null) return "–";
|
|
25
|
+
return String(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatSeconds(value) {
|
|
29
|
+
if (!value && value !== 0) return "–";
|
|
30
|
+
if (value >= 60) return `${Math.round(value / 60)}m`;
|
|
31
|
+
return `${value}s`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function severityBadge(sev = "medium") {
|
|
35
|
+
const normalized = String(sev).toLowerCase();
|
|
36
|
+
if (normalized === "high" || normalized === "critical") return "danger";
|
|
37
|
+
if (normalized === "medium") return "warning";
|
|
38
|
+
return "info";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function TelemetryTab() {
|
|
42
|
+
const summary = telemetrySummary.value;
|
|
43
|
+
const errors = telemetryErrors.value || [];
|
|
44
|
+
const executors = telemetryExecutors.value || {};
|
|
45
|
+
const alerts = telemetryAlerts.value || [];
|
|
46
|
+
|
|
47
|
+
const hasSummary = summary && summary.total > 0;
|
|
48
|
+
|
|
49
|
+
const executorRows = useMemo(
|
|
50
|
+
() => Object.entries(executors).sort((a, b) => b[1] - a[1]),
|
|
51
|
+
[executors],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const alertRows = useMemo(
|
|
55
|
+
() => alerts.slice(-10).reverse(),
|
|
56
|
+
[alerts],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return html`
|
|
60
|
+
<section class="telemetry-tab">
|
|
61
|
+
<div class="section-header">
|
|
62
|
+
<h2>Telemetry</h2>
|
|
63
|
+
<button
|
|
64
|
+
class="btn btn-ghost btn-sm"
|
|
65
|
+
onClick=${() => {
|
|
66
|
+
loadTelemetrySummary();
|
|
67
|
+
loadTelemetryErrors();
|
|
68
|
+
loadTelemetryExecutors();
|
|
69
|
+
loadTelemetryAlerts();
|
|
70
|
+
scheduleRefresh(4000);
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
Refresh
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
${!hasSummary
|
|
78
|
+
? html`<${EmptyState}
|
|
79
|
+
title="No telemetry yet"
|
|
80
|
+
description="Telemetry appears here once agents start running."
|
|
81
|
+
/>`
|
|
82
|
+
: html`<${Card} title="Summary" class="telemetry-summary">
|
|
83
|
+
<div class="metric-grid">
|
|
84
|
+
<div>
|
|
85
|
+
<div class="metric-label">Sessions</div>
|
|
86
|
+
<div class="metric-value">${formatCount(summary.total)}</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div>
|
|
89
|
+
<div class="metric-label">Success</div>
|
|
90
|
+
<div class="metric-value">
|
|
91
|
+
${formatCount(summary.success)} (${summary.successRate}%)
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<div>
|
|
95
|
+
<div class="metric-label">Avg Duration</div>
|
|
96
|
+
<div class="metric-value">${formatSeconds(summary.avgDuration)}</div>
|
|
97
|
+
</div>
|
|
98
|
+
<div>
|
|
99
|
+
<div class="metric-label">Errors</div>
|
|
100
|
+
<div class="metric-value">${formatCount(summary.totalErrors)}</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</${Card}>`}
|
|
104
|
+
|
|
105
|
+
<div class="telemetry-grid">
|
|
106
|
+
<${Card} title="Top Errors">
|
|
107
|
+
${errors.length === 0
|
|
108
|
+
? html`<${EmptyState}
|
|
109
|
+
title="No errors logged"
|
|
110
|
+
description="Errors appear here when failures are detected."
|
|
111
|
+
/>`
|
|
112
|
+
: html`<ul class="telemetry-list">
|
|
113
|
+
${errors.slice(0, 8).map(
|
|
114
|
+
(err) => html`<li>
|
|
115
|
+
<span class="telemetry-label">${err.fingerprint}</span>
|
|
116
|
+
<span class="telemetry-count">${err.count}</span>
|
|
117
|
+
</li>`,
|
|
118
|
+
)}
|
|
119
|
+
</ul>`}
|
|
120
|
+
</${Card}>
|
|
121
|
+
|
|
122
|
+
<${Card} title="Executors">
|
|
123
|
+
${executorRows.length === 0
|
|
124
|
+
? html`<${EmptyState}
|
|
125
|
+
title="No executor data"
|
|
126
|
+
description="Run tasks to populate executor usage."
|
|
127
|
+
/>`
|
|
128
|
+
: html`<ul class="telemetry-list">
|
|
129
|
+
${executorRows.map(
|
|
130
|
+
([name, count]) => html`<li>
|
|
131
|
+
<span class="telemetry-label">${name}</span>
|
|
132
|
+
<span class="telemetry-count">${count}</span>
|
|
133
|
+
</li>`,
|
|
134
|
+
)}
|
|
135
|
+
</ul>`}
|
|
136
|
+
</${Card}>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<${Card} title="Recent Alerts">
|
|
140
|
+
${alertRows.length === 0
|
|
141
|
+
? html`<${EmptyState}
|
|
142
|
+
title="No alerts"
|
|
143
|
+
description="Analyzer alerts will show up here."
|
|
144
|
+
/>`
|
|
145
|
+
: html`<ul class="telemetry-alerts">
|
|
146
|
+
${alertRows.map(
|
|
147
|
+
(alert) => html`<li>
|
|
148
|
+
<div>
|
|
149
|
+
<div class="telemetry-alert-title">
|
|
150
|
+
${alert.type || "alert"}
|
|
151
|
+
<${Badge} tone=${severityBadge(alert.severity)}>${
|
|
152
|
+
String(alert.severity || "medium").toUpperCase()
|
|
153
|
+
}</${Badge}>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="telemetry-alert-meta">
|
|
156
|
+
${alert.attempt_id || "unknown"}
|
|
157
|
+
${alert.executor ? html` · ${alert.executor}` : ""}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</li>`,
|
|
161
|
+
)}
|
|
162
|
+
</ul>`}
|
|
163
|
+
</${Card}>
|
|
164
|
+
</section>
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
|
package/ui-server.mjs
CHANGED
|
@@ -600,6 +600,7 @@ const SETTINGS_KNOWN_KEYS = [
|
|
|
600
600
|
"TELEGRAM_COMMAND_CONCURRENCY", "TELEGRAM_VERBOSITY", "TELEGRAM_BATCH_NOTIFICATIONS",
|
|
601
601
|
"TELEGRAM_BATCH_INTERVAL_SEC", "TELEGRAM_BATCH_MAX_SIZE", "TELEGRAM_IMMEDIATE_PRIORITY",
|
|
602
602
|
"TELEGRAM_API_BASE_URL", "TELEGRAM_HTTP_TIMEOUT_MS", "TELEGRAM_RETRY_ATTEMPTS",
|
|
603
|
+
"TELEGRAM_HISTORY_RETENTION_DAYS",
|
|
603
604
|
"PROJECT_NAME", "TELEGRAM_MINIAPP_ENABLED", "TELEGRAM_UI_PORT", "TELEGRAM_UI_HOST",
|
|
604
605
|
"TELEGRAM_UI_PUBLIC_HOST", "TELEGRAM_UI_BASE_URL", "TELEGRAM_UI_ALLOW_UNSAFE",
|
|
605
606
|
"TELEGRAM_UI_AUTH_MAX_AGE_SEC", "TELEGRAM_UI_TUNNEL",
|
|
@@ -2213,6 +2214,60 @@ async function tailFile(filePath, lineCount, maxBytes = 1_000_000) {
|
|
|
2213
2214
|
};
|
|
2214
2215
|
}
|
|
2215
2216
|
|
|
2217
|
+
async function readJsonlTail(filePath, maxLines = 2000) {
|
|
2218
|
+
if (!existsSync(filePath)) return [];
|
|
2219
|
+
const tail = await tailFile(filePath, maxLines);
|
|
2220
|
+
return (tail.lines || [])
|
|
2221
|
+
.map((line) => {
|
|
2222
|
+
try {
|
|
2223
|
+
return JSON.parse(line);
|
|
2224
|
+
} catch {
|
|
2225
|
+
return null;
|
|
2226
|
+
}
|
|
2227
|
+
})
|
|
2228
|
+
.filter(Boolean);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function withinDays(entry, days) {
|
|
2232
|
+
if (!days) return true;
|
|
2233
|
+
const ts = Date.parse(entry?.timestamp || "");
|
|
2234
|
+
if (!Number.isFinite(ts)) return true;
|
|
2235
|
+
return ts >= Date.now() - days * 24 * 60 * 60 * 1000;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function summarizeTelemetry(metrics, days) {
|
|
2239
|
+
const filtered = metrics.filter((m) => withinDays(m, days));
|
|
2240
|
+
if (filtered.length === 0) return null;
|
|
2241
|
+
const total = filtered.length;
|
|
2242
|
+
const success = filtered.filter(
|
|
2243
|
+
(m) => m.outcome?.status === "completed" || m.metrics?.success === true,
|
|
2244
|
+
).length;
|
|
2245
|
+
const durations = filtered.map((m) => m.metrics?.duration_ms || 0);
|
|
2246
|
+
const avgDuration =
|
|
2247
|
+
durations.length > 0
|
|
2248
|
+
? Math.round(
|
|
2249
|
+
durations.reduce((a, b) => a + b, 0) / durations.length / 1000,
|
|
2250
|
+
)
|
|
2251
|
+
: 0;
|
|
2252
|
+
const totalErrors = filtered.reduce(
|
|
2253
|
+
(sum, m) => sum + (m.error_summary?.total_errors || m.metrics?.errors || 0),
|
|
2254
|
+
0,
|
|
2255
|
+
);
|
|
2256
|
+
const executors = {};
|
|
2257
|
+
for (const m of filtered) {
|
|
2258
|
+
const exec = m.executor || "unknown";
|
|
2259
|
+
executors[exec] = (executors[exec] || 0) + 1;
|
|
2260
|
+
}
|
|
2261
|
+
return {
|
|
2262
|
+
total,
|
|
2263
|
+
success,
|
|
2264
|
+
successRate: total > 0 ? Math.round((success / total) * 100) : 0,
|
|
2265
|
+
avgDuration,
|
|
2266
|
+
totalErrors,
|
|
2267
|
+
executors,
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2216
2271
|
async function listAgentLogFiles(query = "", limit = 60) {
|
|
2217
2272
|
const entries = [];
|
|
2218
2273
|
const files = await readdir(agentLogsDir).catch(() => []);
|
|
@@ -3183,6 +3238,79 @@ async function handleApi(req, res, url) {
|
|
|
3183
3238
|
return;
|
|
3184
3239
|
}
|
|
3185
3240
|
|
|
3241
|
+
if (path === "/api/telemetry/summary") {
|
|
3242
|
+
try {
|
|
3243
|
+
const days = Number(url.searchParams.get("days") || "7");
|
|
3244
|
+
const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
|
|
3245
|
+
const metricsPath = resolve(logDir, "agent-metrics.jsonl");
|
|
3246
|
+
const metrics = await readJsonlTail(metricsPath, 3000);
|
|
3247
|
+
const summary = summarizeTelemetry(metrics, days);
|
|
3248
|
+
jsonResponse(res, 200, { ok: true, data: summary });
|
|
3249
|
+
} catch (err) {
|
|
3250
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
3251
|
+
}
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
if (path === "/api/telemetry/errors") {
|
|
3256
|
+
try {
|
|
3257
|
+
const days = Number(url.searchParams.get("days") || "7");
|
|
3258
|
+
const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
|
|
3259
|
+
const errorsPath = resolve(logDir, "agent-errors.jsonl");
|
|
3260
|
+
const errors = (await readJsonlTail(errorsPath, 2000)).filter((e) =>
|
|
3261
|
+
withinDays(e, days),
|
|
3262
|
+
);
|
|
3263
|
+
const byFingerprint = new Map();
|
|
3264
|
+
for (const e of errors) {
|
|
3265
|
+
const fp = e.data?.error_fingerprint || e.data?.error_message || "unknown";
|
|
3266
|
+
byFingerprint.set(fp, (byFingerprint.get(fp) || 0) + 1);
|
|
3267
|
+
}
|
|
3268
|
+
const top = [...byFingerprint.entries()]
|
|
3269
|
+
.sort((a, b) => b[1] - a[1])
|
|
3270
|
+
.slice(0, 20)
|
|
3271
|
+
.map(([fingerprint, count]) => ({ fingerprint, count }));
|
|
3272
|
+
jsonResponse(res, 200, { ok: true, data: top });
|
|
3273
|
+
} catch (err) {
|
|
3274
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
3275
|
+
}
|
|
3276
|
+
return;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
if (path === "/api/telemetry/executors") {
|
|
3280
|
+
try {
|
|
3281
|
+
const days = Number(url.searchParams.get("days") || "7");
|
|
3282
|
+
const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
|
|
3283
|
+
const metricsPath = resolve(logDir, "agent-metrics.jsonl");
|
|
3284
|
+
const metrics = await readJsonlTail(metricsPath, 3000);
|
|
3285
|
+
const summary = summarizeTelemetry(metrics, days);
|
|
3286
|
+
jsonResponse(res, 200, {
|
|
3287
|
+
ok: true,
|
|
3288
|
+
data: summary?.executors || {},
|
|
3289
|
+
});
|
|
3290
|
+
} catch (err) {
|
|
3291
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
3292
|
+
}
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
if (path === "/api/telemetry/alerts") {
|
|
3297
|
+
try {
|
|
3298
|
+
const days = Number(url.searchParams.get("days") || "7");
|
|
3299
|
+
const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
|
|
3300
|
+
const alertsPath = resolve(logDir, "agent-alerts.jsonl");
|
|
3301
|
+
const alerts = (await readJsonlTail(alertsPath, 500)).filter((a) =>
|
|
3302
|
+
withinDays(a, days),
|
|
3303
|
+
);
|
|
3304
|
+
jsonResponse(res, 200, {
|
|
3305
|
+
ok: true,
|
|
3306
|
+
data: alerts.slice(-50),
|
|
3307
|
+
});
|
|
3308
|
+
} catch (err) {
|
|
3309
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
3310
|
+
}
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3186
3314
|
if (path === "/api/agent-logs/context") {
|
|
3187
3315
|
try {
|
|
3188
3316
|
const query = url.searchParams.get("query") || "";
|