@virtengine/openfleet 0.25.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 (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,465 @@
1
+ /**
2
+ * update-check.mjs — Self-updating system for openfleet.
3
+ *
4
+ * Capabilities:
5
+ * - `checkForUpdate(currentVersion)` — non-blocking startup check, prints notice
6
+ * - `forceUpdate(currentVersion)` — interactive `npm install -g` with confirmation
7
+ * - `startAutoUpdateLoop(opts)` — background polling loop (default 10 min) that
8
+ * auto-installs updates and restarts the process. Zero user interaction.
9
+ *
10
+ * Respects:
11
+ * - CODEX_MONITOR_SKIP_UPDATE_CHECK=1 — disable startup check
12
+ * - CODEX_MONITOR_SKIP_AUTO_UPDATE=1 — disable polling auto-update
13
+ * - CODEX_MONITOR_UPDATE_INTERVAL_MS — override poll interval (default 10 min)
14
+ * - Caches the last check timestamp so we don't query npm too aggressively
15
+ */
16
+
17
+ import { execFileSync } from "node:child_process";
18
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
19
+ import { readFileSync, existsSync } from "node:fs";
20
+ import { resolve, dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { createInterface } from "node:readline";
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const PKG_NAME = "@virtengine/openfleet";
26
+ const CACHE_FILE = resolve(__dirname, "logs", ".update-check-cache.json");
27
+ const STARTUP_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour (startup notice)
28
+ const AUTO_UPDATE_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes (polling loop)
29
+
30
+ function runNpmCommand(args, options = {}) {
31
+ const npmExecPath = process.env.npm_execpath;
32
+ if (npmExecPath && existsSync(npmExecPath)) {
33
+ return execFileSync(process.execPath, [npmExecPath, ...args], options);
34
+ }
35
+
36
+ const nodeBinDir = dirname(process.execPath);
37
+ const candidates =
38
+ process.platform === "win32"
39
+ ? [
40
+ join(nodeBinDir, "npm.cmd"),
41
+ join(nodeBinDir, "npm.exe"),
42
+ join(nodeBinDir, "npm"),
43
+ ]
44
+ : [join(nodeBinDir, "npm")];
45
+
46
+ for (const candidate of candidates) {
47
+ if (existsSync(candidate)) {
48
+ return execFileSync(candidate, args, options);
49
+ }
50
+ }
51
+
52
+ const fallback = process.platform === "win32" ? "npm.cmd" : "npm";
53
+ return execFileSync(fallback, args, options);
54
+ }
55
+
56
+ // ── Semver comparison ────────────────────────────────────────────────────────
57
+
58
+ function parseVersion(v) {
59
+ const parts = v.replace(/^v/, "").split(".").map(Number);
60
+ return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 };
61
+ }
62
+
63
+ function isNewer(remote, local) {
64
+ const r = parseVersion(remote);
65
+ const l = parseVersion(local);
66
+ if (r.major !== l.major) return r.major > l.major;
67
+ if (r.minor !== l.minor) return r.minor > l.minor;
68
+ return r.patch > l.patch;
69
+ }
70
+
71
+ // ── Cache ────────────────────────────────────────────────────────────────────
72
+
73
+ async function readCache() {
74
+ try {
75
+ const raw = await readFile(CACHE_FILE, "utf8");
76
+ return JSON.parse(raw);
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+
82
+ async function writeCache(data) {
83
+ try {
84
+ await mkdir(dirname(CACHE_FILE), { recursive: true });
85
+ await writeFile(CACHE_FILE, JSON.stringify(data, null, 2));
86
+ } catch {
87
+ // non-critical
88
+ }
89
+ }
90
+
91
+ // ── Registry query ───────────────────────────────────────────────────────────
92
+
93
+ async function fetchLatestVersion() {
94
+ // Try native fetch (Node 18+), fall back to npm view
95
+ try {
96
+ const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, {
97
+ headers: { Accept: "application/json" },
98
+ signal: AbortSignal.timeout(10000),
99
+ });
100
+ if (res.ok) {
101
+ const data = await res.json();
102
+ return data.version || null;
103
+ }
104
+ } catch {
105
+ // fetch failed, try npm view
106
+ }
107
+
108
+ try {
109
+ const out = runNpmCommand(["view", PKG_NAME, "version"], {
110
+ encoding: "utf8",
111
+ timeout: 15000,
112
+ stdio: ["pipe", "pipe", "ignore"],
113
+ }).trim();
114
+ return out || null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ // ── Public API ───────────────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * Non-blocking update check. Prints a notice if an update is available.
124
+ * Called on startup — must never throw or delay the main process.
125
+ */
126
+ export async function checkForUpdate(currentVersion) {
127
+ if (process.env.CODEX_MONITOR_SKIP_UPDATE_CHECK) return;
128
+
129
+ try {
130
+ // Rate-limit: at most once per hour
131
+ const cache = await readCache();
132
+ const now = Date.now();
133
+ if (cache.lastCheck && now - cache.lastCheck < STARTUP_CHECK_INTERVAL_MS) {
134
+ // Use cached result if still fresh
135
+ if (cache.latestVersion && isNewer(cache.latestVersion, currentVersion)) {
136
+ printUpdateNotice(currentVersion, cache.latestVersion);
137
+ }
138
+ return;
139
+ }
140
+
141
+ const latest = await fetchLatestVersion();
142
+ await writeCache({ lastCheck: now, latestVersion: latest });
143
+
144
+ if (latest && isNewer(latest, currentVersion)) {
145
+ printUpdateNotice(currentVersion, latest);
146
+ }
147
+ } catch {
148
+ // Silent — never interfere with startup
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Force-update to the latest version.
154
+ * Prompts for confirmation, then runs npm install -g.
155
+ */
156
+ export async function forceUpdate(currentVersion) {
157
+ console.log(`\n Current version: v${currentVersion}`);
158
+ console.log(" Checking npm registry...\n");
159
+
160
+ const latest = await fetchLatestVersion();
161
+
162
+ if (!latest) {
163
+ console.log(" ❌ Could not reach npm registry. Check your connection.\n");
164
+ return;
165
+ }
166
+
167
+ if (!isNewer(latest, currentVersion)) {
168
+ console.log(` ✅ Already up to date (v${currentVersion})\n`);
169
+ return;
170
+ }
171
+
172
+ console.log(` 📦 Update available: v${currentVersion} → v${latest}\n`);
173
+
174
+ const confirmed = await promptConfirm(" Install update now? [Y/n]: ");
175
+
176
+ if (!confirmed) {
177
+ console.log(" Skipped.\n");
178
+ return;
179
+ }
180
+
181
+ console.log(`\n Installing ${PKG_NAME}@${latest}...\n`);
182
+
183
+ try {
184
+ runNpmCommand(["install", "-g", `${PKG_NAME}@${latest}`], {
185
+ stdio: "inherit",
186
+ timeout: 120000,
187
+ });
188
+ console.log(
189
+ `\n ✅ Updated to v${latest}. Restart openfleet to use the new version.\n`,
190
+ );
191
+ } catch (err) {
192
+ console.error(`\n ❌ Update failed: ${err.message}`);
193
+ console.error(` Try manually: npm install -g ${PKG_NAME}@latest\n`);
194
+ }
195
+ }
196
+
197
+ // ── Helpers ──────────────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Read the current version from package.json (on-disk, not cached import).
201
+ * After an auto-update, the on-disk package.json reflects the new version.
202
+ */
203
+ export function getCurrentVersion() {
204
+ try {
205
+ const pkg = JSON.parse(
206
+ readFileSync(resolve(__dirname, "package.json"), "utf8"),
207
+ );
208
+ return pkg.version || "0.0.0";
209
+ } catch {
210
+ return "0.0.0";
211
+ }
212
+ }
213
+
214
+ // ── Auto-update polling loop ─────────────────────────────────────────────────
215
+
216
+ let autoUpdateTimer = null;
217
+ let autoUpdateRunning = false;
218
+ let parentPid = null;
219
+ let parentCheckInterval = null;
220
+ let cleanupHandlersRegistered = false;
221
+
222
+ /**
223
+ * Start a background polling loop that checks for updates every `intervalMs`
224
+ * (default 10 min). When a newer version is found, it:
225
+ * 1. Runs `npm install -g @virtengine/openfleet@<version>`
226
+ * 2. Calls `onRestart()` (or `process.exit(0)` if not provided)
227
+ *
228
+ * This is fully autonomous — no user interaction required.
229
+ *
230
+ * Safety features to prevent zombie processes:
231
+ * - Monitors parent process health (terminates if parent dies)
232
+ * - Registers cleanup handlers for SIGTERM, SIGINT, SIGHUP
233
+ * - Cleans up intervals on process exit or uncaught exceptions
234
+ * - Periodic parent health check every 30 seconds
235
+ *
236
+ * @param {object} opts
237
+ * @param {function} [opts.onRestart] - Called after successful update (should restart process)
238
+ * @param {function} [opts.onNotify] - Called with message string for Telegram/log
239
+ * @param {number} [opts.intervalMs] - Poll interval (default: 10 min)
240
+ * @param {number} [opts.parentPid] - Parent process PID to monitor (default: process.ppid)
241
+ */
242
+ export function startAutoUpdateLoop(opts = {}) {
243
+ if (process.env.CODEX_MONITOR_SKIP_AUTO_UPDATE === "1") {
244
+ console.log("[auto-update] Disabled via CODEX_MONITOR_SKIP_AUTO_UPDATE=1");
245
+ return;
246
+ }
247
+
248
+ const intervalMs =
249
+ Number(process.env.CODEX_MONITOR_UPDATE_INTERVAL_MS) ||
250
+ opts.intervalMs ||
251
+ AUTO_UPDATE_INTERVAL_MS;
252
+ const onRestart = opts.onRestart || (() => process.exit(0));
253
+ const onNotify = opts.onNotify || ((msg) => console.log(msg));
254
+
255
+ // Register cleanup handlers to prevent zombie processes
256
+ registerCleanupHandlers();
257
+
258
+ // Track parent process if provided
259
+ if (opts.parentPid) {
260
+ parentPid = opts.parentPid;
261
+ console.log(`[auto-update] Monitoring parent process PID ${parentPid}`);
262
+ } else {
263
+ parentPid = process.ppid; // Track parent by default
264
+ console.log(`[auto-update] Monitoring parent process PID ${parentPid}`);
265
+ }
266
+
267
+ console.log(
268
+ `[auto-update] Polling every ${Math.round(intervalMs / 1000 / 60)} min for upstream changes`,
269
+ );
270
+
271
+ async function poll() {
272
+ // Safety check: Is parent process still alive?
273
+ if (!isParentAlive()) {
274
+ console.log(
275
+ `[auto-update] Parent process ${parentPid} no longer exists. Terminating.`,
276
+ );
277
+ stopAutoUpdateLoop();
278
+ process.exit(0);
279
+ }
280
+
281
+ if (autoUpdateRunning) return;
282
+ autoUpdateRunning = true;
283
+ try {
284
+ const currentVersion = getCurrentVersion();
285
+ const latest = await fetchLatestVersion();
286
+
287
+ if (!latest) {
288
+ autoUpdateRunning = false;
289
+ return; // registry unreachable — try again next cycle
290
+ }
291
+
292
+ if (!isNewer(latest, currentVersion)) {
293
+ autoUpdateRunning = false;
294
+ return; // already up to date
295
+ }
296
+
297
+ // ── Update detected! ──────────────────────────────────────────────
298
+ const msg = `[auto-update] 🔄 Update detected: v${currentVersion} → v${latest}. Installing...`;
299
+ console.log(msg);
300
+ onNotify(msg);
301
+
302
+ try {
303
+ runNpmCommand(["install", "-g", `${PKG_NAME}@${latest}`], {
304
+ timeout: 180000,
305
+ stdio: ["pipe", "pipe", "pipe"],
306
+ });
307
+ } catch (installErr) {
308
+ const errMsg = `[auto-update] ❌ Install failed: ${installErr.message || installErr}`;
309
+ console.error(errMsg);
310
+ onNotify(errMsg);
311
+ autoUpdateRunning = false;
312
+ return;
313
+ }
314
+
315
+ // Verify the install actually changed the on-disk version
316
+ const newVersion = getCurrentVersion();
317
+ if (!isNewer(newVersion, currentVersion) && newVersion !== latest) {
318
+ const errMsg = `[auto-update] ⚠️ Install ran but version unchanged (${newVersion}). Skipping restart.`;
319
+ console.warn(errMsg);
320
+ onNotify(errMsg);
321
+ autoUpdateRunning = false;
322
+ return;
323
+ }
324
+
325
+ await writeCache({ lastCheck: Date.now(), latestVersion: latest });
326
+
327
+ const successMsg = `[auto-update] ✅ Updated to v${latest}. Restarting...`;
328
+ console.log(successMsg);
329
+ onNotify(successMsg);
330
+
331
+ // Give Telegram a moment to deliver the notification
332
+ await new Promise((r) => setTimeout(r, 2000));
333
+
334
+ onRestart(`auto-update v${currentVersion} → v${latest}`);
335
+ } catch (err) {
336
+ console.warn(`[auto-update] Poll error: ${err.message || err}`);
337
+ } finally {
338
+ autoUpdateRunning = false;
339
+ }
340
+ }
341
+
342
+ // Set up parent health check (every 30s)
343
+ if (parentPid) {
344
+ parentCheckInterval = setInterval(() => {
345
+ if (!isParentAlive()) {
346
+ console.log(`[auto-update] Parent process ${parentPid} died. Exiting.`);
347
+ stopAutoUpdateLoop();
348
+ process.exit(0);
349
+ }
350
+ }, 30 * 1000);
351
+ }
352
+
353
+ // First poll after 60s (let startup settle), then every intervalMs
354
+ setTimeout(() => {
355
+ void poll();
356
+ autoUpdateTimer = setInterval(() => void poll(), intervalMs);
357
+ }, 60 * 1000);
358
+ }
359
+
360
+ /**
361
+ * Stop the auto-update polling loop (for clean shutdown).
362
+ */
363
+ export function stopAutoUpdateLoop() {
364
+ if (autoUpdateTimer) {
365
+ clearInterval(autoUpdateTimer);
366
+ autoUpdateTimer = null;
367
+ }
368
+ if (parentCheckInterval) {
369
+ clearInterval(parentCheckInterval);
370
+ parentCheckInterval = null;
371
+ }
372
+ parentPid = null;
373
+ }
374
+
375
+ /**
376
+ * Check if parent process is still alive.
377
+ * If parent dies, this child polling loop should terminate too.
378
+ */
379
+ function isParentAlive() {
380
+ if (!parentPid) return true; // No parent tracking configured
381
+ try {
382
+ // On Windows and Unix, kill(pid, 0) checks if process exists without sending signal
383
+ process.kill(parentPid, 0);
384
+ return true;
385
+ } catch (err) {
386
+ // ESRCH = no such process
387
+ if (err.code === "ESRCH") {
388
+ return false;
389
+ }
390
+ // Other errors (EPERM) mean process exists but we can't signal it
391
+ return true;
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Register cleanup handlers to prevent zombie processes.
397
+ */
398
+ function registerCleanupHandlers() {
399
+ if (cleanupHandlersRegistered) return;
400
+ cleanupHandlersRegistered = true;
401
+
402
+ const cleanup = (signal) => {
403
+ console.log(`[auto-update] Received ${signal}, cleaning up...`);
404
+ stopAutoUpdateLoop();
405
+ // Don't call process.exit() - let the signal handler chain continue
406
+ };
407
+
408
+ // Handle graceful shutdown signals
409
+ process.on("SIGTERM", () => cleanup("SIGTERM"));
410
+ process.on("SIGINT", () => cleanup("SIGINT"));
411
+ process.on("SIGHUP", () => cleanup("SIGHUP"));
412
+
413
+ // Handle process exit
414
+ process.on("exit", () => {
415
+ stopAutoUpdateLoop();
416
+ });
417
+
418
+ // Handle uncaught exceptions (last resort)
419
+ const originalUncaughtException = process.listeners("uncaughtException");
420
+ process.on("uncaughtException", (err) => {
421
+ console.error(`[auto-update] Uncaught exception, cleaning up:`, err);
422
+ stopAutoUpdateLoop();
423
+ // Re-emit for other handlers
424
+ if (originalUncaughtException.length > 0) {
425
+ for (const handler of originalUncaughtException) {
426
+ handler(err);
427
+ }
428
+ }
429
+ });
430
+ }
431
+
432
+ function printUpdateNotice(current, latest) {
433
+ console.log("");
434
+ console.log(" ╭──────────────────────────────────────────────────────────╮");
435
+ console.log(
436
+ ` │ Update available: v${current} → v${latest}${" ".repeat(Math.max(0, 38 - current.length - latest.length))}│`,
437
+ );
438
+ console.log(" │ │");
439
+ console.log(` │ Run: npm install -g ${PKG_NAME}@latest │`);
440
+ console.log(" │ Or: openfleet --update │");
441
+ console.log(" ╰──────────────────────────────────────────────────────────╯");
442
+ console.log("");
443
+ }
444
+
445
+ function promptConfirm(question) {
446
+ return new Promise((res) => {
447
+ const rl = createInterface({
448
+ input: process.stdin,
449
+ output: process.stdout,
450
+ terminal: process.stdin.isTTY && process.stdout.isTTY,
451
+ });
452
+ rl.question(question, (answer) => {
453
+ try {
454
+ rl.close();
455
+ } catch (err) {
456
+ const msg = err?.message || String(err || "");
457
+ if (!msg.includes("setRawMode EIO")) {
458
+ throw err;
459
+ }
460
+ }
461
+ const a = answer.trim().toLowerCase();
462
+ res(!a || a === "y" || a === "yes");
463
+ });
464
+ });
465
+ }
package/utils.mjs ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Pure utility functions for openfleet
3
+ * Extracted for unit testing (no I/O, no side effects)
4
+ */
5
+
6
+ /**
7
+ * Normalize text for deduplication by stripping timestamps and IDs
8
+ * @param {string} text - Input text to normalize
9
+ * @returns {string} Normalized text with numbers replaced by N
10
+ */
11
+ export function normalizeDedupKey(text) {
12
+ return (
13
+ String(text || "")
14
+ .trim()
15
+ // Replace numbers (integers and decimals) with N, preserving surrounding text
16
+ .replaceAll(/\d+(\.\d+)?/g, "N")
17
+ // Collapse any resulting multi-N sequences (e.g., "N.N" → "N")
18
+ .replaceAll(/N[.\-/:]N/g, "N")
19
+ // Collapse whitespace
20
+ .replaceAll(/\s+/g, " ")
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Strip ANSI escape codes from text
26
+ * PowerShell and colored CLI output includes \x1b[...m sequences that show
27
+ * as garbage in Telegram messages.
28
+ * @param {string} text - Input text with potential ANSI codes
29
+ * @returns {string} Clean text without ANSI codes
30
+ */
31
+ export function stripAnsi(text) {
32
+ // eslint-disable-next-line no-control-regex
33
+ return String(text || "")
34
+ .replace(/\x1b\[[0-9;]*m/g, "")
35
+ .replace(/\[\d+;?\d*m/g, "");
36
+ }
37
+
38
+ /**
39
+ * Check if a line mentions "error" in a benign/summary context
40
+ * (e.g. "errors=0", "0 errors", "no errors found").
41
+ * Shared by isErrorLine() and autofix fallback to avoid false positives.
42
+ * @param {string} line - Log line to check
43
+ * @returns {boolean} True if the "error" mention is benign
44
+ */
45
+ export function isBenignErrorMention(line) {
46
+ const benign = [
47
+ /errors?[=:]\s*0\b/i,
48
+ /\b0\s+errors?\b/i,
49
+ /\bno\s+errors?\b/i,
50
+ /\bcomplete\b.*\berrors?[=:]\s*0/i,
51
+ /\bpassed\b.*\berrors?\b/i,
52
+ /\bclean\b.*\berrors?\b/i,
53
+ /\bsuccess\b.*\berrors?\b/i,
54
+ /errors?\s*(count|total|sum|rate)\s*[=:]\s*0/i,
55
+ ];
56
+ return benign.some((rx) => rx.test(line));
57
+ }
58
+
59
+ /**
60
+ * Check if a line matches error patterns (excluding noise patterns)
61
+ * @param {string} line - Log line to check
62
+ * @param {RegExp[]} errorPatterns - Patterns that indicate errors
63
+ * @param {RegExp[]} errorNoisePatterns - Patterns to exclude from error detection
64
+ * @returns {boolean} True if line is an error
65
+ */
66
+ export function isErrorLine(line, errorPatterns, errorNoisePatterns) {
67
+ if (errorNoisePatterns.some((pattern) => pattern.test(line))) {
68
+ return false;
69
+ }
70
+ if (isBenignErrorMention(line)) {
71
+ return false;
72
+ }
73
+ return errorPatterns.some((pattern) => pattern.test(line));
74
+ }
75
+
76
+ /**
77
+ * Escape HTML special characters
78
+ * @param {any} value - Value to escape
79
+ * @returns {string} HTML-escaped string
80
+ */
81
+ export function escapeHtml(value) {
82
+ return String(value)
83
+ .replace(/&/g, "&amp;")
84
+ .replace(/"/g, "&quot;")
85
+ .replace(/'/g, "&#39;")
86
+ .replace(/</g, "&lt;")
87
+ .replace(/>/g, "&gt;");
88
+ }
89
+
90
+ /**
91
+ * Format an HTML link with proper escaping
92
+ * @param {string} url - URL for the link
93
+ * @param {string} label - Display text for the link
94
+ * @returns {string} HTML anchor tag or escaped label if no URL
95
+ */
96
+ export function formatHtmlLink(url, label) {
97
+ if (!url) {
98
+ return escapeHtml(label);
99
+ }
100
+ return `<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`;
101
+ }
102
+
103
+ /**
104
+ * Generate a normalized fingerprint for an error line (for deduplication)
105
+ * Strips timestamps, attempt IDs, and branch-specific parts
106
+ * @param {string} line - Error line to fingerprint
107
+ * @returns {string} Normalized fingerprint
108
+ */
109
+ export function getErrorFingerprint(line) {
110
+ // Normalize: strip timestamps, attempt IDs, branch-specific parts
111
+ return line
112
+ .replace(/\[\d{2}:\d{2}:\d{2}\]\s*/g, "")
113
+ .replace(/\b[0-9a-f]{8}\b/gi, "<ID>") // attempt IDs
114
+ .replace(/ve\/[\w.-]+/g, "ve/<BRANCH>") // branch names
115
+ .trim();
116
+ }
117
+
118
+ /**
119
+ * Parse -MaxParallel argument from command line arguments
120
+ * Supports: -MaxParallel N, --maxparallel=N, --max-parallel N
121
+ * Falls back to VK_MAX_PARALLEL or MAX_PARALLEL env vars
122
+ * @param {string[]} argsList - Command line arguments array
123
+ * @returns {number|null} Maximum parallel value or null if not found
124
+ */
125
+ export function getMaxParallelFromArgs(argsList) {
126
+ if (!Array.isArray(argsList)) {
127
+ return null;
128
+ }
129
+ for (let i = 0; i < argsList.length; i += 1) {
130
+ const arg = String(argsList[i] ?? "");
131
+ const directMatch =
132
+ arg.match(/^-{1,2}maxparallel(?:=|:)?(\d+)$/i) ||
133
+ arg.match(/^--max-parallel(?:=|:)?(\d+)$/i);
134
+ if (directMatch) {
135
+ const value = Number(directMatch[1]);
136
+ if (Number.isFinite(value) && value > 0) {
137
+ return value;
138
+ }
139
+ }
140
+ const normalized = arg.toLowerCase();
141
+ if (
142
+ normalized === "-maxparallel" ||
143
+ normalized === "--maxparallel" ||
144
+ normalized === "--max-parallel"
145
+ ) {
146
+ const next = Number(argsList[i + 1]);
147
+ if (Number.isFinite(next) && next > 0) {
148
+ return next;
149
+ }
150
+ }
151
+ }
152
+ const envValue = Number(
153
+ process.env.VK_MAX_PARALLEL || process.env.MAX_PARALLEL,
154
+ );
155
+ if (Number.isFinite(envValue) && envValue > 0) {
156
+ return envValue;
157
+ }
158
+ return null;
159
+ }
160
+
161
+ /**
162
+ * Extract PR number from a GitHub pull request URL
163
+ * @param {string} url - GitHub PR URL
164
+ * @returns {number|null} PR number or null if not found
165
+ */
166
+ export function parsePrNumberFromUrl(url) {
167
+ if (!url) return null;
168
+ const match = String(url).match(/\/pull\/(\d+)/i);
169
+ if (!match) return null;
170
+ const num = Number(match[1]);
171
+ return Number.isFinite(num) ? num : null;
172
+ }