diff --git a/README.md b/README.md index d3cfe3e..249dd05 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ac-cli/src/main/java/com/agenticcode/cli/ProjectCommand.java b/ac-cli/src/main/java/com/agenticcode/cli/ProjectCommand.java index bb7280d..ab93b49 100644 --- a/ac-cli/src/main/java/com/agenticcode/cli/ProjectCommand.java +++ b/ac-cli/src/main/java/com/agenticcode/cli/ProjectCommand.java @@ -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 { } } + /** + * 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 diff --git a/ac-cli/src/main/resources/agenticcode.properties b/ac-cli/src/main/resources/agenticcode.properties index 89920c6..4edee73 100644 --- a/ac-cli/src/main/resources/agenticcode.properties +++ b/ac-cli/src/main/resources/agenticcode.properties @@ -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 diff --git a/ac-code-server/src/main/java/com/agenticcode/codeserver/api/ProjectResource.java b/ac-code-server/src/main/java/com/agenticcode/codeserver/api/ProjectResource.java index a0fea8d..913102c 100644 --- a/ac-code-server/src/main/java/com/agenticcode/codeserver/api/ProjectResource.java +++ b/ac-code-server/src/main/java/com/agenticcode/codeserver/api/ProjectResource.java @@ -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 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") diff --git a/ac-code-server/src/main/resources/application.properties b/ac-code-server/src/main/resources/application.properties index c07b3c2..7c6e090 100644 --- a/ac-code-server/src/main/resources/application.properties +++ b/ac-code-server/src/main/resources/application.properties @@ -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 diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/ProjectRenameIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/ProjectRenameIT.java new file mode 100644 index 0000000..52a210f --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/ProjectRenameIT.java @@ -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"); + } + } +} diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java index 2f99fa2..5ba4bfa 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java @@ -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 diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java index c960aa7..a4dd922 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java @@ -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 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 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. * diff --git a/x-docs/agent-api-usage-ac-implementation.md b/x-docs/agent-api-usage-ac-implementation.md index 2311641..66bdf04 100644 --- a/x-docs/agent-api-usage-ac-implementation.md +++ b/x-docs/agent-api-usage-ac-implementation.md @@ -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 `) +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

`) diff --git a/x-docs/features.md b/x-docs/features.md index a1e4b65..68d9d7b 100644 --- a/x-docs/features.md +++ b/x-docs/features.md @@ -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 `) + 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 diff --git a/x-docs/roadmap.md b/x-docs/roadmap.md index 11b5135..eefca39 100644 --- a/x-docs/roadmap.md +++ b/x-docs/roadmap.md @@ -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)