278 lines
14 KiB
Java
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();
|
|
}
|
|
}
|
|
}
|