Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,23 @@ npm run build # Build (dev)
npm run build:production # Build (prod)
```

**Dev credentials**: admin/admin (auth bypass in dev mode)
**Dev credentials**: There is no baked-in admin/admin login — `AuthController.login` requires a
real `User` row matched by **email**, not username, so a fresh database (new Postgres volume)
has no account to log in with at all. `SECURITY_AUTH_ENABLED=false` only bypasses JWT/MCP token
*validation* (`JwtAuthenticationFilter`, `McpTokenAuthenticationFilter`); it does not create a
user or skip the login form. Create the first admin via the bootstrap endpoint, gated by
`SECURITY_ADMIN_BOOTSTRAP_ENABLED=true` + `ADMIN_BOOTSTRAP_SECRET`, and only callable from
localhost:

```bash
curl -X POST http://localhost:8080/api/users/admin/bootstrap \
-H "Content-Type: application/json" \
-H "X-Admin-Bootstrap-Secret: $ADMIN_BOOTSTRAP_SECRET" \
-d '{"email":"admin@localhost","password":"<your-password>"}'
```

Then log in with that **email** (not `admin`) and password. `POST /users/admin/reset` (same
header) replaces the existing admin if you need to rotate the password.

### Database

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.dbaagent.controller;

import com.dbaagent.model.SavedDashboard;
import com.dbaagent.service.DashboardAgentService;
import com.dbaagent.service.SavedDashboardService;
import com.dbaagent.service.security.AccessControlService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -18,6 +20,7 @@

import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
Expand All @@ -44,6 +47,7 @@ public class DashboardGenerationController {

private final DashboardAgentService dashboardAgentService;
private final AccessControlService accessControlService;
private final SavedDashboardService savedDashboardService;

@PostMapping("/generate")
public ResponseEntity<?> generate(@RequestBody GenerateRequest request) {
Expand All @@ -69,12 +73,38 @@ public ResponseEntity<?> generate(@RequestBody GenerateRequest request) {
public SseEmitter generateStream(@RequestBody GenerateRequest request) {
requireValid(request);
accessControlService.assertCanReadConnectionContent(request.connectionId());
SseEmitter emitter = new SseEmitter(600_000L);

// Resolve (or create) the target dashboard and record the user's message
// SYNCHRONOUSLY, before any slow agent work starts — this is what lets a
// reload mid-generation see "still working" (generationStatus=RUNNING)
// instead of nothing at all, even for a brand-new, never-saved dashboard.
// See SavedDashboardService's "Server-owned chat-turn persistence" section.
final SavedDashboard dashboard;
try {
dashboard = savedDashboardService.beginGenerationTurn(
request.dashboardId(), request.connectionId(), request.prompt());
} catch (IllegalArgumentException | IllegalStateException e) {
sendErrorAndComplete(emitter, e.getMessage());
return emitter;
}
// The frontend needs this id right away (not just at the end) so a
// brand-new dashboard is addressable — e.g. by a reload — well before
// the potentially multi-minute build finishes.
try {
emitter.send(SseEmitter.event().name("created")
.data(Map.of("dashboardId", dashboard.getId().toString())));
} catch (IOException ignore) {
// Client already gone before we even started streaming — fine, the
// turn is already durably recorded; the work below still runs and
// persists its result regardless of this connection.
}

// Coding a whole dashboard (ground + verify every query + write the HTML) can
// run for minutes. Give it real headroom (10 min) and keep the stream alive
// with a heartbeat — otherwise it emits only 3 step events and the long idle
// gap gets cut by nginx/emitter timeouts before `done`, surfacing to the user
// as "Generation ended unexpectedly".
SseEmitter emitter = new SseEmitter(600_000L);
ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "dashboard-generate-hb");
t.setDaemon(true);
Expand Down Expand Up @@ -106,21 +136,47 @@ public SseEmitter generateStream(@RequestBody GenerateRequest request) {
// `done` event with a real artifact — the FE's done handler always
// appends "Done — built…" and auto-saves. A dedicated `chat` event
// keeps that path from swallowing out-of-context messages.
if (Boolean.TRUE.equals(config.get("chat"))) {
emitter.send(SseEmitter.event().name("chat")
.data(Map.of(
"success", true,
"reply", String.valueOf(config.getOrDefault("reply", "")),
"dashboardConfig", config)));
} else {
emitter.send(SseEmitter.event().name("done")
.data(Map.of("success", true, "dashboardConfig", config)));
boolean chatOnly = Boolean.TRUE.equals(config.get("chat"));
// Persist BEFORE attempting to notify the client — a client that's
// gone by now must never turn an already-successful result into a
// recorded failure (see the catch block below, which only ever
// handles a real dashboardAgentService.generate() failure, not a
// dead SSE connection at delivery time).
try {
if (chatOnly) {
savedDashboardService.appendAgentReply(
dashboard.getId(), String.valueOf(config.getOrDefault("reply", "")));
} else {
savedDashboardService.completeBuildTurn(dashboard.getId(), config);
}
} catch (Exception persistErr) {
log.error("Failed to persist completed dashboard turn {}", dashboard.getId(), persistErr);
}
try {
if (chatOnly) {
emitter.send(SseEmitter.event().name("chat")
.data(Map.of(
"success", true,
"reply", String.valueOf(config.getOrDefault("reply", "")),
"dashboardConfig", config)));
} else {
emitter.send(SseEmitter.event().name("done")
.data(Map.of("success", true, "dashboardConfig", config)));
}
} catch (IOException ignore) {
// Client gone by the time the result was ready — already
// persisted above, so this is a no-op, not a failure.
}
emitter.complete();
} catch (ClientGoneException gone) {
emitter.complete();
} catch (Exception e) {
log.warn("Streamed dashboard generation failed: {}", e.getMessage());
try {
savedDashboardService.appendErrorReply(dashboard.getId(), safe(e));
} catch (Exception persistErr) {
log.error("Failed to persist dashboard generation error {}", dashboard.getId(), persistErr);
}
try {
emitter.send(SseEmitter.event().name("error")
.data(Map.of("success", false, "error", safe(e))));
Expand All @@ -134,6 +190,14 @@ public SseEmitter generateStream(@RequestBody GenerateRequest request) {
return emitter;
}

private static void sendErrorAndComplete(SseEmitter emitter, String message) {
try {
emitter.send(SseEmitter.event().name("error")
.data(Map.of("success", false, "error", message == null ? "Request failed" : message)));
} catch (IOException ignore) { }
emitter.complete();
}

private static void requireValid(GenerateRequest request) {
if (request == null || request.connectionId() == null
|| request.prompt() == null || request.prompt().isBlank()) {
Expand All @@ -149,5 +213,8 @@ private static final class ClientGoneException extends RuntimeException {
ClientGoneException(Throwable cause) { super(cause); }
}

public record GenerateRequest(String connectionId, String prompt, Object currentConfig) { }
// dashboardId is optional — omitting it (a brand-new, never-saved dashboard)
// always creates a new SavedDashboard row, matching the pre-existing default
// behavior for new dashboards.
public record GenerateRequest(String connectionId, String prompt, Object currentConfig, UUID dashboardId) { }
}
18 changes: 18 additions & 0 deletions backend/src/main/java/com/dbaagent/model/SavedDashboard.java
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@ public boolean isSharePasswordSet() {
@Column(length = 255)
private String folder;

// Server-owned "is a generation turn in flight for this dashboard" marker.
// Set to RUNNING the instant a chat submit is accepted (before the slow
// agent work starts) and back to IDLE when it finishes — from the backend
// code path itself, regardless of whether the SSE client that started it
// is still connected. Lets a reload mid-generation distinguish "still
// working" from "answer's ready" without needing to have stayed connected.
// See SavedDashboardService.beginGenerationTurn/appendAgentReply/
// completeBuildTurn/appendErrorReply.
@Column(nullable = false, length = 16)
private String generationStatus = "IDLE";

// When the current RUNNING turn started, so a client can tell a live
// generation from one abandoned by a backend crash (see
// SavedDashboardService.STALE_RUNNING_THRESHOLD).
@Column
private LocalDateTime generationStartedAt;

@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
Expand All @@ -94,5 +111,6 @@ public boolean isSharePasswordSet() {
void applyBooleanDefaults() {
if (isPublic == null) isPublic = false;
if (isFavorite == null) isFavorite = false;
if (generationStatus == null) generationStatus = "IDLE";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ public Map<String, Object> generate(String connectionId, String prompt, Object c
List<Map<String, Object>> trace = new ArrayList<>();
StepListener l = listener == null ? StepListener.NOOP : listener;

emit(l, trace, "grounding", "Handing off to the DeepSQL agent…");
String username = accessControlService.requireCurrentUsername();
String profile = agentBridgeService.ensureProfileForUser(username, connectionId);
// Fresh session per generation — an isolated coding task, not the user's chat thread.
Expand All @@ -87,12 +86,12 @@ public Map<String, Object> generate(String connectionId, String prompt, Object c
// only skip it for something that plainly isn't one (a greeting, a question
// about the tool itself). Answering "hi" by grounding on the schema, writing
// SQL, and self-reviewing an HTML document is where the multi-minute replies
// to trivial messages came from.
// to trivial messages came from. Classify BEFORE emitting any step: a chat-only
// turn should show nothing but the generic "Working" spinner, not a "Handing off
// to the DeepSQL agent" trace that implies a build is underway.
if (isChatOnly(prompt)) {
emit(l, trace, "planning", "Replying…");
AgentChatClient.AgentReply chatReply = agentChatClient.sendAndAwait(sessionId, buildChatTask(prompt));
if (chatReply.ok() && chatReply.text() != null && !chatReply.text().isBlank()) {
emit(l, trace, "done", "Replied");
Map<String, Object> chat = new LinkedHashMap<>();
chat.put("chat", true);
chat.put("reply", chatReply.text().trim());
Expand All @@ -106,6 +105,7 @@ public Map<String, Object> generate(String connectionId, String prompt, Object c
// rather than surfacing a failure for what might be a legitimate request.
}

emit(l, trace, "grounding", "Handing off to the DeepSQL agent…");
emit(l, trace, "planning", "Agent is grounding, writing SQL, and coding the dashboard…");
AgentChatClient.AgentReply reply = agentChatClient.sendAndAwait(
sessionId, buildTask(connectionId, prompt, currentConfig));
Expand Down
Loading
Loading