Files
agenticCode/ac-code-server/src/main/java/com/agenticcode/codeserver/api/ProjectResource.java
Ingo Schnabel 10971915e9 Typescript
2026-09-23 08:15:53 +02:00

278 lines
14 KiB
Java

package com.agenticcode.codeserver.api;
import com.agenticcode.codeserver.service.ProjectIngestService;
import com.agenticcode.codeserver.service.ProjectRootResolver;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
import io.smallrye.common.annotation.Blocking;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
* REST API for creating, updating, deleting and listing projects.
*/
@Path("/api/projects")
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = "projects", description = "Create, update, delete and list projects (ingest roots).")
// All endpoints drive the blocking Neo4j driver (e.g. clearAll's full-graph DETACH DELETE), so
// dispatch to a worker thread instead of blocking the Vert.x event loop.
@Blocking
public class ProjectResource {
private static final Logger LOG = Logger.getLogger(ProjectResource.class);
private final GraphRepository graphRepository;
private final ProjectIngestService ingestService;
private final ProjectRootResolver rootResolver;
private final boolean scanOnCreate;
public ProjectResource(GraphRepository graphRepository, ProjectIngestService ingestService,
ProjectRootResolver rootResolver,
@ConfigProperty(name = "agenticcode.tier1.scan-on-create", defaultValue = "true") boolean scanOnCreate) {
this.graphRepository = graphRepository;
this.ingestService = ingestService;
this.rootResolver = rootResolver;
this.scanOnCreate = scanOnCreate;
}
static Response error(Response.Status status, String code, String message) {
return Response.status(status)
.entity(ErrorResponse.of(code, message))
.build();
}
static Response error(Response.Status status, String code, String message, Map<String, Object> details) {
return Response.status(status)
.entity(ErrorResponse.of(code, message, details))
.build();
}
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java", "typescript");
private static @Nullable String normalize(@Nullable String value) {
return value == null || value.isBlank() ? null : value.strip();
}
/**
* Validates the item-47 fields. {@code language} is required on create (a project declares its
* source language) and, if present anywhere, must be one of {@link #SUPPORTED_LANGUAGES}.
* {@code generatedDir}/{@code userExitDir} are set together or not at all (a generated module is
* annotated with its user-exit twin's metrics, which needs both). Returns an error {@link Response}
* or {@code null} when valid.
*/
private static @Nullable Response validateLanguageAndDirs(ProjectRequest request, boolean onCreate) {
String language = normalize(request.language());
if (onCreate && language == null) {
return error(Response.Status.BAD_REQUEST, "LANGUAGE_REQUIRED",
"A project requires a 'language' (" + String.join("/", SUPPORTED_LANGUAGES) + ")");
}
if (language != null && !SUPPORTED_LANGUAGES.contains(language.toLowerCase())) {
return error(Response.Status.BAD_REQUEST, "LANGUAGE_UNSUPPORTED",
"Unsupported language '" + language + "'; expected one of " + SUPPORTED_LANGUAGES);
}
String generatedDir = normalize(request.generatedDir());
String userExitDir = normalize(request.userExitDir());
// Both-or-neither: enforced on create always, and on update whenever either is being set.
boolean either = generatedDir != null || userExitDir != null;
if ((onCreate || either) && (generatedDir == null) != (userExitDir == null)) {
return error(Response.Status.BAD_REQUEST, "GENERATED_USEREXIT_PAIR",
"'generatedDir' and 'userExitDir' must be provided together or not at all");
}
return null;
}
@GET
@Operation(summary = "List all projects")
public Uni<List<ProjectInfo>> list() {
return graphRepository.listProjects();
}
@GET
@Path("/{project}")
@Operation(summary = "One project's config and last whole-root ingest",
description = "Item 126: the project's configuration plus what its last whole-root ingest did "
+ "(ingestedAt, mode, filesExamined/Persisted/Failed, serverVersion). 'ingest' is null when "
+ "no whole-root ingest has been recorded, which is not the same as one that found nothing.")
@APIResponse(responseCode = "200", description = "The project.")
@APIResponse(responseCode = "404", description = "Project not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> get(@PathParam("project") String project) {
return graphRepository.getProject(project)
.map(info -> info == null
? error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
"Project '" + project + "' does not exist")
: Response.ok(info).build());
}
@POST
@Path("/{project}")
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a project", description = "Registers a project with a server-side source root and Tier-1 scans it.")
@APIResponse(responseCode = "201", description = "Created.")
@APIResponse(responseCode = "400", description = "Invalid request.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "409", description = "Project already exists.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> create(@PathParam("project") String project, ProjectRequest request) {
if (request.root() == null || request.root().isBlank()) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "ROOT_REQUIRED",
"A project requires a 'root' folder (server-side path to its sources)"));
}
@Nullable Response invalid = validateLanguageAndDirs(request, true);
if (invalid != null) {
return Uni.createFrom().item(invalid);
}
@Nullable List<String> counterparts = request.normalizedCounterparts();
if (counterparts != null && counterparts.contains(project)) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
"A project cannot be its own counterpart"));
}
return graphRepository.createProject(project, request.description(), request.root(), request.excludeDirsOrEmpty(),
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
counterparts == null ? List.of() : counterparts)
.map(result -> switch (result) {
case SUCCESS -> {
scanTier1(project);
yield Response.status(Response.Status.CREATED).build();
}
case CONFLICT -> error(Response.Status.CONFLICT, "PROJECT_ALREADY_EXISTS",
"Project '" + project + "' already exists");
case NOT_FOUND -> throw new IllegalStateException("Unexpected NOT_FOUND on create");
});
}
/**
* Runs the Tier-1 coarse reference scan (item 36) for a just-created project so it is immediately
* queryable ({@code callers}/{@code search_identifier}/...). Non-fatal: the project is already
* created, so a missing/unscannable root is logged and left for a later {@code refresh} rather than
* failing create. Disabled by {@code agenticcode.tier1.scan-on-create=false} (e.g. to create a
* project shell and scan later). Runs on the {@code @Blocking} worker thread.
*/
private void scanTier1(String project) {
if (!scanOnCreate) {
return;
}
switch (rootResolver.resolve(project)) {
case ProjectRootResolver.Resolved resolved -> {
try {
ingestService.scanTier1(resolved.project());
} catch (IOException | RuntimeException e) {
LOG.warnf(e, "Tier-1 scan on create failed for project '%s'; created empty, run refresh to retry", project);
}
}
case ProjectRootResolver.Failed failed ->
LOG.warnf("Tier-1 scan on create skipped for project '%s': %s", project, failed.message());
}
}
@PUT
@Path("/{project}")
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Update a project")
@APIResponse(responseCode = "200", description = "Updated.")
@APIResponse(responseCode = "404", description = "Project not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> update(@PathParam("project") String project, ProjectRequest request) {
@Nullable Response invalid = validateLanguageAndDirs(request, false);
if (invalid != null) {
return Uni.createFrom().item(invalid);
}
@Nullable List<String> counterparts = request.normalizedCounterparts();
if (counterparts != null && counterparts.contains(project)) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
"A project cannot be its own counterpart"));
}
return graphRepository.updateProject(project, request.description(), request.root(), request.excludeDirs(),
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
counterparts)
.map(result -> switch (result) {
case SUCCESS -> Response.ok().build();
case NOT_FOUND -> error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
"Project '" + project + "' does not exist");
case CONFLICT -> throw new IllegalStateException("Unexpected CONFLICT on update");
});
}
@DELETE
@Operation(summary = "Clear the whole graph", description = "Deletes every node/edge across all projects.")
@APIResponse(responseCode = "204", description = "Cleared.")
public Uni<Response> clearAll() {
return graphRepository.clearAll().replaceWith(Response.noContent().build());
}
@DELETE
@Path("/{project}")
@Operation(summary = "Delete a project")
@APIResponse(responseCode = "204", description = "Deleted.")
@APIResponse(responseCode = "404", description = "Project not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> delete(@PathParam("project") String project) {
return graphRepository.deleteProject(project)
.map(result -> switch (result) {
case SUCCESS -> Response.noContent().build();
case NOT_FOUND -> error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
"Project '" + project + "' does not exist");
case CONFLICT -> throw new IllegalStateException("Unexpected CONFLICT on delete");
});
}
/**
* Request body for {@code POST}/{@code PUT} {@code /api/projects/{project}}.
*
* <p>{@code root} is required on create (the server-side path the project's sources live under)
* and optional on update (null leaves it unchanged). {@code excludeDirs} are path components
* skipped when scanning {@code root}; null on update leaves the stored list unchanged.
* {@code language} is required on create (item 47). {@code generatedDir}/{@code userExitDir} are
* directory names for the generated vs. user-exit LoC split; set together or not at all.
*/
public record ProjectRequest(@Nullable String description, @Nullable String root,
@Nullable List<String> excludeDirs, @Nullable String language,
@Nullable String generatedDir, @Nullable String userExitDir,
@Nullable List<String> counterparts) {
/**
* Pre-item-193 shape (no counterparts).
*/
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
this(description, root, excludeDirs, language, generatedDir, userExitDir, null);
}
/**
* Backward-compatible constructor for callers that predate the item-47 fields (language /
* generated / user-exit dir all absent).
*/
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs) {
this(description, root, excludeDirs, null, null, null, null);
}
/**
* Item 193: the counterpart list, trimmed; {@code null} when absent (update: unchanged).
*/
@Nullable List<String> normalizedCounterparts() {
if (counterparts == null) {
return null;
}
return counterparts.stream().filter(c -> c != null && !c.isBlank()).map(String::strip).distinct().toList();
}
List<String> excludeDirsOrEmpty() {
return excludeDirs != null ? excludeDirs : List.of();
}
}
}