This commit is contained in:
Ingo Schnabel
2026-09-23 12:39:15 +02:00
parent 432ddf1c11
commit 7e0d75cc5e
11 changed files with 296 additions and 19 deletions

View File

@@ -358,6 +358,7 @@ agenticcode> exit
ac project create upms /path/to/sources -l natural -d "UPMS legacy"
ac project list
ac project update upms -d "New description"
ac project rename upms upms_alt # every node, override and counterpart reference follows
ac project delete upms
# Ingest / refresh

View File

@@ -12,17 +12,18 @@ import java.util.Objects;
import java.util.concurrent.Callable;
/**
* Groups project management subcommands: create, update, delete, recreate, show, list.
* Groups project management subcommands: create, update, delete, recreate, rename, show, list.
*/
@Command(
name = "project",
mixinStandardHelpOptions = true,
description = "Create, update, delete, recreate, show or list projects",
description = "Create, update, delete, recreate, rename, show or list projects",
subcommands = {
ProjectCommand.CreateCommand.class,
ProjectCommand.UpdateCommand.class,
ProjectCommand.DeleteCommand.class,
ProjectCommand.RecreateCommand.class,
ProjectCommand.RenameCommand.class,
ProjectCommand.ShowCommand.class,
ProjectCommand.ListCommand.class
}
@@ -180,6 +181,28 @@ final class ProjectCommand implements Callable<Integer> {
}
}
/**
* Item 202: rename a project in place (nodes, overrides and other projects' counterparts follow).
*/
@Command(name = "rename", mixinStandardHelpOptions = true,
description = "Rename a project; every node, override and counterpart reference follows")
static final class RenameCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Current project name")
String name;
@SuppressWarnings("NullAway.Init")
@Parameters(index = "1", description = "New project name")
String newName;
@Override
public Integer call() throws Exception {
return printResponse(apiClient().postJson("/api/projects/" + encode(name) + "/rename",
java.util.Map.of("newName", newName)));
}
}
/**
* Item 78: re-initialise a project from its own stored config — no need to look up and re-type
* {@code root}/{@code language}/{@code generatedDir}/{@code userExitDir}, and no window in which they

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=330
version=333

View File

@@ -1,6 +1,7 @@
package com.agenticcode.codeserver.api;
import com.agenticcode.codeserver.service.ProjectIngestService;
import com.agenticcode.codeserver.service.ProjectMetadataCache;
import com.agenticcode.codeserver.service.ProjectRootResolver;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
@@ -38,14 +39,16 @@ public class ProjectResource {
private final GraphRepository graphRepository;
private final ProjectIngestService ingestService;
private final ProjectRootResolver rootResolver;
private final ProjectMetadataCache projectMetadataCache;
private final boolean scanOnCreate;
public ProjectResource(GraphRepository graphRepository, ProjectIngestService ingestService,
ProjectRootResolver rootResolver,
ProjectRootResolver rootResolver, ProjectMetadataCache projectMetadataCache,
@ConfigProperty(name = "agenticcode.tier1.scan-on-create", defaultValue = "true") boolean scanOnCreate) {
this.graphRepository = graphRepository;
this.ingestService = ingestService;
this.rootResolver = rootResolver;
this.projectMetadataCache = projectMetadataCache;
this.scanOnCreate = scanOnCreate;
}
@@ -214,6 +217,48 @@ public class ProjectResource {
return graphRepository.clearAll().replaceWith(Response.noContent().build());
}
/**
* Item 202: rename a project in place — every node, override and counterpart reference follows.
*/
@POST
@Path("/{project}/rename")
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Rename a project (item 202)",
description = "Rewrites the project key on every node and override and in other projects' counterparts lists. "
+ "Batched; an interrupted rename is finished by re-running it.")
@APIResponse(responseCode = "200", description = "The renamed project.")
@APIResponse(responseCode = "400", description = "newName missing, blank or equal to the current name.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "409", description = "A project with the new name already exists.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> rename(@PathParam("project") String project, RenameRequest request) {
String newName = request == null || request.newName() == null ? "" : request.newName().trim();
if (newName.isEmpty() || newName.equals(project)) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "INVALID_REQUEST",
"newName is required and must differ from the current name"));
}
return graphRepository.renameProject(project, newName)
.flatMap(result -> switch (result) {
case SUCCESS -> {
projectMetadataCache.invalidate(project);
projectMetadataCache.invalidate(newName);
yield graphRepository.getProject(newName).map(info -> Response.ok(info).build());
}
case NOT_FOUND -> Uni.createFrom().item(error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
"Project '" + project + "' does not exist"));
case CONFLICT -> Uni.createFrom().item(error(Response.Status.CONFLICT, "PROJECT_EXISTS",
"Project '" + newName + "' already exists"));
});
}
/**
* Body of {@code POST /api/projects/{project}/rename}.
*/
public record RenameRequest(@Nullable String newName) {
}
@DELETE
@Path("/{project}")
@Operation(summary = "Delete a project")

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=330
agenticcode.version=333
# 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,116 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
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 java.util.List;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 202: renaming a project moves every node, its dynamic-call overrides and other projects'
* {@code counterparts} references; the old name answers 404 afterwards, the new one answers everything
* the old one did. Refuses a taken name and a blank or unchanged one.
*/
@QuarkusTest
class ProjectRenameIT {
private static final String OLD = "rename-old";
private static final String NEW = "rename-new";
private static final String PEER = "rename-peer";
@TempDir
static Path root;
@TempDir
static Path peerRoot;
@jakarta.inject.Inject
org.neo4j.driver.Driver driver;
@BeforeAll
static void setUp() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write(root, "CALLER.nat", """
DEFINE DATA
LOCAL
01 #TGT (A8)
END-DEFINE
CALLNAT 'CALLEE' #TGT
CALLNAT #TGT
END
""");
write(root, "CALLEE.nat", """
DEFINE DATA
PARAMETER
01 #P (A8)
END-DEFINE
END
""");
write(peerRoot, "Peer.java", "package p; public class Peer {}\n");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + OLD).then().statusCode(201);
given().when().post("/api/projects/" + OLD + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, peerRoot.toString(), null, "java", null, null, List.of(OLD)))
.when().post("/api/projects/" + PEER).then().statusCode(201);
// a manual override that must follow the rename
String file = given().when().get("/api/projects/" + OLD + "/dynamic-calls/unresolved").then().statusCode(200)
.extract().path("[0].originFile");
Integer line = given().when().get("/api/projects/" + OLD + "/dynamic-calls/unresolved").then().statusCode(200)
.extract().path("[0].lineNo");
given().contentType("application/json")
.body(Map.of("originFile", file, "lineNo", line, "targets", List.of("CALLEE"), "variable", "#TGT"))
.when().post("/api/projects/" + OLD + "/dynamic-calls/overrides").then().statusCode(200);
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void renameMovesNodesOverridesAndCounterpartReferences() {
given().contentType("application/json").body(Map.of("newName", " "))
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(400).body("code", equalTo("INVALID_REQUEST"));
given().contentType("application/json").body(Map.of("newName", OLD))
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(400);
given().contentType("application/json").body(Map.of("newName", PEER))
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(409).body("code", equalTo("PROJECT_EXISTS"));
given().contentType("application/json").body(Map.of("newName", NEW))
.when().post("/api/projects/nope/rename").then().statusCode(404);
given().contentType("application/json").body(Map.of("newName", NEW))
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(200)
.body("name", equalTo(NEW)).body("language", equalTo("natural")).body("ingest.filesPersisted", equalTo(2));
given().when().get("/api/projects/" + OLD).then().statusCode(404);
given().when().get("/api/projects/" + NEW + "/modules").then().statusCode(200).body("name", containsInAnyOrder("CALLER", "CALLEE"));
given().when().get("/api/projects/" + NEW + "/modules/CALLER/callees").then().statusCode(200)
.body("items.name", hasItem("CALLEE"));
given().when().get("/api/projects/" + NEW + "/dynamic-calls/overrides").then().statusCode(200)
.body("size()", equalTo(1)).body("[0].targets", contains("CALLEE"));
given().when().get("/api/projects/" + PEER).then().statusCode(200).body("counterparts", contains(NEW));
given().when().get("/api/projects").then().statusCode(200)
.body("name", hasItem(NEW)).body("name", not(hasItem(OLD)));
// a full delete takes the project's overrides with it (found on upms2: 10 orphaned override nodes)
given().when().delete("/api/projects/" + NEW).then().statusCode(204);
try (var session = driver.session()) {
long orphans = session.run("MATCH (o:DynamicCallOverride {project: $p}) RETURN count(o) AS c", Map.of("p", NEW))
.single().get("c").asLong();
org.junit.jupiter.api.Assertions.assertEquals(0, orphans, "override nodes of a deleted project");
}
}
}

