diff --git a/src/main/java/org/zaine/app/controller/HealthController.java b/src/main/java/org/zaine/app/controller/HealthController.java deleted file mode 100755 index 98dc4e2..0000000 --- a/src/main/java/org/zaine/app/controller/HealthController.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.zaine.app.controller; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class HealthController { - - @GetMapping("/api/health") - public String health() { - return "OK"; - } -} diff --git a/src/main/java/org/zaine/app/controller/zone/BuildController.java b/src/main/java/org/zaine/app/controller/zone/BuildController.java index b266556..a476e5f 100644 --- a/src/main/java/org/zaine/app/controller/zone/BuildController.java +++ b/src/main/java/org/zaine/app/controller/zone/BuildController.java @@ -7,31 +7,47 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + import java.io.File; import java.io.IOException; import java.io.RandomAccessFile; +import java.lang.management.ManagementFactory; import java.nio.file.Path; import java.time.Instant; +import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; @RestController @RequestMapping("/api") - public class BuildController { // Separate state for each build - private final AtomicBoolean webBuildRunning = new AtomicBoolean(false); + private final AtomicBoolean webBuildRunning = new AtomicBoolean(false); private final AtomicBoolean roamBuildRunning = new AtomicBoolean(false); + private final AtomicBoolean emacsRunning = new AtomicBoolean(false); + private final AtomicBoolean combinedRunning = new AtomicBoolean(false); + // Cancellable process references + private final AtomicReference webProcess = new AtomicReference<>(); + private final AtomicReference roamProcess = new AtomicReference<>(); + private final AtomicReference emacsProcess = new AtomicReference<>(); + private final AtomicReference combinedProcess = new AtomicReference<>(); + + private volatile Instant emacsLastRun; + private volatile Integer emacsLastExitCode; private volatile Instant webLastRun; private volatile Integer webLastExitCode; - private volatile Instant roamLastRun; private volatile Integer roamLastExitCode; + private volatile Instant combinedLastRun; + private volatile Integer combinedLastExitCode; + + private static final Logger log = LoggerFactory.getLogger(BuildController.class); + + // Server start time for uptime calculation + private static final Instant SERVER_START = Instant.now(); - private static final Logger log = - LoggerFactory.getLogger(BuildController.class); - @Value("${zone.build.dir}") private String webBuildDirectory; @@ -44,29 +60,52 @@ public class BuildController { @Value("${orgroam.build.log}") private String roamBuildLogFile; + @Value("${emacs.run.dir}") + private String emacsRunDirectory; + + @Value("${emacs.run.log}") + private String emacsRunLogFile; + + @Value("${combined.run.log}") + private String combinedRunLogFile; + + /* ========================================================= + HEALTH + UPTIME + ========================================================= */ + + @GetMapping("/health") + public ResponseEntity health() { + return ResponseEntity.ok("ok"); + } + + @GetMapping("/uptime") + public ResponseEntity> getUptime() { + long uptimeMs = Instant.now().toEpochMilli() - SERVER_START.toEpochMilli(); + long jvmUptimeMs = ManagementFactory.getRuntimeMXBean().getUptime(); + return ResponseEntity.ok(Map.of( + "serverStart", SERVER_START, + "uptimeMs", uptimeMs, + "jvmUptimeMs", jvmUptimeMs + )); + } + /* ========================================================= WEB BUILD ========================================================= */ @PostMapping("/build-web") public ResponseEntity triggerWebBuild() { - return startBuild( - webBuildDirectory, - webBuildLogFile, - webBuildRunning, - true - ); + return startBuild(webBuildDirectory, webBuildLogFile, webBuildRunning, webProcess, true); + } + + @DeleteMapping("/build-web") + public ResponseEntity cancelWebBuild() { + return killProcess(webProcess, webBuildRunning, "web build"); } @GetMapping("/build-web/status") public ResponseEntity getWebBuildStatus() { - return ResponseEntity.ok( - new BuildStatus( - webBuildRunning.get(), - webLastRun, - webLastExitCode - ) - ); + return ResponseEntity.ok(new BuildStatus(webBuildRunning.get(), webLastRun, webLastExitCode)); } @GetMapping("/build-web/logs") @@ -80,23 +119,17 @@ public class BuildController { @PostMapping("/build-roam") public ResponseEntity triggerRoamBuild() { - return startBuild( - roamBuildDirectory, - roamBuildLogFile, - roamBuildRunning, - false - ); + return startBuild(roamBuildDirectory, roamBuildLogFile, roamBuildRunning, roamProcess, false); + } + + @DeleteMapping("/build-roam") + public ResponseEntity cancelRoamBuild() { + return killProcess(roamProcess, roamBuildRunning, "roam build"); } @GetMapping("/build-roam/status") public ResponseEntity getRoamBuildStatus() { - return ResponseEntity.ok( - new BuildStatus( - roamBuildRunning.get(), - roamLastRun, - roamLastExitCode - ) - ); + return ResponseEntity.ok(new BuildStatus(roamBuildRunning.get(), roamLastRun, roamLastExitCode)); } @GetMapping("/build-roam/logs") @@ -104,68 +137,174 @@ public class BuildController { return streamLogs(roamBuildLogFile); } + /* ========================================================= + EMACS RERUN + ========================================================= */ + + @PostMapping("/rerun-emacs") + public ResponseEntity triggerEmacs() { + log.info("Triggering Emacs command"); + return startCommand(emacsRunDirectory, emacsRunLogFile, emacsRunning, emacsProcess); + } + + @DeleteMapping("/rerun-emacs") + public ResponseEntity cancelEmacs() { + return killProcess(emacsProcess, emacsRunning, "emacs"); + } + + @GetMapping("/rerun-emacs/status") + public ResponseEntity getEmacsStatus() { + return ResponseEntity.ok(new BuildStatus(emacsRunning.get(), emacsLastRun, emacsLastExitCode)); + } + + @GetMapping("/rerun-emacs/logs") + public SseEmitter streamEmacsLogs() { + return streamLogs(emacsRunLogFile); + } + + /* ========================================================= + COMBINED: EMACS + ROAM (sequential) + ========================================================= */ + + @PostMapping("/run-combined") + public ResponseEntity triggerCombined() { + if (!combinedRunning.compareAndSet(false, true)) { + return ResponseEntity.status(HttpStatus.CONFLICT).body("Combined run already in progress"); + } + if (emacsRunning.get() || roamBuildRunning.get()) { + combinedRunning.set(false); + return ResponseEntity.status(HttpStatus.CONFLICT).body("Emacs or Roam already running independently"); + } + + try { + File logFile = prepareLogFile(combinedRunLogFile); + combinedLastRun = Instant.now(); + + new Thread(() -> { + try { + appendToLog(logFile, "=== [1/2] Starting Emacs ===\n"); + + // Step 1: Emacs + ProcessBuilder emacsPb = new ProcessBuilder( + "bash", "-c", + "TERM=vt100 /usr/bin/timeout 10 /usr/bin/script -q -c \"emacs -nw\" /dev/null" + ); + emacsPb.directory(Path.of(emacsRunDirectory).toFile()); + emacsPb.redirectErrorStream(true); + emacsPb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile)); + + Process ep = emacsPb.start(); + combinedProcess.set(ep); + int emacsExit = ep.waitFor(); + emacsLastExitCode = emacsExit; + + appendToLog(logFile, "\n=== Emacs exited (code " + emacsExit + ") ===\n"); + appendToLog(logFile, "=== [2/2] Starting Roam build ===\n"); + + // Step 2: Roam make + ProcessBuilder roamPb = new ProcessBuilder("make"); + roamPb.directory(Path.of(roamBuildDirectory).toFile()); + roamPb.redirectErrorStream(true); + roamPb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile)); + + Process rp = roamPb.start(); + combinedProcess.set(rp); + int roamExit = rp.waitFor(); + roamLastExitCode = roamExit; + roamLastRun = Instant.now(); + + int finalCode = (emacsExit == 0 || emacsExit == 124) && roamExit == 0 ? 0 : 1; + combinedLastExitCode = finalCode; + + appendToLog(logFile, "\n=== Roam build exited (code " + roamExit + ") ===\n"); + appendToLog(logFile, "=== Combined run complete (exit " + finalCode + ") ===\n"); + + } catch (Exception e) { + combinedLastExitCode = 1; + log.error("Combined run failed", e); + } finally { + combinedRunning.set(false); + combinedProcess.set(null); + } + }).start(); + + return ResponseEntity.ok("Combined run started"); + + } catch (IOException e) { + combinedRunning.set(false); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to start: " + e.getMessage()); + } + } + + @DeleteMapping("/run-combined") + public ResponseEntity cancelCombined() { + return killProcess(combinedProcess, combinedRunning, "combined run"); + } + + @GetMapping("/run-combined/status") + public ResponseEntity getCombinedStatus() { + return ResponseEntity.ok(new BuildStatus(combinedRunning.get(), combinedLastRun, combinedLastExitCode)); + } + + @GetMapping("/run-combined/logs") + public SseEmitter streamCombinedLogs() { + return streamLogs(combinedRunLogFile); + } + + /* ========================================================= + LAST RUN TIMESTAMPS (all at once) + ========================================================= */ + + @GetMapping("/last-runs") + public ResponseEntity> getLastRuns() { + return ResponseEntity.ok(Map.of( + "web", Map.of("lastRun", nullSafe(webLastRun), "exitCode", nullSafe(webLastExitCode)), + "roam", Map.of("lastRun", nullSafe(roamLastRun), "exitCode", nullSafe(roamLastExitCode)), + "emacs", Map.of("lastRun", nullSafe(emacsLastRun), "exitCode", nullSafe(emacsLastExitCode)), + "combined", Map.of("lastRun", nullSafe(combinedLastRun), "exitCode", nullSafe(combinedLastExitCode)) + )); + } + /* ========================================================= CORE BUILD LOGIC ========================================================= */ private ResponseEntity startBuild( - String buildDir, - String logPath, - AtomicBoolean runningFlag, - boolean isWeb - ) { + String buildDir, String logPath, + AtomicBoolean runningFlag, AtomicReference processRef, + boolean isWeb) { if (!runningFlag.compareAndSet(false, true)) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body("Build already running"); + return ResponseEntity.status(HttpStatus.CONFLICT).body("Build already running"); } try { - File workingDir = Path.of(buildDir).toFile(); - File logFile = new File(logPath); - - if (logFile.getParentFile() != null) { - logFile.getParentFile().mkdirs(); - } - - if (logFile.exists()) { - logFile.delete(); - } - - logFile.createNewFile(); - + File logFile = prepareLogFile(logPath); ProcessBuilder pb = new ProcessBuilder("make"); - pb.directory(workingDir); + pb.directory(Path.of(buildDir).toFile()); pb.redirectErrorStream(true); pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile)); - log.info("Starting build in {}", workingDir.getAbsolutePath()); - + log.info("Starting build in {}", buildDir); Process process = pb.start(); + processRef.set(process); - if (isWeb) { - webLastRun = Instant.now(); - } else { - roamLastRun = Instant.now(); - } + if (isWeb) webLastRun = Instant.now(); + else roamLastRun = Instant.now(); new Thread(() -> { try { int exit = process.waitFor(); - - if (isWeb) { - webLastExitCode = exit; - } else { - roamLastExitCode = exit; - } - + if (isWeb) webLastExitCode = exit; + else roamLastExitCode = exit; log.info("Build finished with exit code {}", exit); - } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error("Build thread interrupted", e); } finally { runningFlag.set(false); + processRef.set(null); } }).start(); @@ -175,50 +314,122 @@ public class BuildController { runningFlag.set(false); log.error("Build failed to start", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Failed to start build: " + e.getMessage()); + .body("Failed to start build: " + e.getMessage()); } } + private ResponseEntity startCommand( + String runDir, String logPath, + AtomicBoolean runningFlag, AtomicReference processRef) { + + if (!runningFlag.compareAndSet(false, true)) { + return ResponseEntity.status(HttpStatus.CONFLICT).body("Command already running"); + } + + try { + File logFile = prepareLogFile(logPath); + ProcessBuilder pb = new ProcessBuilder( + "bash", "-c", + "TERM=vt100 /usr/bin/timeout 10 /usr/bin/script -q -c \"emacs -nw\" /dev/null" + ); + pb.directory(Path.of(runDir).toFile()); + pb.redirectErrorStream(true); + pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile)); + + Process process = pb.start(); + processRef.set(process); + emacsLastRun = Instant.now(); + + new Thread(() -> { + try { + int exit = process.waitFor(); + emacsLastExitCode = exit; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + runningFlag.set(false); + processRef.set(null); + } + }).start(); + + return ResponseEntity.ok("Command started"); + + } catch (IOException e) { + runningFlag.set(false); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to start command: " + e.getMessage()); + } + } + + private ResponseEntity killProcess( + AtomicReference processRef, + AtomicBoolean runningFlag, + String label) { + + Process p = processRef.get(); + if (p == null || !p.isAlive()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No running " + label); + } + p.destroyForcibly(); + runningFlag.set(false); + processRef.set(null); + log.info("Killed {}", label); + return ResponseEntity.ok("Killed " + label); + } + /* ========================================================= LOG STREAMING (SSE) ========================================================= */ private SseEmitter streamLogs(String logFilePath) { - SseEmitter emitter = new SseEmitter(0L); - new Thread(() -> { try (RandomAccessFile file = new RandomAccessFile(logFilePath, "r")) { - long pointer = 0; - while (!Thread.currentThread().isInterrupted()) { - long length = file.length(); - if (length > pointer) { file.seek(pointer); - String line; while ((line = file.readLine()) != null) { emitter.send(SseEmitter.event().data(line)); } - pointer = file.getFilePointer(); } - Thread.sleep(1000); } - } catch (Exception e) { log.info("Log stream closed"); emitter.complete(); } }).start(); - return emitter; } + /* ========================================================= + HELPERS + ========================================================= */ + + private File prepareLogFile(String logPath) throws IOException { + File logFile = new File(logPath); + if (logFile.getParentFile() != null) logFile.getParentFile().mkdirs(); + if (logFile.exists()) logFile.delete(); + logFile.createNewFile(); + return logFile; + } + + private void appendToLog(File logFile, String text) { + try (java.io.FileWriter fw = new java.io.FileWriter(logFile, true)) { + fw.write(text); + } catch (IOException e) { + log.warn("Could not append to log", e); + } + } + + private Object nullSafe(Object val) { + return val != null ? val : "never"; + } + /* ========================================================= DTO ========================================================= */ @@ -229,8 +440,8 @@ public class BuildController { public Integer lastExitCode; public BuildStatus(boolean running, Instant lastRun, Integer lastExitCode) { - this.running = running; - this.lastRun = lastRun; + this.running = running; + this.lastRun = lastRun; this.lastExitCode = lastExitCode; } }