using claude to spice things up
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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<Process> webProcess = new AtomicReference<>();
|
||||
private final AtomicReference<Process> roamProcess = new AtomicReference<>();
|
||||
private final AtomicReference<Process> emacsProcess = new AtomicReference<>();
|
||||
private final AtomicReference<Process> 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<String> health() {
|
||||
return ResponseEntity.ok("ok");
|
||||
}
|
||||
|
||||
@GetMapping("/uptime")
|
||||
public ResponseEntity<Map<String, Object>> 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<String> triggerWebBuild() {
|
||||
return startBuild(
|
||||
webBuildDirectory,
|
||||
webBuildLogFile,
|
||||
webBuildRunning,
|
||||
true
|
||||
);
|
||||
return startBuild(webBuildDirectory, webBuildLogFile, webBuildRunning, webProcess, true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/build-web")
|
||||
public ResponseEntity<String> cancelWebBuild() {
|
||||
return killProcess(webProcess, webBuildRunning, "web build");
|
||||
}
|
||||
|
||||
@GetMapping("/build-web/status")
|
||||
public ResponseEntity<BuildStatus> 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<String> triggerRoamBuild() {
|
||||
return startBuild(
|
||||
roamBuildDirectory,
|
||||
roamBuildLogFile,
|
||||
roamBuildRunning,
|
||||
false
|
||||
);
|
||||
return startBuild(roamBuildDirectory, roamBuildLogFile, roamBuildRunning, roamProcess, false);
|
||||
}
|
||||
|
||||
@DeleteMapping("/build-roam")
|
||||
public ResponseEntity<String> cancelRoamBuild() {
|
||||
return killProcess(roamProcess, roamBuildRunning, "roam build");
|
||||
}
|
||||
|
||||
@GetMapping("/build-roam/status")
|
||||
public ResponseEntity<BuildStatus> 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<String> triggerEmacs() {
|
||||
log.info("Triggering Emacs command");
|
||||
return startCommand(emacsRunDirectory, emacsRunLogFile, emacsRunning, emacsProcess);
|
||||
}
|
||||
|
||||
@DeleteMapping("/rerun-emacs")
|
||||
public ResponseEntity<String> cancelEmacs() {
|
||||
return killProcess(emacsProcess, emacsRunning, "emacs");
|
||||
}
|
||||
|
||||
@GetMapping("/rerun-emacs/status")
|
||||
public ResponseEntity<BuildStatus> 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<String> 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<String> cancelCombined() {
|
||||
return killProcess(combinedProcess, combinedRunning, "combined run");
|
||||
}
|
||||
|
||||
@GetMapping("/run-combined/status")
|
||||
public ResponseEntity<BuildStatus> 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<Map<String, Object>> 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<String> startBuild(
|
||||
String buildDir,
|
||||
String logPath,
|
||||
AtomicBoolean runningFlag,
|
||||
boolean isWeb
|
||||
) {
|
||||
String buildDir, String logPath,
|
||||
AtomicBoolean runningFlag, AtomicReference<Process> 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<String> startCommand(
|
||||
String runDir, String logPath,
|
||||
AtomicBoolean runningFlag, AtomicReference<Process> 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<String> killProcess(
|
||||
AtomicReference<Process> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user