New features

This commit is contained in:
Ingo Schnabel
2026-08-18 17:22:31 +03:00
parent f5ca2584f3
commit 907286dabb
13 changed files with 464 additions and 21 deletions

View File

@@ -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 {

View File

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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)}.

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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