bgrun 3.10.1 → 3.11.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.
package/README.md CHANGED
@@ -75,7 +75,8 @@ Features:
75
75
  - Real-time process status via SSE (no polling)
76
76
  - Start, stop, restart, and delete processes from the UI
77
77
  - Live stdout/stderr log viewer with search
78
- - Memory, PID, port, and runtime at a glance
78
+ - Memory, PID, port, runtime, and guard restarts at a glance
79
+ - Guard toggle per-process (auto-restart on crash)
79
80
  - Responsive mobile layout
80
81
  - Collapsible directory groups
81
82
 
@@ -91,6 +92,9 @@ Features:
91
92
  - [Caddy Reverse Proxy](#caddy-reverse-proxy)
92
93
  - [TOML Configuration](#toml-configuration)
93
94
  - [Programmatic API](#programmatic-api)
95
+ - [Process Dependencies](#process-dependencies)
96
+ - [Log Rotation](#log-rotation)
97
+ - [Guard (Auto-Restart)](#guard-auto-restart)
94
98
  - [Migrating from PM2](#migrating-from-pm2)
95
99
  - [Edge Cases & Behaviors](#edge-cases--behaviors)
96
100
  - [Full CLI Reference](#full-cli-reference)
@@ -555,12 +559,80 @@ bgrun --name worker --directory ./workers --command "node worker.js" --force
555
559
  WantedBy=multi-user.target
556
560
  ```
557
561
 
558
- 3. **No log rotation** — bgrun writes to plain text files in `~/.bgr/`. Use `logrotate` or similar tools, or specify custom log paths with `--stdout` and `--stderr`.
562
+ 3. **Built-in log rotation** — bgrun automatically rotates logs when they exceed 10MB, keeping the last 5000 lines. Manual rotation is also available via the dashboard API.
559
563
 
560
564
  4. **Bun required** — bgrun runs on Bun, but the *processes it manages* can be anything: Node.js, Python, Ruby, Go, Docker, shell scripts.
561
565
 
562
566
  ---
563
567
 
568
+ ## Process Dependencies
569
+
570
+ bgrun supports declaring process startup dependencies via the `BGR_DEPENDS_ON` environment variable:
571
+
572
+ ```bash
573
+ # Start a price listener (no dependencies)
574
+ bgrun --name price-feed --command "bun run sqd.ts" --force
575
+
576
+ # Start a worker that depends on the price feed
577
+ BGR_DEPENDS_ON=price-feed bgrun --name mm-worker --command "bun run worker.ts" --force
578
+ ```
579
+
580
+ When you start `mm-worker`, bgrun will automatically start `price-feed` first if it's not already running.
581
+
582
+ ### Features
583
+
584
+ - **Topological sort** — processes start in correct dependency order
585
+ - **Cycle detection** — bgrun warns if dependencies form a cycle
586
+ - **Auto-start** — unmet dependencies are started automatically
587
+ - **API** — `GET /api/deps` returns the full dependency graph with startup order
588
+
589
+ ### Setting dependencies via API
590
+
591
+ ```bash
592
+ curl -X POST http://localhost:3001/api/deps \
593
+ -H 'Content-Type: application/json' \
594
+ -d '{"name": "mm-worker", "dependsOn": ["price-feed", "redis"]}'
595
+ ```
596
+
597
+ ---
598
+
599
+ ## Log Rotation
600
+
601
+ bgrun includes automatic log rotation to prevent unbounded log file growth:
602
+
603
+ - **Size-based rotation**: Files exceeding 10MB are truncated, keeping the last 5000 lines
604
+ - **Automatic checks**: Rotation runs every 60 seconds alongside the dashboard
605
+ - **Rotation header**: Rotated files include a timestamp header for auditability
606
+ - **Manual rotation**: Trigger via the dashboard API
607
+
608
+ ```bash
609
+ # Check log sizes
610
+ curl http://localhost:3001/api/logs/rotate
611
+
612
+ # Force rotation now
613
+ curl -X POST http://localhost:3001/api/logs/rotate
614
+ ```
615
+
616
+ ---
617
+
618
+ ## Guard (Auto-Restart)
619
+
620
+ bgrun includes a standalone guard process that monitors and auto-restarts crashed processes:
621
+
622
+ ```bash
623
+ # Enable guard for a process
624
+ BGR_KEEP_ALIVE=true bgrun --name my-api --command "bun run server.ts" --force
625
+ ```
626
+
627
+ The guard checks processes every 30 seconds and restarts any that have stopped. Features:
628
+
629
+ - **Exponential backoff** — avoids restart storms for processes that keep crashing
630
+ - **Per-process toggle** — enable/disable guard via the dashboard shield icon
631
+ - **Guard sentinel** — dashboard shows a pulsing green dot when the guard is active
632
+ - **Restart counter** — dashboard tracks total guard restarts across all processes
633
+
634
+ ---
635
+
564
636
  ## Edge Cases & Behaviors
565
637
 
566
638
  ### What happens when a process crashes?
@@ -702,6 +774,8 @@ All state lives in `~/.bgr/`. To reset everything, delete this directory.
702
774
  |----------|-------------|---------|
703
775
  | `DB_NAME` | Custom database file name | `bgr` |
704
776
  | `BGR_GROUP` | Assign process to a group | *(none)* |
777
+ | `BGR_KEEP_ALIVE` | Enable guard auto-restart for this process | `false` |
778
+ | `BGR_DEPENDS_ON` | Comma-separated list of process dependencies | *(none)* |
705
779
  | `BUN_PORT` | Dashboard port (explicit, no fallback) | *(auto: 3000+)* |
706
780
 
707
781
  ---
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GET /api/deps — Get the process dependency graph
3
+ * POST /api/deps — Set dependencies for a process
4
+ * Body: { name: string, dependsOn: string[] }
5
+ */
6
+
7
+ import { getProcess, updateProcessEnv } from '../../../../src/db'
8
+ import { buildDepGraph } from '../../../../src/deps'
9
+ import { parseEnvString } from '../../../../src/utils'
10
+
11
+ export async function GET() {
12
+ const graph = await buildDepGraph()
13
+ return Response.json(graph)
14
+ }
15
+
16
+ export async function POST(req: Request) {
17
+ try {
18
+ const body = await req.json() as { name: string; dependsOn: string[] }
19
+ if (!body.name) {
20
+ return Response.json({ error: 'Missing process name' }, { status: 400 })
21
+ }
22
+
23
+ const proc = getProcess(body.name)
24
+ if (!proc) {
25
+ return Response.json({ error: `Process "${body.name}" not found` }, { status: 404 })
26
+ }
27
+
28
+ // Parse existing env
29
+ let env: Record<string, string> = {}
30
+ try { env = JSON.parse(proc.env) } catch { env = parseEnvString(proc.env) }
31
+
32
+ // Update dependencies
33
+ if (body.dependsOn && body.dependsOn.length > 0) {
34
+ env.BGR_DEPENDS_ON = body.dependsOn.join(',')
35
+ } else {
36
+ delete env.BGR_DEPENDS_ON
37
+ }
38
+
39
+ updateProcessEnv(body.name, JSON.stringify(env))
40
+
41
+ return Response.json({
42
+ ok: true,
43
+ name: body.name,
44
+ dependsOn: body.dependsOn || [],
45
+ })
46
+ } catch (err: any) {
47
+ return Response.json({ error: err.message }, { status: 500 })
48
+ }
49
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * POST /api/guard — Toggle BGR_KEEP_ALIVE for a process
3
+ * Body: { name: string, enabled: boolean }
4
+ *
5
+ * When enabled=true, the built-in guard will auto-restart this process if it dies.
6
+ * When enabled=false, the process is left alone.
7
+ */
8
+ import { getProcess, updateProcessEnv } from '../../../../src/db';
9
+
10
+ export async function POST(req: Request) {
11
+ try {
12
+ const body = await req.json() as { name: string; enabled: boolean };
13
+ if (!body.name) {
14
+ return Response.json({ error: 'Missing process name' }, { status: 400 });
15
+ }
16
+
17
+ const proc = getProcess(body.name);
18
+ if (!proc) {
19
+ return Response.json({ error: `Process "${body.name}" not found` }, { status: 404 });
20
+ }
21
+
22
+ // Parse existing env
23
+ let env: Record<string, string> = {};
24
+ if (proc.env) {
25
+ try { env = JSON.parse(proc.env); } catch { env = {}; }
26
+ }
27
+
28
+ // Toggle BGR_KEEP_ALIVE
29
+ if (body.enabled) {
30
+ env.BGR_KEEP_ALIVE = 'true';
31
+ } else {
32
+ delete env.BGR_KEEP_ALIVE;
33
+ }
34
+
35
+ // Save back
36
+ updateProcessEnv(body.name, JSON.stringify(env));
37
+
38
+ return Response.json({
39
+ ok: true,
40
+ name: body.name,
41
+ guarded: body.enabled
42
+ });
43
+ } catch (err: any) {
44
+ return Response.json({ error: err.message }, { status: 500 });
45
+ }
46
+ }
47
+
48
+ export async function GET() {
49
+ return Response.json({ error: 'Use POST to toggle guard' }, { status: 405 });
50
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * POST /api/guard-all — Bulk toggle guard for all processes
3
+ * Body: { enabled: boolean }
4
+ *
5
+ * When enabled=true, sets BGR_KEEP_ALIVE=true for ALL processes (except bgr-dashboard).
6
+ * When enabled=false, removes BGR_KEEP_ALIVE from ALL processes.
7
+ */
8
+ import { getAllProcesses, getProcess, updateProcessEnv } from '../../../../src/db';
9
+
10
+ const SKIP = new Set(['bgr-dashboard']);
11
+
12
+ export async function POST(req: Request) {
13
+ try {
14
+ const body = await req.json() as { enabled: boolean };
15
+ const processes = getAllProcesses();
16
+ let count = 0;
17
+
18
+ for (const proc of processes) {
19
+ if (SKIP.has(proc.name)) continue;
20
+
21
+ // Parse existing env
22
+ let env: Record<string, string> = {};
23
+ if (proc.env) {
24
+ try { env = JSON.parse(proc.env); } catch { env = {}; }
25
+ }
26
+
27
+ const alreadyGuarded = env.BGR_KEEP_ALIVE === 'true';
28
+ if (body.enabled && alreadyGuarded) continue;
29
+ if (!body.enabled && !alreadyGuarded) continue;
30
+
31
+ if (body.enabled) {
32
+ env.BGR_KEEP_ALIVE = 'true';
33
+ } else {
34
+ delete env.BGR_KEEP_ALIVE;
35
+ }
36
+
37
+ updateProcessEnv(proc.name, JSON.stringify(env));
38
+ count++;
39
+ }
40
+
41
+ return Response.json({
42
+ ok: true,
43
+ action: body.enabled ? 'guarded' : 'unguarded',
44
+ count,
45
+ total: processes.length,
46
+ });
47
+ } catch (err: any) {
48
+ return Response.json({ error: err.message }, { status: 500 });
49
+ }
50
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * POST /api/logs/rotate — Rotate all log files
3
+ * GET /api/logs/rotate — Get log file sizes
4
+ */
5
+
6
+ import { getAllProcesses } from '../../../../../src/db'
7
+ import { rotateAllLogs } from '../../../../../src/log-rotation'
8
+ import { existsSync, statSync } from 'fs'
9
+
10
+ export function POST() {
11
+ const result = rotateAllLogs(() => getAllProcesses())
12
+ return Response.json({
13
+ ok: true,
14
+ rotated: result.rotated,
15
+ checked: result.checked,
16
+ })
17
+ }
18
+
19
+ export function GET() {
20
+ const processes = getAllProcesses()
21
+ const files: Array<{ name: string; type: string; path: string; sizeBytes: number; sizeMB: string }> = []
22
+
23
+ for (const proc of processes) {
24
+ for (const [type, path] of [['stdout', proc.stdout_path], ['stderr', proc.stderr_path]] as const) {
25
+ if (path && existsSync(path)) {
26
+ const stat = statSync(path)
27
+ files.push({
28
+ name: proc.name,
29
+ type,
30
+ path,
31
+ sizeBytes: stat.size,
32
+ sizeMB: (stat.size / 1_000_000).toFixed(2),
33
+ })
34
+ }
35
+ }
36
+ }
37
+
38
+ const totalBytes = files.reduce((sum, f) => sum + f.sizeBytes, 0)
39
+
40
+ return Response.json({
41
+ files,
42
+ totalBytes,
43
+ totalMB: (totalBytes / 1_000_000).toFixed(2),
44
+ })
45
+ }
@@ -1,6 +1,7 @@
1
1
  import { getAllProcesses, updateProcessPid } from '../../../../src/db';
2
2
  import { calculateRuntime } from '../../../../src/utils';
3
- import { getProcessBatchMemory, reconcileProcessPids } from '../../../../src/platform';
3
+ import { getProcessBatchResources, reconcileProcessPids } from '../../../../src/platform';
4
+ import { guardRestartCounts } from '../../../../src/server';
4
5
  import { measure, createMeasure } from 'measure-fn';
5
6
  import { $ } from 'bun';
6
7
 
@@ -14,7 +15,12 @@ const g = globalThis as any;
14
15
  if (!g.__bgrProcessCache) {
15
16
  g.__bgrProcessCache = { data: null, timestamp: 0, inflight: null };
16
17
  }
18
+ if (!g.__bgrResourceHistory) {
19
+ // Map of process name -> { memory: number[], cpu: number[], lastCpuTime: number, lastCheck: number }
20
+ g.__bgrResourceHistory = new Map<string, { memory: number[], cpu: number[], lastCpuTime: number, lastCheck: number }>();
21
+ }
17
22
  const cache = g.__bgrProcessCache;
23
+ const history = g.__bgrResourceHistory;
18
24
 
19
25
  function withTimeout<T>(promise: Promise<T>, fallback: T): Promise<T> {
20
26
  return Promise.race([
@@ -109,10 +115,10 @@ async function fetchProcesses(): Promise<any[]> {
109
115
  const pids = procs.map((p: any) => p.pid);
110
116
 
111
117
  // Three subprocess calls total (not 3×N)
112
- let [runningPids, portMap, memoryMap] = await Promise.all([
118
+ let [runningPids, portMap, resourceMap] = await Promise.all([
113
119
  m('Running PIDs', () => withTimeout(getRunningPids(pids), new Set<number>())),
114
120
  m('Port map', () => withTimeout(getPortsByPid(pids), new Map<number, number[]>())),
115
- m('Memory map', () => withTimeout(getProcessBatchMemory(pids), new Map<number, number>())),
121
+ m('Resource map', () => withTimeout(getProcessBatchResources(pids), new Map<number, { memory: number, cpu: number }>())),
116
122
  ]);
117
123
 
118
124
  // PID reconciliation: if stored PIDs are dead, try to find the real process
@@ -139,22 +145,69 @@ async function fetchProcesses(): Promise<any[]> {
139
145
  if (!runningPids) runningPids = new Set();
140
146
  for (const pid of newPids) runningPids.add(pid);
141
147
 
142
- // Re-fetch ports and memory for the new PIDs
143
- const [newPorts, newMem] = await Promise.all([
148
+ // Re-fetch ports and resources for the new PIDs
149
+ const [newPorts, newResources] = await Promise.all([
144
150
  withTimeout(getPortsByPid(newPids), new Map<number, number[]>()),
145
- withTimeout(getProcessBatchMemory(newPids), new Map<number, number>()),
151
+ withTimeout(getProcessBatchResources(newPids), new Map<number, { memory: number, cpu: number }>()),
146
152
  ]);
147
153
  if (!portMap) portMap = new Map();
148
- if (!memoryMap) memoryMap = new Map();
154
+ if (!resourceMap) resourceMap = new Map();
149
155
  for (const [pid, ports] of newPorts) portMap.set(pid, ports);
150
- for (const [pid, mem] of newMem) memoryMap.set(pid, mem);
156
+ for (const [pid, res] of newResources) resourceMap.set(pid, res);
151
157
  }
152
158
  }
153
159
 
160
+ const now = Date.now();
161
+ const isWin = process.platform === 'win32';
162
+
154
163
  return procs.map((p: any) => {
155
164
  const running = runningPids?.has(p.pid) ?? false;
156
165
  const ports = running ? (portMap?.get(p.pid) || []) : [];
157
- const memory = running ? (memoryMap?.get(p.pid) || 0) : 0;
166
+ const res = running ? (resourceMap?.get(p.pid) || { memory: 0, cpu: 0 }) : { memory: 0, cpu: 0 };
167
+
168
+ // Manage history tracking for sparklines (up to 60 points = 5 minutes at 5s polling)
169
+ let h = history.get(p.name);
170
+ if (!h) {
171
+ h = { memory: [], cpu: [], lastCpuTime: 0, lastCheck: 0 };
172
+ history.set(p.name, h);
173
+ }
174
+
175
+ let cpuPercent = 0;
176
+ if (running) {
177
+ if (isWin) {
178
+ // Windows: cpu is cumulative seconds. Calculate delta percentage across time.
179
+ if (h.lastCheck > 0 && h.lastCpuTime > 0) {
180
+ const timeDeltaSec = (now - h.lastCheck) / 1000;
181
+ const cpuDeltaSec = res.cpu - h.lastCpuTime;
182
+ if (timeDeltaSec > 0 && cpuDeltaSec >= 0) {
183
+ // Max it at 100% per core? We'll just display the total usage
184
+ cpuPercent = (cpuDeltaSec / timeDeltaSec) * 100;
185
+ }
186
+ }
187
+ h.lastCpuTime = res.cpu;
188
+ } else {
189
+ // Unix: it's already a percentage from \`ps\`
190
+ cpuPercent = res.cpu;
191
+ }
192
+
193
+ // Add points
194
+ h.memory.push(res.memory);
195
+ h.cpu.push(cpuPercent);
196
+ if (h.memory.length > 60) h.memory.shift();
197
+ if (h.cpu.length > 60) h.cpu.shift();
198
+ } else {
199
+ // Not running, push zeros if not already zeroed out to bring graphs down
200
+ if (h.memory.length > 0 && h.memory[h.memory.length - 1] !== 0) {
201
+ h.memory.push(0);
202
+ h.cpu.push(0);
203
+ if (h.memory.length > 60) h.memory.shift();
204
+ if (h.cpu.length > 60) h.cpu.shift();
205
+ }
206
+ h.lastCheck = 0;
207
+ h.lastCpuTime = 0;
208
+ }
209
+
210
+ if (running) h.lastCheck = now;
158
211
 
159
212
  return {
160
213
  name: p.name,
@@ -164,7 +217,10 @@ async function fetchProcesses(): Promise<any[]> {
164
217
  running,
165
218
  port: ports.length > 0 ? ports[0] : null,
166
219
  ports,
167
- memory, // Bytes
220
+ memory: res.memory, // Bytes
221
+ cpu: cpuPercent, // Percentage
222
+ memoryHistory: [...h.memory],
223
+ cpuHistory: [...h.cpu],
168
224
  group: getProcessGroup(p.env),
169
225
  runtime: calculateRuntime(p.timestamp),
170
226
  timestamp: p.timestamp,
@@ -172,6 +228,7 @@ async function fetchProcesses(): Promise<any[]> {
172
228
  configPath: p.configPath || '',
173
229
  stdoutPath: p.stdout_path || '',
174
230
  stderrPath: p.stderr_path || '',
231
+ guardRestarts: guardRestartCounts.get(p.name) || 0,
175
232
  };
176
233
  });
177
234
  }) ?? [];