New features
This commit is contained in:
@@ -12,17 +12,18 @@ import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
/**
|
||||
* Groups project management subcommands: create, update, delete, recreate, list.
|
||||
* Groups project management subcommands: create, update, delete, recreate, show, list.
|
||||
*/
|
||||
@Command(
|
||||
name = "project",
|
||||
mixinStandardHelpOptions = true,
|
||||
description = "Create, update, delete, recreate or list projects",
|
||||
description = "Create, update, delete, recreate, show or list projects",
|
||||
subcommands = {
|
||||
ProjectCommand.CreateCommand.class,
|
||||
ProjectCommand.UpdateCommand.class,
|
||||
ProjectCommand.DeleteCommand.class,
|
||||
ProjectCommand.RecreateCommand.class,
|
||||
ProjectCommand.ShowCommand.class,
|
||||
ProjectCommand.ListCommand.class
|
||||
}
|
||||
)
|
||||
@@ -193,6 +194,20 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "show", mixinStandardHelpOptions = true,
|
||||
description = "Show one project's config and its last whole-root ingest (ingestedAt, mode, file counts)")
|
||||
static final class ShowCommand extends AbstractApiCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Project name")
|
||||
String name;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
return printResponse(apiClient().get("/api/projects/" + encode(name)));
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "list", mixinStandardHelpOptions = true, description = "List all projects")
|
||||
static final class ListCommand extends AbstractApiCommand {
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
server.url=http://localhost:8787
|
||||
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
|
||||
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
|
||||
version=218
|
||||
version=222
|
||||
|
||||
@@ -101,6 +101,23 @@ public class ProjectResource {
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.agenticcode.codeserver.service;
|
||||
|
||||
import com.agenticcode.neo4jstore.graph.EnrichmentLevel;
|
||||
import com.agenticcode.neo4jstore.graph.GraphRepository;
|
||||
import com.agenticcode.neo4jstore.graph.IngestDepth;
|
||||
import com.agenticcode.neo4jstore.graph.ModuleIngestState;
|
||||
import com.agenticcode.neo4jstore.graph.*;
|
||||
import com.agenticcode.parsercore.ast.model.LocMetrics;
|
||||
import com.agenticcode.parsercore.ast.model.NodeType;
|
||||
import com.agenticcode.parsercore.ast.spi.CoarseScanner;
|
||||
@@ -160,6 +157,14 @@ public class AstIngestService {
|
||||
return graphRepository.distinctSourceFiles(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 126: records what the last <b>whole-root</b> ingest of {@code project} did, so an agent can
|
||||
* date an answer and see how complete the graph is without crawling the file system.
|
||||
*/
|
||||
public Uni<Void> recordProjectIngest(String project, ProjectIngestInfo ingest) {
|
||||
return graphRepository.recordProjectIngest(project, ingest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 43: deletes every node of {@code project} belonging to one of {@code sourceFiles} — the
|
||||
* deleted-file orphan sweep run after a whole-project refresh.
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.agenticcode.codeserver.service;
|
||||
import com.agenticcode.neo4jstore.graph.EnrichmentLevel;
|
||||
import com.agenticcode.neo4jstore.graph.IngestDepth;
|
||||
import com.agenticcode.neo4jstore.graph.ProjectInfo;
|
||||
import com.agenticcode.neo4jstore.graph.ProjectIngestInfo;
|
||||
import com.agenticcode.parsercore.ast.model.*;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
@@ -13,6 +14,8 @@ import org.jspecify.annotations.Nullable;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -43,14 +46,19 @@ public class ProjectIngestService {
|
||||
private final int maxDeepDepth;
|
||||
private final int defaultDeepNodes;
|
||||
private final boolean autoInvalidateEnabled;
|
||||
// Item 126: stamped onto the project shell with every whole-root ingest, so a graph can be
|
||||
// attributed to the server release that wrote it.
|
||||
private final VersionInfo versionInfo;
|
||||
|
||||
public ProjectIngestService(AstIngestService astIngestService,
|
||||
VersionInfo versionInfo,
|
||||
@ConfigProperty(name = "agenticcode.ingest.batch-size", defaultValue = "200") int batchSize,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.default-depth", defaultValue = "5") int defaultDeepDepth,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.max-depth", defaultValue = "20") int maxDeepDepth,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.default-nodes", defaultValue = "300") int defaultDeepNodes,
|
||||
@ConfigProperty(name = "agenticcode.auto-invalidate.enabled", defaultValue = "true") boolean autoInvalidateEnabled) {
|
||||
this.astIngestService = astIngestService;
|
||||
this.versionInfo = versionInfo;
|
||||
this.batchSize = Math.max(1, batchSize);
|
||||
this.maxDeepDepth = Math.max(1, maxDeepDepth);
|
||||
this.defaultDeepDepth = Math.min(Math.max(1, defaultDeepDepth), this.maxDeepDepth);
|
||||
@@ -479,14 +487,47 @@ public class ProjectIngestService {
|
||||
astIngestService.markIngestDepth(project.name(),
|
||||
level.resolveFields() ? IngestDepth.FULL : IngestDepth.CALL_GRAPH, null).await().indefinitely();
|
||||
}
|
||||
String mode = coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT);
|
||||
long durationSeconds = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt);
|
||||
LOG.infof("Project ingest finished: project='%s', mode=%s, files=%d, persisted=%d, failed=%d, "
|
||||
+ "duplicates=%d, %d s",
|
||||
project.name(), coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT), examinedFiles.size(),
|
||||
ingested, failed.size(), duplicates.size(),
|
||||
TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt));
|
||||
project.name(), mode, examinedFiles.size(),
|
||||
ingested, failed.size(), duplicates.size(), durationSeconds);
|
||||
// Item 126: this is the only place that stamps the project shell, and it is reached only by the
|
||||
// three whole-root passes (Tier-1 scan, call-graph refresh, deep refresh). By-name and fan-out
|
||||
// ingests deliberately do not come through here — they walk a fraction of the tree, and moving
|
||||
// ingestedAt for them would report the project as freshly walked when one module was deepened.
|
||||
recordIngest(project, mode, examinedFiles.size(), ingested, failed, durationSeconds);
|
||||
return new IngestSummary(ingested, List.of(), duplicates, failed, examinedFiles, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 126: writes the whole-root ingest's outcome onto the {@code (:Project)} shell. Failure
|
||||
* <em>paths</em> are capped at {@link ProjectIngestInfo#MAX_FAILURES} while the count stays exact,
|
||||
* so a truncated list can never be read as "these were all of them".
|
||||
*
|
||||
* <p>Never fatal: the graph is already written, and losing the bookkeeping must not turn a
|
||||
* successful ingest into a failed request. A warning is logged instead — the missing metadata then
|
||||
* shows up as {@code ingest: null}, which reads as "not recorded" rather than as a false fact.
|
||||
*/
|
||||
private void recordIngest(ProjectInfo project, String mode, int filesExamined, int filesPersisted,
|
||||
List<IngestSummary.Failure> failed, long durationSeconds) {
|
||||
List<String> failurePaths = failed.stream()
|
||||
.map(f -> relativeSourceFile(Path.of(project.root()), Path.of(f.path())))
|
||||
.limit(ProjectIngestInfo.MAX_FAILURES)
|
||||
.toList();
|
||||
ProjectIngestInfo ingest = new ProjectIngestInfo(
|
||||
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), mode,
|
||||
filesExamined, filesPersisted, failed.size(), failurePaths,
|
||||
failed.size() > failurePaths.size(), durationSeconds, versionInfo.version());
|
||||
try {
|
||||
astIngestService.recordProjectIngest(project.name(), ingest).await().indefinitely();
|
||||
} catch (RuntimeException e) {
|
||||
LOG.warnf(e, "Could not record ingest metadata for project '%s'; it will report ingest=null",
|
||||
project.name());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingests {@code moduleName} and its transitive dependencies using the configured default depth
|
||||
* and node budget. See {@link #ingestModule(ProjectInfo, String, Integer, Integer)}.
|
||||
|
||||
@@ -3,7 +3,7 @@ quarkus.http.port=8787
|
||||
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
|
||||
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
|
||||
# info version (referenced below via property expression, not duplicated).
|
||||
agenticcode.version=218
|
||||
agenticcode.version=222
|
||||
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
|
||||
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
|
||||
mp.openapi.extensions.smallrye.info.title=AgenticCode API
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
* Item 126: {@code /projects} must carry what the last <b>whole-root</b> ingest did, so a negative
|
||||
* answer is evidence rather than a guess. Before this, nothing in the API said whether a project had
|
||||
* ever been fully ingested, so every "not found" had to be cross-checked against the file system.
|
||||
*
|
||||
* <p>The sharpest assertion here is {@link #aByNameRefreshDoesNotMoveIngestedAt()}: a partial ingest
|
||||
* that moved the timestamp would report the project as freshly walked when one module was deepened —
|
||||
* which is the very "looks complete but isn't" answer the item exists to remove.
|
||||
*/
|
||||
@QuarkusTest
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class ProjectIngestMetadataIT {
|
||||
|
||||
private static final String PROJECT = "ingest-metadata-project";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void createProject() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
writeSource("GOOD.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #A (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
CALLNAT 'OTHER'
|
||||
END
|
||||
""");
|
||||
writeSource("OTHER.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #B (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""");
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then()
|
||||
.statusCode(201);
|
||||
}
|
||||
|
||||
private static void writeSource(String fileName, String content) {
|
||||
try {
|
||||
Files.writeString(root.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static io.restassured.response.Response project() {
|
||||
return given().when().get("/api/projects/" + PROJECT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void theCreateTimeScanIsRecordedAsTier1() {
|
||||
project().then()
|
||||
.statusCode(200)
|
||||
.body("ingest.mode", equalTo("tier1"))
|
||||
.body("ingest.ingestedAt", notNullValue())
|
||||
.body("ingest.filesExamined", equalTo(2))
|
||||
.body("ingest.filesPersisted", equalTo(2))
|
||||
.body("ingest.filesFailed", equalTo(0))
|
||||
.body("ingest.failuresTruncated", equalTo(false))
|
||||
.body("ingest.serverVersion", not(emptyString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void aDeepRefreshRecordsTheFullModeAndTheNewFileCount() {
|
||||
writeSource("THIRD.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #C (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""");
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
|
||||
project().then()
|
||||
.statusCode(200)
|
||||
.body("ingest.mode", equalTo("full"))
|
||||
.body("ingest.filesExamined", equalTo(3))
|
||||
.body("ingest.filesPersisted", equalTo(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure <em>list</em> and the failure <em>count</em> must agree whenever the list was not
|
||||
* truncated — that invariant is what stops a short list from being read as "these were all of
|
||||
* them". Deliberately not asserting that the malformed file below fails to parse: the Natural
|
||||
* parser is tolerant by design, so a fixture that "looks broken" is not a reliable way to produce
|
||||
* a failure, and a test that pretends otherwise would be testing the parser's mood.
|
||||
*/
|
||||
@Test
|
||||
@Order(3)
|
||||
void theFailureListAgreesWithTheFailureCount() {
|
||||
writeSource("BROKEN.nat", "DEFINE DATA LOCAL\n1 #X (A8\n");
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh").then().statusCode(200);
|
||||
io.restassured.response.Response response = project();
|
||||
response.then()
|
||||
.statusCode(200)
|
||||
.body("ingest.mode", equalTo("call_graph"))
|
||||
.body("ingest.filesExamined", equalTo(4));
|
||||
int failed = response.jsonPath().getInt("ingest.filesFailed");
|
||||
int listed = response.jsonPath().getList("ingest.failures").size();
|
||||
boolean truncated = response.jsonPath().getBoolean("ingest.failuresTruncated");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(truncated, listed < failed,
|
||||
"failuresTruncated must say exactly whether the list is shorter than the count");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(listed <= failed,
|
||||
"the failure list can never be longer than the failure count");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void aConfigUpdateDoesNotClobberTheIngestMetadata() {
|
||||
String before = project().jsonPath().getString("ingest.ingestedAt");
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest("now described", null, null, null, null, null))
|
||||
.when().put("/api/projects/" + PROJECT)
|
||||
.then().statusCode(200);
|
||||
project().then()
|
||||
.statusCode(200)
|
||||
.body("description", equalTo("now described"))
|
||||
.body("ingest.ingestedAt", equalTo(before));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void aByNameRefreshDoesNotMoveIngestedAt() {
|
||||
String before = project().jsonPath().getString("ingest.ingestedAt");
|
||||
int examinedBefore = project().jsonPath().getInt("ingest.filesExamined");
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh/GOOD").then().statusCode(200);
|
||||
project().then()
|
||||
.statusCode(200)
|
||||
.body("ingest.ingestedAt", equalTo(before))
|
||||
.body("ingest.filesExamined", equalTo(examinedBefore));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
void theProjectListingCarriesTheSameMetadata() {
|
||||
given().when().get("/api/projects")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("find { it.name == '" + PROJECT + "' }.ingest.mode", notNullValue())
|
||||
.body("find { it.name == '" + PROJECT + "' }.ingest.filesExamined", greaterThan(0));
|
||||
}
|
||||
}
|
||||
@@ -2615,7 +2615,14 @@ public final class CypherQueries {
|
||||
public static final String GET_PROJECT = """
|
||||
MATCH (p:Project {name: $name})
|
||||
RETURN p.name AS name, p.description AS description, p.root AS root, p.excludeDirs AS excludeDirs,
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir,
|
||||
// Item 126: what the last whole-root ingest did. Null on a project last ingested
|
||||
// before this was recorded — "never measured", which is not the same as zero.
|
||||
p.ingestedAt AS ingestedAt, p.ingestMode AS ingestMode,
|
||||
p.ingestFilesExamined AS ingestFilesExamined, p.ingestFilesPersisted AS ingestFilesPersisted,
|
||||
p.ingestFilesFailed AS ingestFilesFailed, p.ingestFailures AS ingestFailures,
|
||||
p.ingestFailuresTruncated AS ingestFailuresTruncated,
|
||||
p.ingestDurationSeconds AS ingestDurationSeconds, p.ingestServerVersion AS ingestServerVersion
|
||||
""";
|
||||
|
||||
/**
|
||||
@@ -2640,10 +2647,35 @@ public final class CypherQueries {
|
||||
public static final String LIST_PROJECTS = """
|
||||
MATCH (p:Project)
|
||||
RETURN p.name AS name, p.description AS description, p.root AS root, p.excludeDirs AS excludeDirs,
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir,
|
||||
// Item 126: what the last whole-root ingest did. Null on a project last ingested
|
||||
// before this was recorded — "never measured", which is not the same as zero.
|
||||
p.ingestedAt AS ingestedAt, p.ingestMode AS ingestMode,
|
||||
p.ingestFilesExamined AS ingestFilesExamined, p.ingestFilesPersisted AS ingestFilesPersisted,
|
||||
p.ingestFilesFailed AS ingestFilesFailed, p.ingestFailures AS ingestFailures,
|
||||
p.ingestFailuresTruncated AS ingestFailuresTruncated,
|
||||
p.ingestDurationSeconds AS ingestDurationSeconds, p.ingestServerVersion AS ingestServerVersion
|
||||
ORDER BY p.name
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 126: stamps the {@code (:Project)} shell with what a <b>whole-root</b> ingest just did, so
|
||||
* every later answer can be dated and its completeness judged from the API alone. Written only by
|
||||
* the whole-root passes — a by-name or fan-out ingest walks a fraction of the tree, and letting it
|
||||
* move {@code ingestedAt} would report the project as freshly walked when one module was deepened.
|
||||
*
|
||||
* <p>Separate from {@link #UPDATE_PROJECT} on purpose: that one {@code COALESCE}s user config, and
|
||||
* these are server observations. Neither can clobber the other.
|
||||
*/
|
||||
public static final String RECORD_PROJECT_INGEST = """
|
||||
MATCH (p:Project {name: $name})
|
||||
SET p.ingestedAt = $ingestedAt, p.ingestMode = $mode,
|
||||
p.ingestFilesExamined = $filesExamined, p.ingestFilesPersisted = $filesPersisted,
|
||||
p.ingestFilesFailed = $filesFailed, p.ingestFailures = $failures,
|
||||
p.ingestFailuresTruncated = $failuresTruncated,
|
||||
p.ingestDurationSeconds = $durationSeconds, p.ingestServerVersion = $serverVersion
|
||||
""";
|
||||
|
||||
/**
|
||||
* @return the callers query, grouping all call sites to the same caller into a single row
|
||||
* with {@code lineNos}. Includes incoming {@code EXTENDS}/{@code IMPLEMENTS} edges (subtypes of
|
||||
|
||||
@@ -1339,7 +1339,62 @@ public class GraphRepository {
|
||||
: record.get("excludeDirs").asList(value -> value.asString());
|
||||
return new ProjectInfo(record.get("name").asString(), description, root, excludeDirs,
|
||||
nullableString(record, "language"), nullableString(record, "generatedDir"),
|
||||
nullableString(record, "userExitDir"));
|
||||
nullableString(record, "userExitDir"), toProjectIngestInfo(record));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 126: the last whole-root ingest's facts, or {@code null} when the project shell carries no
|
||||
* {@code ingestedAt} — i.e. it was last ingested before this was recorded. Null rather than a
|
||||
* zero-filled record: "never measured" and "measured as none" are different answers, and
|
||||
* conflating them is the failure mode the item is about.
|
||||
*/
|
||||
private static @Nullable ProjectIngestInfo toProjectIngestInfo(Record record) {
|
||||
@Nullable String ingestedAt = nullableString(record, "ingestedAt");
|
||||
if (ingestedAt == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> failures = record.containsKey("ingestFailures") && !record.get("ingestFailures").isNull()
|
||||
? record.get("ingestFailures").asList(value -> value.asString())
|
||||
: List.of();
|
||||
return new ProjectIngestInfo(ingestedAt,
|
||||
nullableString(record, "ingestMode") != null ? record.get("ingestMode").asString() : "",
|
||||
intOrZero(record, "ingestFilesExamined"), intOrZero(record, "ingestFilesPersisted"),
|
||||
intOrZero(record, "ingestFilesFailed"), failures,
|
||||
record.containsKey("ingestFailuresTruncated") && !record.get("ingestFailuresTruncated").isNull()
|
||||
&& record.get("ingestFailuresTruncated").asBoolean(),
|
||||
record.containsKey("ingestDurationSeconds") && !record.get("ingestDurationSeconds").isNull()
|
||||
? record.get("ingestDurationSeconds").asLong() : 0L,
|
||||
nullableString(record, "ingestServerVersion") != null
|
||||
? record.get("ingestServerVersion").asString() : "");
|
||||
}
|
||||
|
||||
private static int intOrZero(Record record, String key) {
|
||||
return record.containsKey(key) && !record.get(key).isNull() ? record.get(key).asInt() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 126: records what a whole-root ingest just did on the {@code (:Project)} shell. The failure
|
||||
* list is capped at {@link ProjectIngestInfo#MAX_FAILURES} with an explicit truncation flag — the
|
||||
* count stays exact, so a short list never reads as the whole story.
|
||||
*/
|
||||
public Uni<Void> recordProjectIngest(String project, ProjectIngestInfo ingest) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("name", project);
|
||||
params.put("ingestedAt", ingest.ingestedAt());
|
||||
params.put("mode", ingest.mode());
|
||||
params.put("filesExamined", ingest.filesExamined());
|
||||
params.put("filesPersisted", ingest.filesPersisted());
|
||||
params.put("filesFailed", ingest.filesFailed());
|
||||
params.put("failures", ingest.failures());
|
||||
params.put("failuresTruncated", ingest.failuresTruncated());
|
||||
params.put("durationSeconds", ingest.durationSeconds());
|
||||
params.put("serverVersion", ingest.serverVersion());
|
||||
return Uni.createFrom().item(() -> {
|
||||
try (Session session = driver.session()) {
|
||||
session.executeWriteWithoutResult(tx -> tx.run(CypherQueries.RECORD_PROJECT_INGEST, params));
|
||||
}
|
||||
return project;
|
||||
}).replaceWithVoid();
|
||||
}
|
||||
|
||||
private static @Nullable String nullableString(Record record, String key) {
|
||||
|
||||
@@ -20,12 +20,23 @@ import java.util.List;
|
||||
* They are set together or not at all; {@code null} when the project has no user-exit split.
|
||||
*/
|
||||
public record ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir,
|
||||
@Nullable ProjectIngestInfo ingest) {
|
||||
|
||||
/**
|
||||
* Legacy convenience constructor for projects without a language / user-exit split.
|
||||
*/
|
||||
public ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs) {
|
||||
this(name, description, root, excludeDirs, null, null, null);
|
||||
this(name, description, root, excludeDirs, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-only constructor: everything above is what the user configured, {@code ingest} is what the
|
||||
* server observed. Kept separate on purpose — this record is also the <em>input</em> to an ingest
|
||||
* (see {@code ProjectRootResolver}), and a run must never be fed the state it is about to replace.
|
||||
*/
|
||||
public ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
|
||||
this(name, description, root, excludeDirs, language, generatedDir, userExitDir, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.agenticcode.neo4jstore.graph;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Item 126: what the last <b>whole-root</b> ingest of a project did, recorded on the {@code (:Project)}
|
||||
* shell so a caller can tell an answer's age and completeness from the API instead of crawling the
|
||||
* file system.
|
||||
*
|
||||
* <p>Without this, a "not found" was never evidence: an empty result could mean the name does not
|
||||
* exist, or that the project was never fully ingested, and nothing in any response told the two
|
||||
* apart. {@code null} on {@link ProjectInfo#ingest()} means <b>never recorded</b> (a project ingested
|
||||
* before this was introduced) — deliberately not zeros, which would state a fact that was never
|
||||
* measured.
|
||||
*
|
||||
* <p><b>Only whole-root passes write this</b> — create-time Tier-1 scan, {@code refresh},
|
||||
* {@code refresh?deep=true}. A by-name deep ingest, a {@code refresh/{name}} or the fan-out warm
|
||||
* ingest real files but see a fraction of the tree, so letting them stamp {@code ingestedAt} would
|
||||
* report the project as freshly walked when one module was deepened — the "looks complete but isn't"
|
||||
* answer this item exists to remove.
|
||||
*
|
||||
* <p><b>This is not a freshness guarantee.</b> It says when the walk ran, not that the graph still
|
||||
* matches disk: files edited afterwards are stale while {@code ingestedAt} still looks recent.
|
||||
* Content-hash-based staleness is item 129.
|
||||
*
|
||||
* @param ingestedAt UTC ISO-8601 instant the ingest finished
|
||||
* @param mode {@code "tier1"} (coarse create-time scan), {@code "call_graph"}
|
||||
* ({@code refresh}) or {@code "full"} ({@code refresh?deep=true})
|
||||
* @param filesExamined source files the walk found and attempted
|
||||
* @param filesPersisted files actually parsed and written (examined minus failed and minus
|
||||
* duplicate-skipped identities)
|
||||
* @param filesFailed how many files failed to parse — the <b>full</b> count, even when
|
||||
* {@code failures} below is truncated
|
||||
* @param failures the failing paths (relative to the project root), capped
|
||||
* @param failuresTruncated {@code true} when {@code failures} lists fewer paths than
|
||||
* {@code filesFailed}, so a short list is never mistaken for the whole story
|
||||
* @param durationSeconds how long the pass took
|
||||
* @param serverVersion the {@code agenticcode.version} of the server that wrote the graph — the
|
||||
* server release, not a per-parser grammar version
|
||||
*/
|
||||
public record ProjectIngestInfo(String ingestedAt, String mode, int filesExamined, int filesPersisted,
|
||||
int filesFailed, List<String> failures, boolean failuresTruncated,
|
||||
long durationSeconds, String serverVersion) {
|
||||
|
||||
/**
|
||||
* Cap on the persisted {@code failures} list: a misconfigured root can fail thousands of files and
|
||||
* a graph property is not a log. The count stays exact and {@link #failuresTruncated} says the list
|
||||
* was cut, so the truncation is never silent.
|
||||
*/
|
||||
public static final int MAX_FAILURES = 200;
|
||||
}
|
||||
@@ -69,6 +69,34 @@ numbers — re-ingest (refresh) the project to update the graph. Line ranges you
|
||||
read directly off disk are of course always current; this only guards the API's
|
||||
own slicing. Copycode/INCLUDE slices are raw pre-expansion file text.
|
||||
|
||||
## Is this project's graph any good? (item 126)
|
||||
|
||||
`GET /api/projects` and `GET /api/projects/{p}` (CLI `ac project list` / `ac project show <p>`)
|
||||
carry an `ingest` object describing the **last whole-root ingest**:
|
||||
|
||||
```json
|
||||
"ingest": { "ingestedAt": "2026-08-18T10:12:44Z", "mode": "full", "filesExamined": 2981,
|
||||
"filesPersisted": 2977, "filesFailed": 4, "failures": ["a/B.java", "..."],
|
||||
"failuresTruncated": false, "durationSeconds": 176, "serverVersion": "…" }
|
||||
```
|
||||
|
||||
Use it before trusting a **negative** answer: without it, "no such module" and "that part of the
|
||||
project was never ingested" are the same empty response. Three rules the field obeys:
|
||||
|
||||
* **`ingest: null` means never recorded**, not "ingested nothing" — a project last walked before
|
||||
this existed reads as null rather than as a fabricated zero.
|
||||
* **Only whole-root passes write it** — the create-time Tier-1 scan, `refresh`, `refresh?deep=true`.
|
||||
A by-name `refresh/{name}`, a deep ingest or a fan-out warm ingests real files but sees a fraction
|
||||
of the tree, so it deliberately leaves `ingestedAt` alone; otherwise deepening one module would
|
||||
advertise the whole project as freshly walked.
|
||||
* **`ingestedAt` is not a freshness guarantee.** It says when the walk ran, not that the graph still
|
||||
matches disk — a file edited a minute later is stale while the timestamp still looks recent. For
|
||||
the real check, read a file through `GET /{p}/source?file=…`, which answers `409 STALE_SOURCE`
|
||||
when the content no longer matches the ingested hash. (Targeted/incremental refresh is item 129.)
|
||||
|
||||
`failures` is capped at 200 paths while `filesFailed` stays exact; `failuresTruncated` says whether
|
||||
the list was cut, so a short list is never mistaken for the whole story.
|
||||
|
||||
## Tier-1 coarse scan on project create (item 36)
|
||||
|
||||
Creating a project (`POST /api/projects/{p}`) now runs a **Tier-1 coarse reference scan** of the
|
||||
|
||||
@@ -43,7 +43,7 @@ cost. All probes below were run against a freshly refreshed graph and are reprod
|
||||
Items 125 and 127 were the two said to make the API return a *wrong* answer rather than a missing
|
||||
one, which is why they led the list. **125 is fixed (2026-08-18); 127 was retracted the same day —
|
||||
its probe was not ambiguous, so the answer had been correct all along (see the retraction below).**
|
||||
That leaves **126, 128, 129, 130** open.
|
||||
**126 is fixed (2026-08-18).** That leaves **128, 129, 130** open.
|
||||
|
||||
- [x] **125. `search/identifier` did not match a type declaration's short name — and silently ignored `contains`** (
|
||||
fixed 2026-08-18)
|
||||
@@ -86,7 +86,7 @@ That leaves **126, 128, 129, 130** open.
|
||||
could not check). The substring scan on `upms` (454,300 nodes) is **1.2–2.3 s** warm — usable, but
|
||||
not free: it is a label scan, so keep a `limit`.
|
||||
|
||||
- [ ] **126. `/projects` carries no ingest metadata, so "not found" is never evidence**
|
||||
- [x] **126. `/projects` carried no ingest metadata, so "not found" was never evidence** (fixed 2026-08-18)
|
||||
|
||||
**Symptom.**
|
||||
```
|
||||
@@ -99,9 +99,28 @@ That leaves **126, 128, 129, 130** open.
|
||||
agent instructions. The same blind spot hides staleness: after a code change there is no way to
|
||||
tell whether an answer predates the edit short of running a refresh.
|
||||
|
||||
**Fix.** `/projects` returns `ingestedAt`, `filesExamined`, `filesFailed` (with the list) and the
|
||||
language/parser version per project. Ideally every response carries the project's `ingestedAt`,
|
||||
so a stale answer is visible at the point of use rather than only on the project listing.
|
||||
**Delivered.** The `(:Project)` shell records what the last **whole-root** ingest did, returned as a
|
||||
nested `ingest` object on `GET /projects` and on the new `GET /projects/{p}` (CLI `ac project show`):
|
||||
`ingestedAt`, `mode` (`tier1`/`call_graph`/`full`), `filesExamined`, `filesPersisted`, `filesFailed`
|
||||
+ `failures` (capped at 200 with an explicit `failuresTruncated`, so a short list is never read as
|
||||
the whole story), `durationSeconds`, `serverVersion`. Three deliberate semantics, each pinned by a
|
||||
test in `ProjectIngestMetadataIT` (6 tests):
|
||||
|
||||
* `ingest: null` means **never recorded**, not "ingested nothing" — a zero-filled record would state
|
||||
a fact nobody measured, which is the same class of error as the one this item reports.
|
||||
* **Only whole-root passes write it.** A by-name `refresh/{name}`, a deep ingest or a fan-out warm
|
||||
ingests real files but walks a fraction of the tree; letting one move `ingestedAt` would advertise
|
||||
the whole project as freshly walked because one module was deepened.
|
||||
* `ingestedAt` is **not** a freshness guarantee — it dates the walk, not the match against disk.
|
||||
That is item 129's hash work; `GET /{p}/source?file=` (`409 STALE_SOURCE`) is today's real check.
|
||||
|
||||
Kept apart from `UPDATE_PROJECT` (user config, `COALESCE`d) so neither can clobber the other, and
|
||||
`ProjectInfo` carries the state in a nested component rather than flat — the same record is the
|
||||
*input* to an ingest, and a run must not be handed the state it is about to replace.
|
||||
|
||||
**Not delivered (deferred, not forgotten).** "Ideally every response carries the project's
|
||||
`ingestedAt`" is a cross-cutting envelope change — responses are bare arrays today. It belongs with
|
||||
item 130's scope-echo work, which rewrites the same envelopes.
|
||||
|
||||
- *Not a bug — retracted 2026-08-18 (was #127): "an ambiguous simple module name is resolved
|
||||
silently to one candidate".* The probe picked a name that is **not** ambiguous: `pur` holds exactly
|
||||
|
||||
Reference in New Issue
Block a user