bgrun 3.10.2 → 3.12.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 +84 -4
- package/dashboard/app/api/deps/route.ts +49 -0
- package/dashboard/app/api/events/route.ts +10 -2
- 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 +547 -6
- package/dashboard/app/page.client.tsx +636 -68
- package/dashboard/app/page.tsx +52 -1
- package/dist/index.js +452 -36
- package/package.json +62 -60
- package/scripts/bgr-startup.ps1 +118 -0
- package/src/api.ts +3 -3
- package/src/bgrun.test.ts +109 -0
- 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/src/table.ts +3 -3
package/package.json
CHANGED
|
@@ -1,60 +1,62 @@
|
|
|
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
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "bgrun",
|
|
3
|
+
"version": "3.12.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
|
+
"scripts",
|
|
23
|
+
"README.md",
|
|
24
|
+
"image.png",
|
|
25
|
+
"examples/bgr-startup.sh"
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"process-manager",
|
|
29
|
+
"bun",
|
|
30
|
+
"monitoring",
|
|
31
|
+
"devops",
|
|
32
|
+
"deployment",
|
|
33
|
+
"background",
|
|
34
|
+
"daemon"
|
|
35
|
+
],
|
|
36
|
+
"author": "7flash",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/7flash/bgrun.git"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/bun": "^1.3.10",
|
|
44
|
+
"bun-types": "latest"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"typescript": "^5.0.0"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"boxen": "^8.0.1",
|
|
51
|
+
"chalk": "^5.4.1",
|
|
52
|
+
"dedent": "^1.5.3",
|
|
53
|
+
"measure-fn": "3.10.1",
|
|
54
|
+
"melina": "2.3.6",
|
|
55
|
+
"react": "^19.2.4",
|
|
56
|
+
"react-dom": "^19.2.4",
|
|
57
|
+
"sqlite-zod-orm": "3.26.1"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"bun": ">=1.0.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# bgr-startup.ps1 — Auto-start bgrun guard on Windows login
|
|
2
|
+
# Ensures all guarded processes (and the dashboard itself) start on boot.
|
|
3
|
+
#
|
|
4
|
+
# Installation:
|
|
5
|
+
# 1. Run this script once with -Install flag:
|
|
6
|
+
# powershell -ExecutionPolicy Bypass -File bgr-startup.ps1 -Install
|
|
7
|
+
#
|
|
8
|
+
# 2. Or manually create a Task Scheduler task:
|
|
9
|
+
# - Trigger: At log on
|
|
10
|
+
# - Action: powershell -WindowStyle Hidden -ExecutionPolicy Bypass -File "C:\Code\bgr\scripts\bgr-startup.ps1"
|
|
11
|
+
# - Run whether user is logged on or not: Yes
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# bgr-startup.ps1 # Start bgrun guard
|
|
15
|
+
# bgr-startup.ps1 -Install # Register Task Scheduler entry
|
|
16
|
+
|
|
17
|
+
param(
|
|
18
|
+
[switch]$Install
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
$BunPath = "$env:USERPROFILE\.bun\bin\bun.exe"
|
|
22
|
+
$BgrunPath = "C:\Code\bgr"
|
|
23
|
+
$LogPath = "$env:USERPROFILE\.bgr\startup.log"
|
|
24
|
+
|
|
25
|
+
function Write-Log {
|
|
26
|
+
param([string]$Message)
|
|
27
|
+
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|
28
|
+
$line = "[$timestamp] $Message"
|
|
29
|
+
Write-Host $line
|
|
30
|
+
Add-Content -Path $LogPath -Value $line -ErrorAction SilentlyContinue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Ensure .bgr directory exists
|
|
34
|
+
$bgrDir = "$env:USERPROFILE\.bgr"
|
|
35
|
+
if (-not (Test-Path $bgrDir)) {
|
|
36
|
+
New-Item -ItemType Directory -Path $bgrDir -Force | Out-Null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if ($Install) {
|
|
40
|
+
Write-Log "Installing bgrun auto-start task..."
|
|
41
|
+
|
|
42
|
+
$scriptPath = $PSCommandPath
|
|
43
|
+
if (-not $scriptPath) {
|
|
44
|
+
$scriptPath = Join-Path $BgrunPath "scripts\bgr-startup.ps1"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Remove existing task if present
|
|
48
|
+
$existingTask = Get-ScheduledTask -TaskName "bgrun-guard" -ErrorAction SilentlyContinue
|
|
49
|
+
if ($existingTask) {
|
|
50
|
+
Unregister-ScheduledTask -TaskName "bgrun-guard" -Confirm:$false
|
|
51
|
+
Write-Log "Removed existing bgrun-guard task"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Create the scheduled task
|
|
55
|
+
$action = New-ScheduledTaskAction `
|
|
56
|
+
-Execute "powershell.exe" `
|
|
57
|
+
-Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$scriptPath`""
|
|
58
|
+
|
|
59
|
+
$trigger = New-ScheduledTaskTrigger -AtLogon
|
|
60
|
+
$settings = New-ScheduledTaskSettingsSet `
|
|
61
|
+
-AllowStartIfOnBatteries `
|
|
62
|
+
-DontStopIfGoingOnBatteries `
|
|
63
|
+
-StartWhenAvailable `
|
|
64
|
+
-RestartInterval (New-TimeSpan -Minutes 5) `
|
|
65
|
+
-RestartCount 3
|
|
66
|
+
|
|
67
|
+
Register-ScheduledTask `
|
|
68
|
+
-TaskName "bgrun-guard" `
|
|
69
|
+
-Description "bgrun process manager — auto-starts all guarded processes on login" `
|
|
70
|
+
-Action $action `
|
|
71
|
+
-Trigger $trigger `
|
|
72
|
+
-Settings $settings `
|
|
73
|
+
-RunLevel Highest `
|
|
74
|
+
-Force
|
|
75
|
+
|
|
76
|
+
Write-Log "✓ Task 'bgrun-guard' registered. Will start on next login."
|
|
77
|
+
Write-Log " Script: $scriptPath"
|
|
78
|
+
exit 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# ── Main: Start bgrun guard ─────────────────────────────────
|
|
82
|
+
Write-Log "bgrun startup initiated"
|
|
83
|
+
|
|
84
|
+
# Check bun exists
|
|
85
|
+
if (-not (Test-Path $BunPath)) {
|
|
86
|
+
Write-Log "ERROR: bun not found at $BunPath"
|
|
87
|
+
exit 1
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Check bgrun repo exists
|
|
91
|
+
if (-not (Test-Path "$BgrunPath\src\guard.ts")) {
|
|
92
|
+
Write-Log "ERROR: bgrun not found at $BgrunPath"
|
|
93
|
+
exit 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Start the dashboard first (guard needs it)
|
|
97
|
+
Write-Log "Starting bgrun dashboard..."
|
|
98
|
+
$dashboardProc = Start-Process -FilePath $BunPath `
|
|
99
|
+
-ArgumentList "run", "$BgrunPath\src\index.ts", "--dashboard", "--port", "3000" `
|
|
100
|
+
-WindowStyle Hidden `
|
|
101
|
+
-PassThru `
|
|
102
|
+
-WorkingDirectory $BgrunPath
|
|
103
|
+
|
|
104
|
+
Write-Log "Dashboard PID: $($dashboardProc.Id)"
|
|
105
|
+
|
|
106
|
+
# Wait for dashboard to be ready
|
|
107
|
+
Start-Sleep -Seconds 5
|
|
108
|
+
|
|
109
|
+
# Start the guard (watches dashboard + all guarded processes)
|
|
110
|
+
Write-Log "Starting bgrun guard..."
|
|
111
|
+
$guardProc = Start-Process -FilePath $BunPath `
|
|
112
|
+
-ArgumentList "run", "$BgrunPath\src\index.ts", "--guard" `
|
|
113
|
+
-WindowStyle Hidden `
|
|
114
|
+
-PassThru `
|
|
115
|
+
-WorkingDirectory $BgrunPath
|
|
116
|
+
|
|
117
|
+
Write-Log "Guard PID: $($guardProc.Id)"
|
|
118
|
+
Write-Log "✓ bgrun startup complete. Dashboard: $($dashboardProc.Id), Guard: $($guardProc.Id)"
|
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
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bgrun core utility tests
|
|
3
|
+
*
|
|
4
|
+
* Tests pure logic functions: env parsing, config flattening,
|
|
5
|
+
* string truncation, and runtime calculation.
|
|
6
|
+
*
|
|
7
|
+
* Run: bun test src/bgrun.test.ts
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test'
|
|
10
|
+
import { parseEnvString, calculateRuntime } from './utils'
|
|
11
|
+
import { stripAnsi, truncateString, truncatePath } from './table'
|
|
12
|
+
|
|
13
|
+
// ─── parseEnvString ─────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe('parseEnvString', () => {
|
|
16
|
+
test('parses comma-separated key=value pairs', () => {
|
|
17
|
+
const result = parseEnvString('PORT=3000,HOST=localhost,DEBUG=true')
|
|
18
|
+
expect(result).toEqual({
|
|
19
|
+
PORT: '3000',
|
|
20
|
+
HOST: 'localhost',
|
|
21
|
+
DEBUG: 'true',
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('handles single pair', () => {
|
|
26
|
+
expect(parseEnvString('KEY=value')).toEqual({ KEY: 'value' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('handles empty string', () => {
|
|
30
|
+
expect(parseEnvString('')).toEqual({})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('ignores malformed pairs (no =)', () => {
|
|
34
|
+
const result = parseEnvString('GOOD=yes,BAD,ALSO_GOOD=ok')
|
|
35
|
+
expect(result.GOOD).toBe('yes')
|
|
36
|
+
expect(result.ALSO_GOOD).toBe('ok')
|
|
37
|
+
expect(result.BAD).toBeUndefined()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// ─── calculateRuntime ───────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe('calculateRuntime', () => {
|
|
44
|
+
test('returns 0 minutes for recent start', () => {
|
|
45
|
+
const now = new Date().toISOString()
|
|
46
|
+
expect(calculateRuntime(now)).toBe('0 minutes')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('returns correct minutes', () => {
|
|
50
|
+
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
|
51
|
+
expect(calculateRuntime(fiveMinAgo)).toBe('5 minutes')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('returns correct for 1 hour', () => {
|
|
55
|
+
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
|
|
56
|
+
expect(calculateRuntime(oneHourAgo)).toBe('60 minutes')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ─── stripAnsi ──────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
describe('stripAnsi', () => {
|
|
63
|
+
test('strips color codes', () => {
|
|
64
|
+
const colored = '\u001b[31mred text\u001b[0m'
|
|
65
|
+
expect(stripAnsi(colored)).toBe('red text')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('passes through plain text', () => {
|
|
69
|
+
expect(stripAnsi('hello world')).toBe('hello world')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('handles empty string', () => {
|
|
73
|
+
expect(stripAnsi('')).toBe('')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// ─── truncateString ─────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
describe('truncateString', () => {
|
|
80
|
+
test('returns string unchanged if within limit', () => {
|
|
81
|
+
expect(truncateString('hello', 10)).toBe('hello')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('truncates with ellipsis', () => {
|
|
85
|
+
const result = truncateString('a very long string that exceeds limit', 15)
|
|
86
|
+
expect(result.length).toBeLessThanOrEqual(15)
|
|
87
|
+
expect(result).toContain('…')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('handles maxLength smaller than ellipsis', () => {
|
|
91
|
+
const result = truncateString('hello world', 2)
|
|
92
|
+
expect(result.length).toBeLessThanOrEqual(2)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// ─── truncatePath ───────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('truncatePath', () => {
|
|
99
|
+
test('returns path unchanged if within limit', () => {
|
|
100
|
+
expect(truncatePath('/home/user', 50)).toBe('/home/user')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('truncates middle of long path', () => {
|
|
104
|
+
const longPath = '/home/user/projects/very/deeply/nested/directory/structure'
|
|
105
|
+
const result = truncatePath(longPath, 30)
|
|
106
|
+
expect(result.length).toBeLessThanOrEqual(30)
|
|
107
|
+
expect(result).toContain('…')
|
|
108
|
+
})
|
|
109
|
+
})
|
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
|
+
}
|