bgrun 3.10.2 → 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 +76 -2
- package/dashboard/app/api/deps/route.ts +49 -0
- package/dashboard/app/api/guard/route.ts +1 -1
- package/dashboard/app/api/guard-all/route.ts +50 -0
- package/dashboard/app/api/logs/rotate/route.ts +45 -0
- package/dashboard/app/api/processes/route.ts +67 -10
- package/dashboard/app/globals.css +386 -6
- package/dashboard/app/page.client.tsx +257 -8
- package/dashboard/app/page.tsx +20 -1
- package/dist/index.js +452 -36
- package/package.json +61 -60
- package/src/api.ts +3 -3
- package/src/commands/list.ts +3 -3
- package/src/commands/run.ts +17 -0
- package/src/deps.ts +126 -0
- package/src/guard.ts +157 -0
- package/src/index.ts +108 -3
- package/src/log-rotation.ts +93 -0
- package/src/logger.ts +4 -3
- package/src/platform.ts +39 -23
- package/src/server.ts +43 -3
package/package.json
CHANGED
|
@@ -1,60 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "bgrun",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "bgrun — A lightweight process manager for Bun",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./src/api.ts",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./src/api.ts"
|
|
9
|
-
},
|
|
10
|
-
"bin": {
|
|
11
|
-
"bgrun": "./dist/index.js"
|
|
12
|
-
},
|
|
13
|
-
"scripts": {
|
|
14
|
-
"build": "bun run ./src/build.ts",
|
|
15
|
-
"test": "bun test",
|
|
16
|
-
"prepublishOnly": "bun run build"
|
|
17
|
-
},
|
|
18
|
-
"files": [
|
|
19
|
-
"dist",
|
|
20
|
-
"src",
|
|
21
|
-
"dashboard/app",
|
|
22
|
-
"README.md",
|
|
23
|
-
"image.png",
|
|
24
|
-
"examples/bgr-startup.sh"
|
|
25
|
-
],
|
|
26
|
-
"keywords": [
|
|
27
|
-
"process-manager",
|
|
28
|
-
"bun",
|
|
29
|
-
"monitoring",
|
|
30
|
-
"devops",
|
|
31
|
-
"deployment",
|
|
32
|
-
"background",
|
|
33
|
-
"daemon"
|
|
34
|
-
],
|
|
35
|
-
"author": "7flash",
|
|
36
|
-
"license": "MIT",
|
|
37
|
-
"repository": {
|
|
38
|
-
"type": "git",
|
|
39
|
-
"url": "https://github.com/7flash/bgrun.git"
|
|
40
|
-
},
|
|
41
|
-
"devDependencies": {
|
|
42
|
-
"bun
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"react
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "bgrun",
|
|
3
|
+
"version": "3.11.0",
|
|
4
|
+
"description": "bgrun — A lightweight process manager for Bun",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/api.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/api.ts"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"bgrun": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "bun run ./src/build.ts",
|
|
15
|
+
"test": "bun test",
|
|
16
|
+
"prepublishOnly": "bun run build"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"dashboard/app",
|
|
22
|
+
"README.md",
|
|
23
|
+
"image.png",
|
|
24
|
+
"examples/bgr-startup.sh"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"process-manager",
|
|
28
|
+
"bun",
|
|
29
|
+
"monitoring",
|
|
30
|
+
"devops",
|
|
31
|
+
"deployment",
|
|
32
|
+
"background",
|
|
33
|
+
"daemon"
|
|
34
|
+
],
|
|
35
|
+
"author": "7flash",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/7flash/bgrun.git"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/bun": "^1.3.10",
|
|
43
|
+
"bun-types": "latest"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"typescript": "^5.0.0"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"boxen": "^8.0.1",
|
|
50
|
+
"chalk": "^5.4.1",
|
|
51
|
+
"dedent": "^1.5.3",
|
|
52
|
+
"measure-fn": "3.10.1",
|
|
53
|
+
"melina": "2.3.3",
|
|
54
|
+
"react": "^19.2.4",
|
|
55
|
+
"react-dom": "^19.2.4",
|
|
56
|
+
"sqlite-zod-orm": "3.26.1"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"bun": ">=1.0.0"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/api.ts
CHANGED
|
@@ -39,7 +39,7 @@ export {
|
|
|
39
39
|
ensureDir,
|
|
40
40
|
getHomeDir,
|
|
41
41
|
isWindows,
|
|
42
|
-
|
|
42
|
+
getProcessBatchResources,
|
|
43
43
|
getProcessMemory
|
|
44
44
|
} from './platform'
|
|
45
45
|
|
|
@@ -51,13 +51,13 @@ export { getVersion, calculateRuntime, parseEnvString, validateDirectory } from
|
|
|
51
51
|
|
|
52
52
|
// --- Default Export (namespace style) ---
|
|
53
53
|
import { getAllProcesses, getProcess, insertProcess, removeProcess, removeProcessByName, removeAllProcesses, retryDatabaseOperation, getDbInfo, dbPath, bgrHome } from './db'
|
|
54
|
-
import { isProcessRunning, terminateProcess, readFileTail, getProcessPorts, findChildPid, findPidByPort, getShellCommand, killProcessOnPort, waitForPortFree, ensureDir, getHomeDir, isWindows,
|
|
54
|
+
import { isProcessRunning, terminateProcess, readFileTail, getProcessPorts, findChildPid, findPidByPort, getShellCommand, killProcessOnPort, waitForPortFree, ensureDir, getHomeDir, isWindows, getProcessBatchResources, getProcessMemory } from './platform'
|
|
55
55
|
import { handleRun } from './commands/run'
|
|
56
56
|
import { getVersion, calculateRuntime, parseEnvString, validateDirectory } from './utils'
|
|
57
57
|
|
|
58
58
|
export default {
|
|
59
59
|
getAllProcesses, getProcess, insertProcess, removeProcess, removeProcessByName, removeAllProcesses, retryDatabaseOperation, getDbInfo, dbPath, bgrHome,
|
|
60
|
-
isProcessRunning, terminateProcess, readFileTail, getProcessPorts, findChildPid, findPidByPort, getShellCommand, killProcessOnPort, waitForPortFree, ensureDir, getHomeDir, isWindows,
|
|
60
|
+
isProcessRunning, terminateProcess, readFileTail, getProcessPorts, findChildPid, findPidByPort, getShellCommand, killProcessOnPort, waitForPortFree, ensureDir, getHomeDir, isWindows, getProcessBatchResources, getProcessMemory,
|
|
61
61
|
handleRun,
|
|
62
62
|
getVersion, calculateRuntime, parseEnvString, validateDirectory,
|
|
63
63
|
}
|
package/src/commands/list.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { ProcessTableRow } from "../table";
|
|
|
4
4
|
import { getAllProcesses } from "../db";
|
|
5
5
|
import { announce } from "../logger";
|
|
6
6
|
import { isProcessRunning, calculateRuntime, parseEnvString } from "../utils";
|
|
7
|
-
import { getProcessPorts,
|
|
7
|
+
import { getProcessPorts, getProcessBatchResources } from "../platform";
|
|
8
8
|
import { measure } from "measure-fn";
|
|
9
9
|
|
|
10
10
|
function formatMemory(bytes: number): string {
|
|
@@ -51,12 +51,12 @@ export async function showAll(opts?: { json?: boolean; filter?: string }) {
|
|
|
51
51
|
|
|
52
52
|
// Batch fetch memory for all PIDs
|
|
53
53
|
const allPids = filtered.map(p => p.pid);
|
|
54
|
-
const
|
|
54
|
+
const resourceMap = await getProcessBatchResources(allPids);
|
|
55
55
|
|
|
56
56
|
for (const proc of filtered) {
|
|
57
57
|
const isRunning = await isProcessRunning(proc.pid, proc.command);
|
|
58
58
|
const runtime = calculateRuntime(proc.timestamp);
|
|
59
|
-
const mem = isRunning ? (
|
|
59
|
+
const mem = isRunning ? (resourceMap.get(proc.pid)?.memory || 0) : 0;
|
|
60
60
|
|
|
61
61
|
const ports = isRunning ? await getProcessPorts(proc.pid) : [];
|
|
62
62
|
tableData.push({
|
package/src/commands/run.ts
CHANGED
|
@@ -17,6 +17,23 @@ export async function handleRun(options: CommandOptions) {
|
|
|
17
17
|
|
|
18
18
|
const existingProcess = name ? getProcess(name) : null;
|
|
19
19
|
|
|
20
|
+
// Auto-start unmet dependencies before starting this process
|
|
21
|
+
if (name && existingProcess) {
|
|
22
|
+
const { getUnmetDeps } = await import('../deps');
|
|
23
|
+
const unmet = await getUnmetDeps(name);
|
|
24
|
+
if (unmet.length > 0) {
|
|
25
|
+
await run.measure(`Start ${unmet.length} dependencies for "${name}"`, async () => {
|
|
26
|
+
for (const depName of unmet) {
|
|
27
|
+
const depProc = getProcess(depName);
|
|
28
|
+
if (depProc) {
|
|
29
|
+
announce(`📦 Starting dependency "${depName}" for "${name}"`, 'Dependency');
|
|
30
|
+
await handleRun({ action: 'run', name: depName, force: true, remoteName: '' });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
if (existingProcess) {
|
|
21
38
|
const finalDirectory = directory || existingProcess.workdir;
|
|
22
39
|
validateDirectory(finalDirectory);
|
package/src/deps.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process Dependency Graph
|
|
3
|
+
*
|
|
4
|
+
* Defines and resolves process startup dependencies.
|
|
5
|
+
* Dependencies are stored in process env as BGR_DEPENDS_ON=name1,name2
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Topological sort for startup order
|
|
9
|
+
* - Cycle detection
|
|
10
|
+
* - Dependency graph as adjacency list
|
|
11
|
+
* - Auto-start dependencies on process run
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getAllProcesses, getProcess } from './db'
|
|
15
|
+
import { isProcessRunning } from './platform'
|
|
16
|
+
import { parseEnvString } from './utils'
|
|
17
|
+
|
|
18
|
+
export interface DepNode {
|
|
19
|
+
name: string
|
|
20
|
+
dependsOn: string[] // processes this depends ON (must start first)
|
|
21
|
+
dependedBy: string[] // processes that depend on THIS
|
|
22
|
+
running: boolean
|
|
23
|
+
pid: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DepGraph {
|
|
27
|
+
nodes: DepNode[]
|
|
28
|
+
order: string[] // topological sort (startup order)
|
|
29
|
+
hasCycle: boolean
|
|
30
|
+
cycleNodes?: string[] // nodes involved in cycle
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parse BGR_DEPENDS_ON from a process env string */
|
|
34
|
+
export function getDependencies(envStr: string): string[] {
|
|
35
|
+
const env = parseEnvString(envStr)
|
|
36
|
+
const raw = env.BGR_DEPENDS_ON || ''
|
|
37
|
+
return raw.split(',').map(s => s.trim()).filter(Boolean)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Build the full dependency graph from all registered processes */
|
|
41
|
+
export async function buildDepGraph(): Promise<DepGraph> {
|
|
42
|
+
const processes = getAllProcesses()
|
|
43
|
+
const nodeMap = new Map<string, DepNode>()
|
|
44
|
+
|
|
45
|
+
// Phase 1: Create all nodes
|
|
46
|
+
for (const proc of processes) {
|
|
47
|
+
const deps = getDependencies(proc.env)
|
|
48
|
+
const alive = await isProcessRunning(proc.pid, proc.command)
|
|
49
|
+
nodeMap.set(proc.name, {
|
|
50
|
+
name: proc.name,
|
|
51
|
+
dependsOn: deps,
|
|
52
|
+
dependedBy: [],
|
|
53
|
+
running: alive,
|
|
54
|
+
pid: proc.pid,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Phase 2: Build reverse edges (dependedBy)
|
|
59
|
+
for (const node of nodeMap.values()) {
|
|
60
|
+
for (const dep of node.dependsOn) {
|
|
61
|
+
const depNode = nodeMap.get(dep)
|
|
62
|
+
if (depNode) {
|
|
63
|
+
depNode.dependedBy.push(node.name)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Phase 3: Topological sort (Kahn's algorithm)
|
|
69
|
+
const inDegree = new Map<string, number>()
|
|
70
|
+
for (const node of nodeMap.values()) {
|
|
71
|
+
inDegree.set(node.name, node.dependsOn.filter(d => nodeMap.has(d)).length)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const queue: string[] = []
|
|
75
|
+
for (const [name, degree] of inDegree) {
|
|
76
|
+
if (degree === 0) queue.push(name)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const order: string[] = []
|
|
80
|
+
while (queue.length > 0) {
|
|
81
|
+
const current = queue.shift()!
|
|
82
|
+
order.push(current)
|
|
83
|
+
|
|
84
|
+
const node = nodeMap.get(current)!
|
|
85
|
+
for (const dependent of node.dependedBy) {
|
|
86
|
+
const newDegree = (inDegree.get(dependent) || 0) - 1
|
|
87
|
+
inDegree.set(dependent, newDegree)
|
|
88
|
+
if (newDegree === 0) queue.push(dependent)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hasCycle = order.length < nodeMap.size
|
|
93
|
+
const cycleNodes = hasCycle
|
|
94
|
+
? [...nodeMap.keys()].filter(n => !order.includes(n))
|
|
95
|
+
: undefined
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
nodes: [...nodeMap.values()],
|
|
99
|
+
order,
|
|
100
|
+
hasCycle,
|
|
101
|
+
cycleNodes,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Get unmet dependencies for a specific process */
|
|
106
|
+
export async function getUnmetDeps(name: string): Promise<string[]> {
|
|
107
|
+
const proc = getProcess(name)
|
|
108
|
+
if (!proc) return []
|
|
109
|
+
|
|
110
|
+
const deps = getDependencies(proc.env)
|
|
111
|
+
const unmet: string[] = []
|
|
112
|
+
|
|
113
|
+
for (const depName of deps) {
|
|
114
|
+
const depProc = getProcess(depName)
|
|
115
|
+
if (!depProc) {
|
|
116
|
+
unmet.push(depName) // dependency not even registered
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const alive = await isProcessRunning(depProc.pid, depProc.command)
|
|
120
|
+
if (!alive) {
|
|
121
|
+
unmet.push(depName)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return unmet
|
|
126
|
+
}
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BGR Standalone Process Guard
|
|
3
|
+
*
|
|
4
|
+
* Runs as an independent process that monitors ALL guarded processes
|
|
5
|
+
* (BGR_KEEP_ALIVE=true) and the dashboard itself. If the dashboard
|
|
6
|
+
* crashes, the guard restarts it. If any guarded process dies, the
|
|
7
|
+
* guard restarts it.
|
|
8
|
+
*
|
|
9
|
+
* This is the "outer shell" — it cannot be killed by a dashboard crash.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* bgrun --guard # Start guard as a managed bgrun process
|
|
13
|
+
* bgrun --_guard-loop # (Internal) Actually run the guard loop
|
|
14
|
+
* bgrun --_guard-loop 30 # Check every 30 seconds
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getAllProcesses, getProcess } from './db';
|
|
18
|
+
import { isProcessRunning, getProcessPorts, findChildPid } from './platform';
|
|
19
|
+
import { handleRun } from './commands/run';
|
|
20
|
+
import { parseEnvString } from './utils';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_INTERVAL_MS = 30_000;
|
|
23
|
+
const MAX_BACKOFF_MS = 5 * 60_000; // 5 minutes max
|
|
24
|
+
const CRASH_THRESHOLD = 5; // Start backoff after this many restarts
|
|
25
|
+
const STABILITY_WINDOW_MS = 120_000; // 2 minutes stable = reset counter
|
|
26
|
+
|
|
27
|
+
interface GuardState {
|
|
28
|
+
restartCounts: Map<string, number>;
|
|
29
|
+
nextRestartTime: Map<string, number>;
|
|
30
|
+
lastSeenAlive: Map<string, number>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const state: GuardState = {
|
|
34
|
+
restartCounts: new Map(),
|
|
35
|
+
nextRestartTime: new Map(),
|
|
36
|
+
lastSeenAlive: new Map(),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
async function restartProcess(name: string): Promise<boolean> {
|
|
40
|
+
try {
|
|
41
|
+
await handleRun({
|
|
42
|
+
action: 'run',
|
|
43
|
+
name,
|
|
44
|
+
force: true,
|
|
45
|
+
remoteName: '',
|
|
46
|
+
});
|
|
47
|
+
return true;
|
|
48
|
+
} catch (err: any) {
|
|
49
|
+
console.error(`[guard] ✗ Failed to restart "${name}": ${err.message}`);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getBackoffMs(restartCount: number): number {
|
|
55
|
+
if (restartCount <= CRASH_THRESHOLD) return 0;
|
|
56
|
+
const exponent = restartCount - CRASH_THRESHOLD;
|
|
57
|
+
return Math.min(30_000 * Math.pow(2, exponent - 1), MAX_BACKOFF_MS);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function guardCycle(): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
const processes = getAllProcesses();
|
|
63
|
+
if (processes.length === 0) return;
|
|
64
|
+
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
let checked = 0;
|
|
67
|
+
let restarted = 0;
|
|
68
|
+
let skipped = 0;
|
|
69
|
+
|
|
70
|
+
for (const proc of processes) {
|
|
71
|
+
// Skip the guard process itself
|
|
72
|
+
if (proc.name === 'bgr-guard') continue;
|
|
73
|
+
|
|
74
|
+
const env = proc.env ? parseEnvString(proc.env) : {};
|
|
75
|
+
const isGuarded = env.BGR_KEEP_ALIVE === 'true';
|
|
76
|
+
const isDashboard = proc.name === 'bgr-dashboard';
|
|
77
|
+
|
|
78
|
+
// Guard both: explicitly guarded processes AND the dashboard
|
|
79
|
+
if (!isGuarded && !isDashboard) continue;
|
|
80
|
+
|
|
81
|
+
checked++;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const alive = await isProcessRunning(proc.pid, proc.command);
|
|
85
|
+
|
|
86
|
+
if (!alive && proc.pid > 0) {
|
|
87
|
+
// Check backoff
|
|
88
|
+
const nextRestart = state.nextRestartTime.get(proc.name) || 0;
|
|
89
|
+
if (now < nextRestart) {
|
|
90
|
+
const waitSecs = Math.round((nextRestart - now) / 1000);
|
|
91
|
+
skipped++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(`[guard] ⚠ "${proc.name}" (PID ${proc.pid}) is dead — restarting...`);
|
|
96
|
+
|
|
97
|
+
const success = await restartProcess(proc.name);
|
|
98
|
+
if (success) {
|
|
99
|
+
const count = (state.restartCounts.get(proc.name) || 0) + 1;
|
|
100
|
+
state.restartCounts.set(proc.name, count);
|
|
101
|
+
state.lastSeenAlive.delete(proc.name);
|
|
102
|
+
|
|
103
|
+
const backoff = getBackoffMs(count);
|
|
104
|
+
if (backoff > 0) {
|
|
105
|
+
state.nextRestartTime.set(proc.name, now + backoff);
|
|
106
|
+
console.log(`[guard] ✓ Restarted "${proc.name}" (#${count}). Crash loop: next check in ${Math.round(backoff / 1000)}s`);
|
|
107
|
+
} else {
|
|
108
|
+
console.log(`[guard] ✓ Restarted "${proc.name}" (#${count})`);
|
|
109
|
+
}
|
|
110
|
+
restarted++;
|
|
111
|
+
}
|
|
112
|
+
} else if (alive) {
|
|
113
|
+
// Track stability — if alive for STABILITY_WINDOW, reset counters
|
|
114
|
+
const count = state.restartCounts.get(proc.name) || 0;
|
|
115
|
+
if (count > 0) {
|
|
116
|
+
const lastSeen = state.lastSeenAlive.get(proc.name);
|
|
117
|
+
if (!lastSeen) {
|
|
118
|
+
state.lastSeenAlive.set(proc.name, now);
|
|
119
|
+
} else if (now - lastSeen > STABILITY_WINDOW_MS) {
|
|
120
|
+
state.restartCounts.delete(proc.name);
|
|
121
|
+
state.nextRestartTime.delete(proc.name);
|
|
122
|
+
state.lastSeenAlive.delete(proc.name);
|
|
123
|
+
console.log(`[guard] ✓ "${proc.name}" stable for ${Math.round(STABILITY_WINDOW_MS / 1000)}s — reset counters`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
console.error(`[guard] Error checking "${proc.name}": ${err.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (restarted > 0) {
|
|
133
|
+
console.log(`[guard] Cycle: ${checked} checked, ${restarted} restarted, ${skipped} in backoff`);
|
|
134
|
+
}
|
|
135
|
+
} catch (err: any) {
|
|
136
|
+
console.error(`[guard] Error in guard cycle: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function startGuardLoop(intervalMs: number = DEFAULT_INTERVAL_MS) {
|
|
141
|
+
const interval = intervalMs || DEFAULT_INTERVAL_MS;
|
|
142
|
+
|
|
143
|
+
console.log(`[guard] ═══════════════════════════════════════════`);
|
|
144
|
+
console.log(`[guard] 🛡️ BGR Standalone Guard started`);
|
|
145
|
+
console.log(`[guard] Check interval: ${interval / 1000}s`);
|
|
146
|
+
console.log(`[guard] Crash backoff threshold: ${CRASH_THRESHOLD} restarts`);
|
|
147
|
+
console.log(`[guard] Stability window: ${STABILITY_WINDOW_MS / 1000}s`);
|
|
148
|
+
console.log(`[guard] Monitoring: BGR_KEEP_ALIVE=true + bgr-dashboard`);
|
|
149
|
+
console.log(`[guard] Started: ${new Date().toLocaleString()}`);
|
|
150
|
+
console.log(`[guard] ═══════════════════════════════════════════`);
|
|
151
|
+
|
|
152
|
+
// Run initial check immediately
|
|
153
|
+
await guardCycle();
|
|
154
|
+
|
|
155
|
+
// Then run on interval
|
|
156
|
+
setInterval(guardCycle, interval);
|
|
157
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { CommandOptions } from "./types";
|
|
|
12
12
|
import { error, announce } from "./logger";
|
|
13
13
|
// startServer is dynamically imported only when --_serve is used
|
|
14
14
|
// to avoid loading melina (which has side-effects) on every bgrun command
|
|
15
|
-
import { getHomeDir, getShellCommand, findChildPid, isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort, waitForPortFree } from "./platform";
|
|
15
|
+
import { getHomeDir, getShellCommand, findChildPid, isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort, waitForPortFree, isPortFree } from "./platform";
|
|
16
16
|
import { insertProcess, removeProcessByName, getProcess, retryDatabaseOperation, getDbInfo } from "./db";
|
|
17
17
|
import dedent from "dedent";
|
|
18
18
|
import chalk from "chalk";
|
|
@@ -38,6 +38,7 @@ async function showHelp() {
|
|
|
38
38
|
bgrun List all processes
|
|
39
39
|
bgrun [name] Show details for a process
|
|
40
40
|
bgrun --dashboard Launch web dashboard (managed by bgrun)
|
|
41
|
+
bgrun --guard Launch standalone process guard
|
|
41
42
|
bgrun --restart [name] Restart a process
|
|
42
43
|
bgrun --restart-all Restart ALL registered processes
|
|
43
44
|
bgrun --stop [name] Stop a process (keep in registry)
|
|
@@ -105,8 +106,10 @@ async function run() {
|
|
|
105
106
|
stdout: { type: 'string' },
|
|
106
107
|
stderr: { type: 'string' },
|
|
107
108
|
dashboard: { type: 'boolean' },
|
|
109
|
+
guard: { type: 'boolean' },
|
|
108
110
|
debug: { type: 'boolean' },
|
|
109
111
|
"_serve": { type: 'boolean' },
|
|
112
|
+
"_guard-loop": { type: 'boolean' },
|
|
110
113
|
port: { type: 'string' },
|
|
111
114
|
},
|
|
112
115
|
strict: false,
|
|
@@ -122,6 +125,15 @@ async function run() {
|
|
|
122
125
|
return;
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
// Internal: actually run the guard loop (spawned by --guard)
|
|
129
|
+
if (values['_guard-loop']) {
|
|
130
|
+
const { startGuardLoop } = await import("./guard");
|
|
131
|
+
const intervalStr = positionals[0];
|
|
132
|
+
const intervalMs = intervalStr ? parseInt(intervalStr) * 1000 : undefined;
|
|
133
|
+
await startGuardLoop(intervalMs);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
125
137
|
// Dashboard: spawn the dashboard server as a bgr-managed process
|
|
126
138
|
if (values.dashboard) {
|
|
127
139
|
const dashboardName = 'bgr-dashboard';
|
|
@@ -186,6 +198,21 @@ async function run() {
|
|
|
186
198
|
spawnEnv.BUN_PORT = requestedPort;
|
|
187
199
|
}
|
|
188
200
|
|
|
201
|
+
// Resolve the target port: --port flag > BUN_PORT env > default 3000
|
|
202
|
+
const targetPort = parseInt(requestedPort || Bun.env.BUN_PORT || '3000');
|
|
203
|
+
if (!isNaN(targetPort) && targetPort > 0) {
|
|
204
|
+
// Auto-kill whatever occupies the target port so dashboard always reclaims it
|
|
205
|
+
const portFree = await isPortFree(targetPort);
|
|
206
|
+
if (!portFree) {
|
|
207
|
+
console.log(chalk.yellow(` ⚡ Port ${targetPort} is occupied — reclaiming...`));
|
|
208
|
+
await killProcessOnPort(targetPort);
|
|
209
|
+
const freed = await waitForPortFree(targetPort, 5000);
|
|
210
|
+
if (!freed) {
|
|
211
|
+
console.log(chalk.red(` ⚠ Could not free port ${targetPort} — dashboard may pick a fallback port`));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
189
216
|
const newProcess = Bun.spawn(getShellCommand(spawnCommand), {
|
|
190
217
|
env: spawnEnv,
|
|
191
218
|
cwd: bgrDir,
|
|
@@ -246,6 +273,85 @@ async function run() {
|
|
|
246
273
|
return;
|
|
247
274
|
}
|
|
248
275
|
|
|
276
|
+
// Guard: spawn the standalone guard as a bgr-managed process
|
|
277
|
+
if (values.guard) {
|
|
278
|
+
const guardName = 'bgr-guard';
|
|
279
|
+
const homePath = getHomeDir();
|
|
280
|
+
const bgrDir = join(homePath, '.bgr');
|
|
281
|
+
|
|
282
|
+
// Check if guard is already running
|
|
283
|
+
const existing = getProcess(guardName);
|
|
284
|
+
if (existing && await isProcessRunning(existing.pid)) {
|
|
285
|
+
announce(
|
|
286
|
+
`Guard is already running (PID ${existing.pid})\n\n` +
|
|
287
|
+
` Use ${chalk.yellow(`bgrun --stop ${guardName}`)} to stop it\n` +
|
|
288
|
+
` Use ${chalk.yellow(`bgrun --guard --force`)} to restart`,
|
|
289
|
+
'BGR Guard'
|
|
290
|
+
);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Kill existing if force
|
|
295
|
+
if (existing) {
|
|
296
|
+
if (await isProcessRunning(existing.pid)) {
|
|
297
|
+
await terminateProcess(existing.pid);
|
|
298
|
+
}
|
|
299
|
+
await retryDatabaseOperation(() => removeProcessByName(guardName));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const { resolve } = require('path');
|
|
303
|
+
const scriptPath = resolve(process.argv[1]);
|
|
304
|
+
const spawnCommand = `bun run ${scriptPath} --_guard-loop`;
|
|
305
|
+
const command = `bgrun --_guard-loop`;
|
|
306
|
+
const stdoutPath = join(bgrDir, `${guardName}-out.txt`);
|
|
307
|
+
const stderrPath = join(bgrDir, `${guardName}-err.txt`);
|
|
308
|
+
|
|
309
|
+
await Bun.write(stdoutPath, '');
|
|
310
|
+
await Bun.write(stderrPath, '');
|
|
311
|
+
|
|
312
|
+
const newProcess = Bun.spawn(getShellCommand(spawnCommand), {
|
|
313
|
+
env: { ...Bun.env },
|
|
314
|
+
cwd: bgrDir,
|
|
315
|
+
stdout: Bun.file(stdoutPath),
|
|
316
|
+
stderr: Bun.file(stderrPath),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
newProcess.unref();
|
|
320
|
+
await sleep(1000);
|
|
321
|
+
const actualPid = await findChildPid(newProcess.pid);
|
|
322
|
+
|
|
323
|
+
await retryDatabaseOperation(() =>
|
|
324
|
+
insertProcess({
|
|
325
|
+
pid: actualPid,
|
|
326
|
+
workdir: bgrDir,
|
|
327
|
+
command,
|
|
328
|
+
name: guardName,
|
|
329
|
+
env: 'BGR_KEEP_ALIVE=false', // Guard doesn't guard itself
|
|
330
|
+
configPath: '',
|
|
331
|
+
stdout_path: stdoutPath,
|
|
332
|
+
stderr_path: stderrPath,
|
|
333
|
+
})
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const msg = dedent`
|
|
337
|
+
${chalk.bold('🛡️ BGR Standalone Guard launched')}
|
|
338
|
+
${chalk.gray('─'.repeat(40))}
|
|
339
|
+
|
|
340
|
+
Monitors: All processes with BGR_KEEP_ALIVE=true
|
|
341
|
+
Also watches: bgr-dashboard (auto-restart if it dies)
|
|
342
|
+
Check interval: 30 seconds
|
|
343
|
+
Backoff: Exponential after 5 rapid crashes
|
|
344
|
+
|
|
345
|
+
${chalk.gray('─'.repeat(40))}
|
|
346
|
+
Process: ${chalk.white(guardName)} | PID: ${chalk.white(String(actualPid))}
|
|
347
|
+
|
|
348
|
+
${chalk.yellow(`bgrun ${guardName} --logs`)} View guard logs
|
|
349
|
+
${chalk.yellow(`bgrun --stop ${guardName}`)} Stop the guard
|
|
350
|
+
`;
|
|
351
|
+
announce(msg, 'BGR Guard');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
249
355
|
if (values.version) {
|
|
250
356
|
console.log(`bgrun version: ${await getVersion()}`);
|
|
251
357
|
return;
|
|
@@ -435,6 +541,5 @@ async function run() {
|
|
|
435
541
|
}
|
|
436
542
|
|
|
437
543
|
run().catch(err => {
|
|
438
|
-
|
|
439
|
-
process.exit(1);
|
|
544
|
+
error(err);
|
|
440
545
|
});
|