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.
@@ -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
+ }