loki-mode 5.42.2 → 5.46.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 +4 -3
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +684 -0
- package/autonomy/checklist-verify.py +368 -0
- package/autonomy/completion-council.sh +49 -0
- package/autonomy/loki +83 -0
- package/autonomy/playwright-verify.sh +350 -0
- package/autonomy/prd-analyzer.py +457 -0
- package/autonomy/prd-checklist.sh +223 -0
- package/autonomy/run.sh +164 -4
- package/completions/loki.bash +6 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +134 -1
- package/dashboard/static/index.html +804 -265
- package/docs/INSTALLATION.md +1 -1
- package/docs/audit-logging.md +600 -0
- package/docs/authentication.md +374 -0
- package/docs/authorization.md +455 -0
- package/docs/git-workflow.md +446 -0
- package/docs/metrics.md +527 -0
- package/docs/network-security.md +275 -0
- package/docs/openclaw-integration.md +572 -0
- package/docs/siem-integration.md +579 -0
- package/learning/__init__.py +1 -1
- package/mcp/__init__.py +1 -1
- package/memory/__init__.py +2 -0
- package/package.json +2 -1
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#===============================================================================
|
|
3
|
+
# App Runner Module (v5.45.0)
|
|
4
|
+
#
|
|
5
|
+
# Detects, starts, restarts, and monitors user applications during autonomous
|
|
6
|
+
# Loki Mode sessions. Auto-restarts on code changes, provides health checks,
|
|
7
|
+
# and integrates with the dashboard and completion council.
|
|
8
|
+
#
|
|
9
|
+
# Functions:
|
|
10
|
+
# app_runner_init() - Detect app type and prerequisites
|
|
11
|
+
# app_runner_start() - Start the detected application
|
|
12
|
+
# app_runner_stop() - Stop the running application
|
|
13
|
+
# app_runner_restart() - Restart (stop + start)
|
|
14
|
+
# app_runner_health_check() - Check if app is healthy (HTTP or PID)
|
|
15
|
+
# app_runner_should_restart() - Check if code changes warrant restart
|
|
16
|
+
# app_runner_cleanup() - Full cleanup on session exit
|
|
17
|
+
# app_runner_status() - One-line status for prompt injection
|
|
18
|
+
# app_runner_watchdog() - Auto-restart on crash (with circuit breaker)
|
|
19
|
+
#
|
|
20
|
+
# Environment Variables:
|
|
21
|
+
# LOKI_APP_RUNNER - Enable/disable (default: true)
|
|
22
|
+
# LOKI_APP_RUNNER_ENABLED - Alias for LOKI_APP_RUNNER
|
|
23
|
+
# LOKI_APP_PORT - Override detected port
|
|
24
|
+
# LOKI_APP_COMMAND - Override app start command
|
|
25
|
+
#
|
|
26
|
+
# Data:
|
|
27
|
+
# .loki/app-runner/state.json - App state
|
|
28
|
+
# .loki/app-runner/app.pid - Process ID
|
|
29
|
+
# .loki/app-runner/app.log - Application stdout/stderr
|
|
30
|
+
# .loki/app-runner/health.json - Last health check
|
|
31
|
+
# .loki/app-runner/detection.json - Detection results
|
|
32
|
+
#
|
|
33
|
+
#===============================================================================
|
|
34
|
+
|
|
35
|
+
# Configuration
|
|
36
|
+
APP_RUNNER_ENABLED="${LOKI_APP_RUNNER:-${LOKI_APP_RUNNER_ENABLED:-true}}"
|
|
37
|
+
|
|
38
|
+
# Internal state
|
|
39
|
+
_APP_RUNNER_DIR=""
|
|
40
|
+
_APP_RUNNER_METHOD=""
|
|
41
|
+
_APP_RUNNER_PORT=""
|
|
42
|
+
_APP_RUNNER_PID=""
|
|
43
|
+
_APP_RUNNER_URL=""
|
|
44
|
+
_APP_RUNNER_IS_DOCKER=false
|
|
45
|
+
_APP_RUNNER_CRASH_COUNT=0
|
|
46
|
+
_APP_RUNNER_RESTART_COUNT=0
|
|
47
|
+
_GIT_DIFF_HASH=""
|
|
48
|
+
_APP_LOG_MAX_LINES=10000
|
|
49
|
+
|
|
50
|
+
#===============================================================================
|
|
51
|
+
# Internal Helpers
|
|
52
|
+
#===============================================================================
|
|
53
|
+
|
|
54
|
+
_app_runner_dir() {
|
|
55
|
+
local loki_dir="${TARGET_DIR:-.}/.loki"
|
|
56
|
+
_APP_RUNNER_DIR="$loki_dir/app-runner"
|
|
57
|
+
mkdir -p "$_APP_RUNNER_DIR"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Escape a string for safe JSON embedding (handles quotes, backslashes, newlines)
|
|
61
|
+
_json_escape() {
|
|
62
|
+
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr -d '\n'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Validate a command string: reject shell metacharacters that enable injection
|
|
66
|
+
_validate_app_command() {
|
|
67
|
+
local cmd="$1"
|
|
68
|
+
# Allow alphanumeric, spaces, hyphens, underscores, dots, slashes, colons, equals
|
|
69
|
+
# Reject semicolons, pipes, backticks, $(), &&, ||, redirects used for injection
|
|
70
|
+
if echo "$cmd" | grep -qE '[;|`$]|&&|\|\||>>|<<'; then
|
|
71
|
+
log_error "App Runner: command rejected (unsafe characters): $cmd"
|
|
72
|
+
return 1
|
|
73
|
+
fi
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Atomic JSON write: write to temp then mv
|
|
78
|
+
_write_app_state() {
|
|
79
|
+
local tmp_file
|
|
80
|
+
tmp_file="$_APP_RUNNER_DIR/state.json.tmp.$$"
|
|
81
|
+
local method_escaped
|
|
82
|
+
method_escaped=$(_json_escape "${_APP_RUNNER_METHOD}")
|
|
83
|
+
local url_escaped
|
|
84
|
+
url_escaped=$(_json_escape "${_APP_RUNNER_URL}")
|
|
85
|
+
cat > "$tmp_file" << APPSTATE_EOF
|
|
86
|
+
{
|
|
87
|
+
"main_pid": ${_APP_RUNNER_PID:-0},
|
|
88
|
+
"process_group": "-${_APP_RUNNER_PID:-0}",
|
|
89
|
+
"method": "${method_escaped}",
|
|
90
|
+
"port": ${_APP_RUNNER_PORT:-0},
|
|
91
|
+
"url": "${url_escaped}",
|
|
92
|
+
"started_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
93
|
+
"restart_count": ${_APP_RUNNER_RESTART_COUNT},
|
|
94
|
+
"status": "${1:-unknown}",
|
|
95
|
+
"last_health": $(cat "$_APP_RUNNER_DIR/health.json" 2>/dev/null || echo '{"ok": false}'),
|
|
96
|
+
"crash_count": ${_APP_RUNNER_CRASH_COUNT}
|
|
97
|
+
}
|
|
98
|
+
APPSTATE_EOF
|
|
99
|
+
mv "$tmp_file" "$_APP_RUNNER_DIR/state.json"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_write_health() {
|
|
103
|
+
local ok="$1"
|
|
104
|
+
local tmp_file
|
|
105
|
+
tmp_file="$_APP_RUNNER_DIR/health.json.tmp.$$"
|
|
106
|
+
cat > "$tmp_file" << HEALTH_EOF
|
|
107
|
+
{"ok": ${ok}, "checked_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"}
|
|
108
|
+
HEALTH_EOF
|
|
109
|
+
mv "$tmp_file" "$_APP_RUNNER_DIR/health.json"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Rotate app.log if it exceeds max lines
|
|
113
|
+
_rotate_app_log() {
|
|
114
|
+
local log_file="$_APP_RUNNER_DIR/app.log"
|
|
115
|
+
if [ -f "$log_file" ]; then
|
|
116
|
+
local line_count
|
|
117
|
+
line_count=$(wc -l < "$log_file" 2>/dev/null || echo 0)
|
|
118
|
+
if [ "$line_count" -gt "$_APP_LOG_MAX_LINES" ]; then
|
|
119
|
+
local keep=$(( _APP_LOG_MAX_LINES / 2 ))
|
|
120
|
+
tail -n "$keep" "$log_file" > "$log_file.tmp.$$"
|
|
121
|
+
mv "$log_file.tmp.$$" "$log_file"
|
|
122
|
+
fi
|
|
123
|
+
fi
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Detect port from project files
|
|
127
|
+
_detect_port() {
|
|
128
|
+
local method="$1"
|
|
129
|
+
|
|
130
|
+
# User override takes priority
|
|
131
|
+
if [ -n "${LOKI_APP_PORT:-}" ]; then
|
|
132
|
+
_APP_RUNNER_PORT="$LOKI_APP_PORT"
|
|
133
|
+
return
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
case "$method" in
|
|
137
|
+
*docker\ compose*)
|
|
138
|
+
# Parse port from compose file
|
|
139
|
+
local compose_file
|
|
140
|
+
if [ -f "${TARGET_DIR:-.}/docker-compose.yml" ]; then
|
|
141
|
+
compose_file="${TARGET_DIR:-.}/docker-compose.yml"
|
|
142
|
+
else
|
|
143
|
+
compose_file="${TARGET_DIR:-.}/compose.yml"
|
|
144
|
+
fi
|
|
145
|
+
local port
|
|
146
|
+
port=$(grep -E '^\s*-\s*"?[0-9]+:[0-9]+"?' "$compose_file" 2>/dev/null | head -1 | sed 's/.*"\?\([0-9]*\):[0-9]*"\?.*/\1/')
|
|
147
|
+
_APP_RUNNER_PORT="${port:-8080}"
|
|
148
|
+
;;
|
|
149
|
+
*docker\ build*)
|
|
150
|
+
local port
|
|
151
|
+
port=$(grep -i '^EXPOSE' "${TARGET_DIR:-.}/Dockerfile" 2>/dev/null | head -1 | awk '{print $2}')
|
|
152
|
+
_APP_RUNNER_PORT="${port:-8080}"
|
|
153
|
+
;;
|
|
154
|
+
*npm*)
|
|
155
|
+
# Check .env for PORT, then common defaults
|
|
156
|
+
if [ -f "${TARGET_DIR:-.}/.env" ]; then
|
|
157
|
+
local port
|
|
158
|
+
port=$(grep -E '^PORT=' "${TARGET_DIR:-.}/.env" 2>/dev/null | head -1 | cut -d= -f2 | tr -d '"' | tr -d "'")
|
|
159
|
+
if [ -n "$port" ]; then
|
|
160
|
+
_APP_RUNNER_PORT="$port"
|
|
161
|
+
return
|
|
162
|
+
fi
|
|
163
|
+
fi
|
|
164
|
+
# Check for Vite (5173), Astro (4321), or default Node (3000)
|
|
165
|
+
if grep -q '"vite"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then
|
|
166
|
+
_APP_RUNNER_PORT=5173
|
|
167
|
+
elif grep -q '"astro"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then
|
|
168
|
+
_APP_RUNNER_PORT=4321
|
|
169
|
+
else
|
|
170
|
+
_APP_RUNNER_PORT=3000
|
|
171
|
+
fi
|
|
172
|
+
;;
|
|
173
|
+
*manage.py*)
|
|
174
|
+
_APP_RUNNER_PORT=8000
|
|
175
|
+
;;
|
|
176
|
+
*flask*|*app.py*)
|
|
177
|
+
_APP_RUNNER_PORT=5000
|
|
178
|
+
;;
|
|
179
|
+
*uvicorn*|*fastapi*|*main.py*)
|
|
180
|
+
_APP_RUNNER_PORT=8000
|
|
181
|
+
;;
|
|
182
|
+
*cargo*)
|
|
183
|
+
_APP_RUNNER_PORT=8080
|
|
184
|
+
;;
|
|
185
|
+
*go\ run*)
|
|
186
|
+
_APP_RUNNER_PORT=8080
|
|
187
|
+
;;
|
|
188
|
+
*make*)
|
|
189
|
+
_APP_RUNNER_PORT=8080
|
|
190
|
+
;;
|
|
191
|
+
*)
|
|
192
|
+
_APP_RUNNER_PORT=8080
|
|
193
|
+
;;
|
|
194
|
+
esac
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#===============================================================================
|
|
198
|
+
# Detection
|
|
199
|
+
#===============================================================================
|
|
200
|
+
|
|
201
|
+
app_runner_init() {
|
|
202
|
+
if [ "$APP_RUNNER_ENABLED" != "true" ]; then
|
|
203
|
+
return 1
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
_app_runner_dir
|
|
207
|
+
local dir="${TARGET_DIR:-.}"
|
|
208
|
+
_APP_RUNNER_METHOD=""
|
|
209
|
+
|
|
210
|
+
# User command override (validated for safety)
|
|
211
|
+
if [ -n "${LOKI_APP_COMMAND:-}" ]; then
|
|
212
|
+
if ! _validate_app_command "$LOKI_APP_COMMAND"; then
|
|
213
|
+
log_error "App Runner: LOKI_APP_COMMAND rejected due to unsafe characters"
|
|
214
|
+
return 1
|
|
215
|
+
fi
|
|
216
|
+
_APP_RUNNER_METHOD="$LOKI_APP_COMMAND"
|
|
217
|
+
_detect_port "$_APP_RUNNER_METHOD"
|
|
218
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
219
|
+
log_info "App Runner: using override command: $_APP_RUNNER_METHOD"
|
|
220
|
+
_write_detection "override" "$_APP_RUNNER_METHOD"
|
|
221
|
+
return 0
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
# Detection cascade
|
|
225
|
+
# 1. docker-compose.yml / compose.yml
|
|
226
|
+
if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then
|
|
227
|
+
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
228
|
+
_APP_RUNNER_METHOD="docker compose up -d"
|
|
229
|
+
_APP_RUNNER_IS_DOCKER=true
|
|
230
|
+
_detect_port "$_APP_RUNNER_METHOD"
|
|
231
|
+
_write_detection "docker-compose" "$_APP_RUNNER_METHOD"
|
|
232
|
+
log_info "App Runner: detected Docker Compose project"
|
|
233
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
234
|
+
return 0
|
|
235
|
+
else
|
|
236
|
+
log_warn "App Runner: docker-compose.yml found but Docker is not running"
|
|
237
|
+
fi
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
# 2. Dockerfile
|
|
241
|
+
if [ -f "$dir/Dockerfile" ]; then
|
|
242
|
+
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
243
|
+
_detect_port "docker build"
|
|
244
|
+
_APP_RUNNER_METHOD="docker build -t loki-app . && docker run -d -p ${_APP_RUNNER_PORT}:${_APP_RUNNER_PORT} --name loki-app-container loki-app"
|
|
245
|
+
_APP_RUNNER_IS_DOCKER=true
|
|
246
|
+
_write_detection "dockerfile" "$_APP_RUNNER_METHOD"
|
|
247
|
+
log_info "App Runner: detected Dockerfile"
|
|
248
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
249
|
+
return 0
|
|
250
|
+
else
|
|
251
|
+
log_warn "App Runner: Dockerfile found but Docker is not running"
|
|
252
|
+
fi
|
|
253
|
+
fi
|
|
254
|
+
|
|
255
|
+
# 3-4. package.json (dev or start)
|
|
256
|
+
if [ -f "$dir/package.json" ]; then
|
|
257
|
+
_install_node_deps "$dir"
|
|
258
|
+
if grep -q '"dev"' "$dir/package.json" 2>/dev/null; then
|
|
259
|
+
_APP_RUNNER_METHOD="npm run dev"
|
|
260
|
+
_detect_port "$_APP_RUNNER_METHOD"
|
|
261
|
+
_write_detection "npm-dev" "$_APP_RUNNER_METHOD"
|
|
262
|
+
log_info "App Runner: detected npm run dev"
|
|
263
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
264
|
+
return 0
|
|
265
|
+
elif grep -q '"start"' "$dir/package.json" 2>/dev/null; then
|
|
266
|
+
_APP_RUNNER_METHOD="npm start"
|
|
267
|
+
_detect_port "$_APP_RUNNER_METHOD"
|
|
268
|
+
_write_detection "npm-start" "$_APP_RUNNER_METHOD"
|
|
269
|
+
log_info "App Runner: detected npm start"
|
|
270
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
271
|
+
return 0
|
|
272
|
+
fi
|
|
273
|
+
fi
|
|
274
|
+
|
|
275
|
+
# 5. Makefile with run or serve target
|
|
276
|
+
if [ -f "$dir/Makefile" ]; then
|
|
277
|
+
if grep -qE '^(run|serve):' "$dir/Makefile" 2>/dev/null; then
|
|
278
|
+
local target
|
|
279
|
+
if grep -qE '^run:' "$dir/Makefile" 2>/dev/null; then
|
|
280
|
+
target="run"
|
|
281
|
+
else
|
|
282
|
+
target="serve"
|
|
283
|
+
fi
|
|
284
|
+
_APP_RUNNER_METHOD="make $target"
|
|
285
|
+
_detect_port "$_APP_RUNNER_METHOD"
|
|
286
|
+
_write_detection "makefile" "$_APP_RUNNER_METHOD"
|
|
287
|
+
log_info "App Runner: detected Makefile target '$target'"
|
|
288
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
289
|
+
return 0
|
|
290
|
+
fi
|
|
291
|
+
fi
|
|
292
|
+
|
|
293
|
+
# 6. Django manage.py
|
|
294
|
+
if [ -f "$dir/manage.py" ]; then
|
|
295
|
+
_install_python_deps "$dir"
|
|
296
|
+
_APP_RUNNER_METHOD="python manage.py runserver"
|
|
297
|
+
_detect_port "$_APP_RUNNER_METHOD"
|
|
298
|
+
_write_detection "django" "$_APP_RUNNER_METHOD"
|
|
299
|
+
log_info "App Runner: detected Django project"
|
|
300
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
301
|
+
return 0
|
|
302
|
+
fi
|
|
303
|
+
|
|
304
|
+
# 7. Flask/FastAPI (app.py or main.py)
|
|
305
|
+
if [ -f "$dir/app.py" ]; then
|
|
306
|
+
_install_python_deps "$dir"
|
|
307
|
+
if grep -qE 'from\s+fastapi|import\s+FastAPI' "$dir/app.py" 2>/dev/null; then
|
|
308
|
+
_APP_RUNNER_METHOD="uvicorn app:app --host 0.0.0.0 --port 8000 --reload"
|
|
309
|
+
_detect_port "fastapi"
|
|
310
|
+
elif grep -qE 'from\s+flask|import\s+Flask' "$dir/app.py" 2>/dev/null; then
|
|
311
|
+
_APP_RUNNER_METHOD="flask run --host 0.0.0.0 --port 5000"
|
|
312
|
+
_detect_port "flask"
|
|
313
|
+
else
|
|
314
|
+
_APP_RUNNER_METHOD="python app.py"
|
|
315
|
+
_detect_port "app.py"
|
|
316
|
+
fi
|
|
317
|
+
_write_detection "python-app" "$_APP_RUNNER_METHOD"
|
|
318
|
+
log_info "App Runner: detected app.py"
|
|
319
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
320
|
+
return 0
|
|
321
|
+
fi
|
|
322
|
+
|
|
323
|
+
if [ -f "$dir/main.py" ]; then
|
|
324
|
+
_install_python_deps "$dir"
|
|
325
|
+
if grep -qE 'from\s+fastapi|import\s+FastAPI' "$dir/main.py" 2>/dev/null; then
|
|
326
|
+
_APP_RUNNER_METHOD="uvicorn main:app --host 0.0.0.0 --port 8000 --reload"
|
|
327
|
+
_detect_port "fastapi"
|
|
328
|
+
else
|
|
329
|
+
_APP_RUNNER_METHOD="python main.py"
|
|
330
|
+
_detect_port "main.py"
|
|
331
|
+
fi
|
|
332
|
+
_write_detection "python-main" "$_APP_RUNNER_METHOD"
|
|
333
|
+
log_info "App Runner: detected main.py"
|
|
334
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
335
|
+
return 0
|
|
336
|
+
fi
|
|
337
|
+
|
|
338
|
+
# 8. Rust Cargo.toml
|
|
339
|
+
if [ -f "$dir/Cargo.toml" ]; then
|
|
340
|
+
_APP_RUNNER_METHOD="cargo run"
|
|
341
|
+
_detect_port "$_APP_RUNNER_METHOD"
|
|
342
|
+
_write_detection "cargo" "$_APP_RUNNER_METHOD"
|
|
343
|
+
log_info "App Runner: detected Cargo.toml"
|
|
344
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
345
|
+
return 0
|
|
346
|
+
fi
|
|
347
|
+
|
|
348
|
+
# 9. Go module with main.go
|
|
349
|
+
if [ -f "$dir/go.mod" ] && [ -f "$dir/main.go" ]; then
|
|
350
|
+
_APP_RUNNER_METHOD="go run ."
|
|
351
|
+
_detect_port "$_APP_RUNNER_METHOD"
|
|
352
|
+
_write_detection "go" "$_APP_RUNNER_METHOD"
|
|
353
|
+
log_info "App Runner: detected Go project"
|
|
354
|
+
_APP_RUNNER_URL="http://localhost:${_APP_RUNNER_PORT}"
|
|
355
|
+
return 0
|
|
356
|
+
fi
|
|
357
|
+
|
|
358
|
+
# 10. Fallback: nothing detected
|
|
359
|
+
log_warn "App Runner: no application detected, continuing without app runner"
|
|
360
|
+
_write_detection "none" ""
|
|
361
|
+
return 1
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
_write_detection() {
|
|
365
|
+
local type="$1"
|
|
366
|
+
local command="$2"
|
|
367
|
+
local tmp_file="$_APP_RUNNER_DIR/detection.json.tmp.$$"
|
|
368
|
+
local type_escaped
|
|
369
|
+
type_escaped=$(_json_escape "$type")
|
|
370
|
+
local command_escaped
|
|
371
|
+
command_escaped=$(_json_escape "$command")
|
|
372
|
+
cat > "$tmp_file" << DETECT_EOF
|
|
373
|
+
{
|
|
374
|
+
"type": "${type_escaped}",
|
|
375
|
+
"command": "${command_escaped}",
|
|
376
|
+
"port": ${_APP_RUNNER_PORT:-0},
|
|
377
|
+
"is_docker": ${_APP_RUNNER_IS_DOCKER},
|
|
378
|
+
"detected_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
379
|
+
}
|
|
380
|
+
DETECT_EOF
|
|
381
|
+
mv "$tmp_file" "$_APP_RUNNER_DIR/detection.json"
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
# Install node dependencies if missing
|
|
385
|
+
_install_node_deps() {
|
|
386
|
+
local dir="$1"
|
|
387
|
+
if [ -f "$dir/package.json" ] && [ ! -d "$dir/node_modules" ]; then
|
|
388
|
+
log_step "App Runner: installing node dependencies..."
|
|
389
|
+
(cd "$dir" && npm install >> "$_APP_RUNNER_DIR/app.log" 2>&1) || \
|
|
390
|
+
log_warn "App Runner: npm install failed, app may not start"
|
|
391
|
+
fi
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
# Install Python dependencies in background
|
|
395
|
+
_install_python_deps() {
|
|
396
|
+
local dir="$1"
|
|
397
|
+
if [ -f "$dir/requirements.txt" ]; then
|
|
398
|
+
log_step "App Runner: installing Python dependencies (background)..."
|
|
399
|
+
(cd "$dir" && pip install -r requirements.txt >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
|
|
400
|
+
fi
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#===============================================================================
|
|
404
|
+
# Lifecycle
|
|
405
|
+
#===============================================================================
|
|
406
|
+
|
|
407
|
+
app_runner_start() {
|
|
408
|
+
if [ -z "$_APP_RUNNER_METHOD" ]; then
|
|
409
|
+
log_warn "App Runner: no method detected, call app_runner_init first"
|
|
410
|
+
return 1
|
|
411
|
+
fi
|
|
412
|
+
|
|
413
|
+
_app_runner_dir
|
|
414
|
+
local dir="${TARGET_DIR:-.}"
|
|
415
|
+
|
|
416
|
+
# Port conflict check
|
|
417
|
+
if [ -n "$_APP_RUNNER_PORT" ] && [ "$_APP_RUNNER_PORT" -gt 0 ] 2>/dev/null; then
|
|
418
|
+
if lsof -ti:"$_APP_RUNNER_PORT" >/dev/null 2>&1; then
|
|
419
|
+
log_warn "App Runner: port $_APP_RUNNER_PORT already in use, skipping app start"
|
|
420
|
+
return 1
|
|
421
|
+
fi
|
|
422
|
+
fi
|
|
423
|
+
|
|
424
|
+
log_step "App Runner: starting application ($_APP_RUNNER_METHOD on port $_APP_RUNNER_PORT)..."
|
|
425
|
+
_rotate_app_log
|
|
426
|
+
|
|
427
|
+
# Start the process in a new process group
|
|
428
|
+
(cd "$dir" && setsid bash -c "$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
|
|
429
|
+
_APP_RUNNER_PID=$!
|
|
430
|
+
|
|
431
|
+
# Write PID file
|
|
432
|
+
echo "$_APP_RUNNER_PID" > "$_APP_RUNNER_DIR/app.pid"
|
|
433
|
+
|
|
434
|
+
# Capture initial git diff hash for change detection
|
|
435
|
+
_GIT_DIFF_HASH=$(cd "$dir" && git diff --stat 2>/dev/null | md5sum 2>/dev/null | awk '{print $1}' || echo "none")
|
|
436
|
+
|
|
437
|
+
# Brief pause for process to initialize
|
|
438
|
+
sleep 2
|
|
439
|
+
|
|
440
|
+
# Verify process started
|
|
441
|
+
if kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
|
|
442
|
+
_write_app_state "running"
|
|
443
|
+
log_info "App Runner: application started (PID: $_APP_RUNNER_PID)"
|
|
444
|
+
return 0
|
|
445
|
+
else
|
|
446
|
+
log_error "App Runner: application failed to start"
|
|
447
|
+
_APP_RUNNER_CRASH_COUNT=$(( _APP_RUNNER_CRASH_COUNT + 1 ))
|
|
448
|
+
_write_app_state "failed"
|
|
449
|
+
return 1
|
|
450
|
+
fi
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
app_runner_stop() {
|
|
454
|
+
_app_runner_dir
|
|
455
|
+
|
|
456
|
+
if [ -z "$_APP_RUNNER_PID" ] && [ -f "$_APP_RUNNER_DIR/app.pid" ]; then
|
|
457
|
+
_APP_RUNNER_PID=$(cat "$_APP_RUNNER_DIR/app.pid" 2>/dev/null)
|
|
458
|
+
fi
|
|
459
|
+
|
|
460
|
+
if [ -z "$_APP_RUNNER_PID" ]; then
|
|
461
|
+
log_info "App Runner: no running process to stop"
|
|
462
|
+
return 0
|
|
463
|
+
fi
|
|
464
|
+
|
|
465
|
+
log_step "App Runner: stopping application (PID: $_APP_RUNNER_PID)..."
|
|
466
|
+
|
|
467
|
+
# Docker cleanup
|
|
468
|
+
if [ "$_APP_RUNNER_IS_DOCKER" = true ]; then
|
|
469
|
+
docker stop loki-app-container 2>/dev/null || true
|
|
470
|
+
docker rm loki-app-container 2>/dev/null || true
|
|
471
|
+
if echo "$_APP_RUNNER_METHOD" | grep -q "docker compose"; then
|
|
472
|
+
(cd "${TARGET_DIR:-.}" && docker compose down 2>/dev/null) || true
|
|
473
|
+
fi
|
|
474
|
+
fi
|
|
475
|
+
|
|
476
|
+
# Send SIGTERM to process group
|
|
477
|
+
kill -TERM "-$_APP_RUNNER_PID" 2>/dev/null || kill -TERM "$_APP_RUNNER_PID" 2>/dev/null || true
|
|
478
|
+
|
|
479
|
+
# Wait up to 5 seconds for graceful shutdown
|
|
480
|
+
local waited=0
|
|
481
|
+
while [ "$waited" -lt 5 ]; do
|
|
482
|
+
if ! kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
|
|
483
|
+
break
|
|
484
|
+
fi
|
|
485
|
+
sleep 1
|
|
486
|
+
waited=$(( waited + 1 ))
|
|
487
|
+
done
|
|
488
|
+
|
|
489
|
+
# Force kill if still running
|
|
490
|
+
if kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
|
|
491
|
+
log_warn "App Runner: process did not stop gracefully, sending SIGKILL"
|
|
492
|
+
kill -KILL "-$_APP_RUNNER_PID" 2>/dev/null || kill -KILL "$_APP_RUNNER_PID" 2>/dev/null || true
|
|
493
|
+
fi
|
|
494
|
+
|
|
495
|
+
rm -f "$_APP_RUNNER_DIR/app.pid"
|
|
496
|
+
_write_app_state "stopped"
|
|
497
|
+
log_info "App Runner: application stopped"
|
|
498
|
+
_APP_RUNNER_PID=""
|
|
499
|
+
return 0
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
app_runner_restart() {
|
|
503
|
+
_APP_RUNNER_RESTART_COUNT=$(( _APP_RUNNER_RESTART_COUNT + 1 ))
|
|
504
|
+
log_step "App Runner: restarting (restart #$_APP_RUNNER_RESTART_COUNT)..."
|
|
505
|
+
app_runner_stop
|
|
506
|
+
sleep 1
|
|
507
|
+
app_runner_start
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
#===============================================================================
|
|
511
|
+
# Health Check
|
|
512
|
+
#===============================================================================
|
|
513
|
+
|
|
514
|
+
app_runner_health_check() {
|
|
515
|
+
_app_runner_dir
|
|
516
|
+
|
|
517
|
+
# Read PID from file if not in memory
|
|
518
|
+
if [ -z "$_APP_RUNNER_PID" ] && [ -f "$_APP_RUNNER_DIR/app.pid" ]; then
|
|
519
|
+
_APP_RUNNER_PID=$(cat "$_APP_RUNNER_DIR/app.pid" 2>/dev/null)
|
|
520
|
+
fi
|
|
521
|
+
|
|
522
|
+
if [ -z "$_APP_RUNNER_PID" ]; then
|
|
523
|
+
_write_health "false"
|
|
524
|
+
return 1
|
|
525
|
+
fi
|
|
526
|
+
|
|
527
|
+
# Check PID is alive
|
|
528
|
+
if ! kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
|
|
529
|
+
_write_health "false"
|
|
530
|
+
return 1
|
|
531
|
+
fi
|
|
532
|
+
|
|
533
|
+
# For HTTP apps, try an HTTP health check
|
|
534
|
+
if [ -n "$_APP_RUNNER_PORT" ] && [ "$_APP_RUNNER_PORT" -gt 0 ] 2>/dev/null; then
|
|
535
|
+
if curl -sf -o /dev/null -m 5 "http://localhost:${_APP_RUNNER_PORT}/" 2>/dev/null; then
|
|
536
|
+
_write_health "true"
|
|
537
|
+
_write_app_state "running"
|
|
538
|
+
return 0
|
|
539
|
+
else
|
|
540
|
+
# HTTP failed but process alive -- may be a non-HTTP app or still starting
|
|
541
|
+
_write_health "true"
|
|
542
|
+
return 0
|
|
543
|
+
fi
|
|
544
|
+
fi
|
|
545
|
+
|
|
546
|
+
# Non-HTTP: PID alive is sufficient
|
|
547
|
+
_write_health "true"
|
|
548
|
+
return 0
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
#===============================================================================
|
|
552
|
+
# Change Detection
|
|
553
|
+
#===============================================================================
|
|
554
|
+
|
|
555
|
+
app_runner_should_restart() {
|
|
556
|
+
local dir="${TARGET_DIR:-.}"
|
|
557
|
+
|
|
558
|
+
# Get current git diff hash
|
|
559
|
+
local current_hash
|
|
560
|
+
current_hash=$(cd "$dir" && git diff --stat 2>/dev/null | md5sum 2>/dev/null | awk '{print $1}' || echo "none")
|
|
561
|
+
|
|
562
|
+
# No change
|
|
563
|
+
if [ "$current_hash" = "$_GIT_DIFF_HASH" ]; then
|
|
564
|
+
return 1
|
|
565
|
+
fi
|
|
566
|
+
|
|
567
|
+
# Check if changes are docs-only (.md, .txt, .rst)
|
|
568
|
+
local changed_files
|
|
569
|
+
changed_files=$(cd "$dir" && git diff --name-only 2>/dev/null || echo "")
|
|
570
|
+
if [ -n "$changed_files" ]; then
|
|
571
|
+
local non_doc_changes
|
|
572
|
+
non_doc_changes=$(echo "$changed_files" | grep -vE '\.(md|txt|rst)$' || true)
|
|
573
|
+
if [ -z "$non_doc_changes" ]; then
|
|
574
|
+
# Only documentation changes, skip restart
|
|
575
|
+
_GIT_DIFF_HASH="$current_hash"
|
|
576
|
+
return 1
|
|
577
|
+
fi
|
|
578
|
+
fi
|
|
579
|
+
|
|
580
|
+
# Source files changed, update hash
|
|
581
|
+
_GIT_DIFF_HASH="$current_hash"
|
|
582
|
+
return 0
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
#===============================================================================
|
|
586
|
+
# Watchdog
|
|
587
|
+
#===============================================================================
|
|
588
|
+
|
|
589
|
+
app_runner_watchdog() {
|
|
590
|
+
_app_runner_dir
|
|
591
|
+
|
|
592
|
+
if [ -z "$_APP_RUNNER_PID" ] && [ -f "$_APP_RUNNER_DIR/app.pid" ]; then
|
|
593
|
+
_APP_RUNNER_PID=$(cat "$_APP_RUNNER_DIR/app.pid" 2>/dev/null)
|
|
594
|
+
fi
|
|
595
|
+
|
|
596
|
+
# No process to watch
|
|
597
|
+
if [ -z "$_APP_RUNNER_PID" ]; then
|
|
598
|
+
return 0
|
|
599
|
+
fi
|
|
600
|
+
|
|
601
|
+
# Process alive, nothing to do
|
|
602
|
+
if kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
|
|
603
|
+
return 0
|
|
604
|
+
fi
|
|
605
|
+
|
|
606
|
+
# Process is dead
|
|
607
|
+
_APP_RUNNER_CRASH_COUNT=$(( _APP_RUNNER_CRASH_COUNT + 1 ))
|
|
608
|
+
log_warn "App Runner: process died (crash #$_APP_RUNNER_CRASH_COUNT)"
|
|
609
|
+
|
|
610
|
+
# Circuit breaker: stop retrying after 5 crashes
|
|
611
|
+
if [ "$_APP_RUNNER_CRASH_COUNT" -ge 5 ]; then
|
|
612
|
+
log_error "App Runner: crash limit reached (5), marking as crashed"
|
|
613
|
+
log_error "App Runner: last 20 lines of app.log:"
|
|
614
|
+
tail -20 "$_APP_RUNNER_DIR/app.log" 2>/dev/null | while IFS= read -r line; do
|
|
615
|
+
log_error " $line"
|
|
616
|
+
done
|
|
617
|
+
_write_app_state "crashed"
|
|
618
|
+
rm -f "$_APP_RUNNER_DIR/app.pid"
|
|
619
|
+
_APP_RUNNER_PID=""
|
|
620
|
+
return 1
|
|
621
|
+
fi
|
|
622
|
+
|
|
623
|
+
# Exponential backoff: 2^crash_count seconds, max 30
|
|
624
|
+
local backoff=$(( 1 << _APP_RUNNER_CRASH_COUNT ))
|
|
625
|
+
if [ "$backoff" -gt 30 ]; then
|
|
626
|
+
backoff=30
|
|
627
|
+
fi
|
|
628
|
+
log_info "App Runner: auto-restarting in ${backoff}s..."
|
|
629
|
+
sleep "$backoff"
|
|
630
|
+
|
|
631
|
+
# Clear PID and restart
|
|
632
|
+
rm -f "$_APP_RUNNER_DIR/app.pid"
|
|
633
|
+
_APP_RUNNER_PID=""
|
|
634
|
+
app_runner_start
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
#===============================================================================
|
|
638
|
+
# Cleanup
|
|
639
|
+
#===============================================================================
|
|
640
|
+
|
|
641
|
+
app_runner_cleanup() {
|
|
642
|
+
_app_runner_dir
|
|
643
|
+
log_step "App Runner: cleaning up..."
|
|
644
|
+
|
|
645
|
+
# Stop running process
|
|
646
|
+
app_runner_stop
|
|
647
|
+
|
|
648
|
+
# Docker-specific cleanup
|
|
649
|
+
if [ "$_APP_RUNNER_IS_DOCKER" = true ]; then
|
|
650
|
+
docker stop loki-app-container 2>/dev/null || true
|
|
651
|
+
docker rm loki-app-container 2>/dev/null || true
|
|
652
|
+
if echo "$_APP_RUNNER_METHOD" | grep -q "docker compose"; then
|
|
653
|
+
(cd "${TARGET_DIR:-.}" && docker compose down 2>/dev/null) || true
|
|
654
|
+
fi
|
|
655
|
+
fi
|
|
656
|
+
|
|
657
|
+
# Remove PID file
|
|
658
|
+
rm -f "$_APP_RUNNER_DIR/app.pid"
|
|
659
|
+
|
|
660
|
+
# Update state
|
|
661
|
+
_write_app_state "stopped"
|
|
662
|
+
log_info "App Runner: cleanup complete"
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
#===============================================================================
|
|
666
|
+
# Status
|
|
667
|
+
#===============================================================================
|
|
668
|
+
|
|
669
|
+
app_runner_status() {
|
|
670
|
+
_app_runner_dir
|
|
671
|
+
|
|
672
|
+
if [ -z "$_APP_RUNNER_METHOD" ]; then
|
|
673
|
+
echo "App Runner: not initialized"
|
|
674
|
+
return
|
|
675
|
+
fi
|
|
676
|
+
|
|
677
|
+
local status="unknown"
|
|
678
|
+
if [ -f "$_APP_RUNNER_DIR/state.json" ]; then
|
|
679
|
+
# Extract status from state file (simple grep, no jq dependency)
|
|
680
|
+
status=$(grep -o '"status": *"[^"]*"' "$_APP_RUNNER_DIR/state.json" 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)"/\1/')
|
|
681
|
+
fi
|
|
682
|
+
|
|
683
|
+
echo "App Runner: ${status} | ${_APP_RUNNER_METHOD} | port ${_APP_RUNNER_PORT:-none} | crashes ${_APP_RUNNER_CRASH_COUNT} | restarts ${_APP_RUNNER_RESTART_COUNT}"
|
|
684
|
+
}
|