View File

@@ -2798,6 +2798,38 @@ public final class CypherQueries {
* graph. Split from the node delete so {@code recreate} can reuse the latter alone. Not batched: it
* is a single node.
*/
/**
* Item 202: renames a project. The name is the key on every {@code AstNode} ({@code project}), on the
* {@code DynamicCallOverride}s and on the {@code Project} shell, and other shells may list it in
* {@code counterparts}; edges carry no project. The node rewrite is batched like
* {@link #DELETE_PROJECT_NODES} (implicit transaction) and runs first, so an interrupted rename is
* finished by re-running it: nodes already moved answer to the new name, the shell still to the old.
*/
public static final String RENAME_PROJECT_NODES = """
MATCH (n:AstNode {project: $from})
CALL { WITH n SET n.project = $to } IN TRANSACTIONS OF $batchSize ROWS
""";
public static final String RENAME_PROJECT_OVERRIDES = """
MATCH (o:DynamicCallOverride {project: $from})
SET o.project = $to
""";
public static final String RENAME_PROJECT_COUNTERPART_REFS = """
MATCH (p:Project)
WHERE $from IN coalesce(p.counterparts, [])
SET p.counterparts = [c IN p.counterparts | CASE WHEN c = $from THEN $to ELSE c END]
""";
public static final String RENAME_PROJECT_SHELL = """
MATCH (p:Project {name: $from})
SET p.name = $to
""";
/**
* Item 202 (found while deleting {@code upms2}): a project's manual overrides are its own data and
* go with the full delete. The recreate path (item 78) keeps them on purpose — they are config.
*/
public static final String DELETE_PROJECT_OVERRIDES = """
MATCH (o:DynamicCallOverride {project: $name})
DELETE o
""";
public static final String DELETE_PROJECT_SHELL = """
MATCH (p:Project {name: $name})
DELETE p

View File

@@ -2441,12 +2441,39 @@ public class GraphRepository {
return ProjectOpResult.NOT_FOUND;
}
deleteNodesBatched(session, name);
session.run(CypherQueries.DELETE_PROJECT_OVERRIDES, Map.of("name", name)).consume();
session.run(CypherQueries.DELETE_PROJECT_SHELL, Map.of("name", name)).consume();
return ProjectOpResult.SUCCESS;
}
});
}
/**
* Item 202: renames {@code from} to {@code to} — nodes (batched, implicit transaction like the
* delete), overrides, other projects' {@code counterparts} entries, then the shell.
*
* @return {@code NOT_FOUND} if {@code from} does not exist, {@code CONFLICT} if {@code to} already
* does, else {@code SUCCESS}.
*/
public Uni<ProjectOpResult> renameProject(String from, String to) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
if (!session.run(CypherQueries.PROJECT_EXISTS, Map.of("name", from)).hasNext()) {
return ProjectOpResult.NOT_FOUND;
}
if (session.run(CypherQueries.PROJECT_EXISTS, Map.of("name", to)).hasNext()) {
return ProjectOpResult.CONFLICT;
}
Map<String, Object> params = Map.of("from", from, "to", to, "batchSize", deleteBatchSize);
session.run(CypherQueries.RENAME_PROJECT_NODES, params).consume();
session.run(CypherQueries.RENAME_PROJECT_OVERRIDES, params).consume();
session.run(CypherQueries.RENAME_PROJECT_COUNTERPART_REFS, params).consume();
session.run(CypherQueries.RENAME_PROJECT_SHELL, params).consume();
return ProjectOpResult.SUCCESS;
}
});
}
/**
* Item 78: deletes a project's graph but keeps its {@code (:Project)} shell, so the config survives.
*

View File

@@ -314,6 +314,17 @@ already ran are committed, so queries keep answering, just from a half-updated g
self-heal (a killed process clears nothing) and does not distinguish "running right now" from "died an
hour ago" — both mean the same thing to a caller. A completed refresh clears it.
## Renaming a project (item 202)
`POST /api/projects/{p}/rename` with `{"newName": "…"}` (CLI `ac project rename <old> <new>`)
moves the project key on every node and override and in other projects' `counterparts` lists, then
the shell; the old name answers `404` afterwards and nothing needs re-ingesting. Refusals:
`400 INVALID_REQUEST` (blank or unchanged), `404`, `409 PROJECT_EXISTS`. The node rewrite is batched;
an interrupted rename is finished by running it again (97 s for the 939k-node `upms`). Use it to
keep a reference graph next to a fresh ingest (`ac project rename upms upms_alt`, then create `upms`
again and compare). A full `DELETE` of a project now also removes its manual overrides; `recreate`
keeps them.
## 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>`)

View File

@@ -5505,6 +5505,26 @@ reproduced the bug.)*
75 reading tokens), `palette.primary.dark` read 26 times; no placeholder left except the undeclared
tokens. The item-195 inherited-field fix is confirmed on the same run (its 8 placeholders are gone).
## Project rename — item 202 (2026-09-23)
- [x] **202. A project cannot be renamed**
`POST /api/projects/{name}/rename` with `{"newName": "..."}` (CLI `ac project rename <old> <new>`)
rewrites the project key everywhere it lives: `project` on every `AstNode` (batched
`IN TRANSACTIONS` like the delete, implicit transaction), on the `DynamicCallOverride`s, the
entries of other projects' `counterparts` lists, and finally the `Project` shell's `name`. Edges
carry no project. `400 INVALID_REQUEST` for a blank or unchanged name, `404` for an unknown project,
`409 PROJECT_EXISTS` for a taken target. The metadata cache is invalidated for both names. Because
the nodes move first and the shell last, an interrupted rename is finished by re-running it (the
shell still answers to the old name until then). Test: `ProjectRenameIT` (nodes, callees, override
and a peer's counterpart reference follow; old name 404; the three refusals).
Found on the way: `DELETE /api/projects/{p}` left the project's `DynamicCallOverride` nodes behind
(10 orphans after deleting `upms2`). The full delete now removes them; the recreate path (item 78)
keeps them on purpose, they are configuration. Asserted at the end of `ProjectRenameIT`.
Measured: renaming `upms` (938 746 nodes, 10 overrides) to `upms_alt` took 97 s on server 333.
## Override apply rebuilds `CALLS_MODULE`; single-INCLUDE programs — items 200, 201 (2026-09-23)
- [x] **200. A dynamic-`CALLNAT` override is applied to `CALLS` at once, but the derived `CALLS_MODULE` edges are not

View File

@@ -106,6 +106,22 @@ Item **141** — the one that made the API return a *wrong* answer rather than a
## Known bugs
- [ ] **203. Synthetic inheritance `CALLS` edges carry an arbitrary line and are never reaped**
(found 2026-09-23 comparing a fresh `pur` ingest against the 2026-09-11 one)
**Symptom.** `LINK_CALLS_TO_IMPLEMENTATIONS` merges one `CALLS {resolvedVia: 'INHERITANCE'}` edge
per caller/implementation pair and sets `lineNo` `ON CREATE` from whichever originating base call
the match meets first. With several call sites the line is arbitrary and changes between runs:
`SearchResultLogic` -> `ResultRepository` was line 95 in one graph and 97 in the other, both real
calls. `callers`/`callees` sites for such an edge therefore point at one call chosen at random.
The edge also carries no `ingestGen`, so the item-198 reap never removes it when the base call is
gone; only a project recreate does (`pur`: 75 stale ones next to 235 new ones in unchanged files).
**Fix.** Key the synthetic edge on the originating call (`lineNo` + `originFile`, one edge per base
call site, like the parsed edge it derives from) or aggregate all originating lines into a list;
and delete the project's (or the scoped modules') inheritance edges before re-deriving them in the
finalize, as `DELETE_CALLS_MODULE` does for `CALLS_MODULE`.
- [ ] **152. `NaturalLines.stripInlineComment` is not quote-aware — a `/*` inside a string literal
truncates the statement** (split out of item 141 on 2026-08-28, pre-existing)
@@ -260,20 +276,6 @@ wrong answer, found by the 2026-07-17 `VMULTMN4` audit.)*
## Agent API gaps
- [ ] **202. A project cannot be renamed** (asked 2026-09-23 while rebuilding `upms2` as a copy of `upms`)
**Symptom.** `PUT /api/projects/{name}` changes description, root, language, exclude dirs and
counterparts, but the name is the key: every node, edge and `DynamicCallOverride` carries a
`project` property, and the `Project` node is found by it. Renaming today means create + full
ingest + replaying the overrides + delete, i.e. ~7 minutes on `upms`.
**Fix.** `POST /api/projects/{name}/rename` (body `{newName}`) with a matching `ac project rename`,
refusing an existing target name and a name that is someone's counterpart unless those are rewritten
too: one transaction that sets `project` on the `Project` node, all `AstNode`s, all
`DynamicCallOverride`s and every edge property that stores it, then rewrites `counterparts` lists
that name the old project. Must be measured on `upms` (2.1 M nodes): a single `SET` over all nodes
may need batching (`CALL { } IN TRANSACTIONS`).
- [ ] **108. `dispatch-table` only understands the `DECIDE` dispatcher, not the dispatch-*table* idiom —
the one the endpoint is named after** (found 2026-08-02, `upms` webservice-layer audit)