using claude to spice things up

This commit is contained in:
2026-03-08 13:26:03 +00:00
parent 1ec9558bf7
commit a225ab2993
2 changed files with 293 additions and 95 deletions

View File

@@ -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";
}
}

View File

@@ -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;
}
}