From 8bbc815e8d4500445228b296ff9b57ec10b4d879 Mon Sep 17 00:00:00 2001 From: Georg Graf Date: Wed, 29 Apr 2026 22:21:48 +0200 Subject: [PATCH 1/3] feat(project): add DELETE /project/:id endpoint (#25004) --- packages/opencode/src/project/project.ts | 19 ++++++++++- .../server/routes/instance/httpapi/project.ts | 27 +++++++++++++++- .../src/server/routes/instance/project.ts | 32 +++++++++++++++++++ .../opencode/test/project/project.test.ts | 31 ++++++++++++++++++ .../test/server/httpapi-instance.test.ts | 20 ++++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 648bfc8fed75..b0df18b847b1 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,7 +1,7 @@ import z from "zod" import { and } from "drizzle-orm" import { Database } from "@/storage/db" -import { eq } from "drizzle-orm" +import { eq, sql } from "drizzle-orm" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" import * as Log from "@opencode-ai/core/util/log" @@ -117,6 +117,7 @@ export interface Interface { readonly sandboxes: (id: ProjectID) => Effect.Effect readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect + readonly remove: (id: ProjectID) => Effect.Effect<{ deleted: boolean; sessionsRemoved: number }> } export class Service extends Context.Service()("@opencode/Project") {} @@ -464,6 +465,21 @@ export const layer: Layer.Layer< yield* emitUpdated(fromRow(result)) }) + const remove = Effect.fn("Project.remove")(function* (id: ProjectID) { + const existing = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!existing) throw new Error(`Project not found: ${id}`) + const sessionsRow = yield* db((d) => + d + .select({ count: sql`count(*)` }) + .from(SessionTable) + .where(eq(SessionTable.project_id, id)) + .get(), + ) + const sessionsRemoved = sessionsRow?.count ?? 0 + yield* db((d) => d.delete(ProjectTable).where(eq(ProjectTable.id, id)).run()) + return { deleted: true, sessionsRemoved } + }) + return Service.of({ fromDirectory, discover, @@ -475,6 +491,7 @@ export const layer: Layer.Layer< sandboxes, addSandbox, removeSandbox, + remove, }) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/project.ts b/packages/opencode/src/server/routes/instance/httpapi/project.ts index 276798b0b946..0f521394c867 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/project.ts @@ -52,6 +52,20 @@ export const ProjectApi = HttpApi.make("project") description: "Update project properties such as name, icon, and commands.", }), ), + HttpApiEndpoint.delete("remove", `${root}/:projectID`, { + params: { projectID: ProjectID }, + success: Schema.Struct({ + deleted: Schema.Boolean, + sessionsRemoved: Schema.Number, + }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "project.delete", + summary: "Delete project", + description: + "Delete a project and permanently remove all associated data including sessions, messages, and history.", + }), + ), ) .annotateMerge( OpenApi.annotations({ @@ -102,6 +116,17 @@ export const projectHandlers = HttpApiBuilder.group(ProjectApi, "project", (hand return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) }) - return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) + const remove_ = Effect.fn("ProjectHttpApi.remove")(function* (ctx: { + params: { projectID: ProjectID } + }) { + return yield* svc.remove(ctx.params.projectID) + }) + + return handlers + .handle("list", list) + .handle("current", current) + .handle("initGit", initGit) + .handle("update", update) + .handle("remove", remove_) }), ) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index b9f86b18391c..2b461ef1d389 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -118,5 +118,37 @@ export const ProjectRoutes = lazy(() => const svc = yield* Project.Service return yield* svc.update({ ...body, projectID }) }), + ) + .delete( + "/:projectID", + describeRoute({ + summary: "Delete project", + description: + "Delete a project and permanently remove all associated data including sessions, messages, and history. Cascading foreign keys handle all child data.", + operationId: "project.delete", + responses: { + 200: { + description: "Successfully deleted project with cascade count", + content: { + "application/json": { + schema: resolver( + z.object({ + deleted: z.boolean(), + sessionsRemoved: z.number(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("param", z.object({ projectID: ProjectID.zod })), + async (c) => + jsonRequest("ProjectRoutes.delete", c, function* () { + const projectID = c.req.valid("param").projectID + const svc = yield* Project.Service + return yield* svc.remove(projectID) + }), ), ) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index e69b8e6df21b..26add98d2be7 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -599,3 +599,34 @@ describe("Project.fromDirectory with bare repos", () => { } }) }) + +describe("Project.remove", () => { + test("should remove a project", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + + const result = await run((svc) => svc.remove(project.id)) + + expect(result).toEqual({ deleted: true, sessionsRemoved: 0 }) + expect(Project.get(project.id)).toBeUndefined() + }) + + test("should throw error when project not found", async () => { + await expect( + run((svc) => svc.remove(ProjectID.make("nonexistent-project-id"))), + ).rejects.toThrow("Project not found: nonexistent-project-id") + }) + + test("should remove project from list", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + + const before = Project.list() + expect(before.find((p) => p.id === project.id)).toBeDefined() + + await run((svc) => svc.remove(project.id)) + + const after = Project.list() + expect(after.find((p) => p.id === project.id)).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 4ab1da11e64a..0515189d0a5f 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -140,6 +140,26 @@ describe("instance HttpApi", () => { ) }) + test("serves project delete through Hono bridge", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) + expect(current.status).toBe(200) + const project = (await current.json()) as { id: string } + + const response = await app().request(`/project/${project.id}`, { + method: "DELETE", + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ deleted: true, sessionsRemoved: 0 }) + + const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } }) + expect(list.status).toBe(200) + expect(await list.json()).not.toContainEqual(expect.objectContaining({ id: project.id })) + }) + test("serves instance dispose through Hono bridge", async () => { await using tmp = await tmpdir() From 6d1dc5e4af3f5209af0206ca1998c76ba9f90422 Mon Sep 17 00:00:00 2001 From: Georg Graf Date: Thu, 30 Apr 2026 00:58:17 +0200 Subject: [PATCH 2/3] fix: use NotFoundError in Project.remove and use isolated test directory (#25004) --- packages/opencode/src/project/project.ts | 3 ++- packages/opencode/test/project/project.test.ts | 2 +- packages/opencode/test/server/httpapi-instance.test.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index b0df18b847b1..8c2bb70ae1d1 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -2,6 +2,7 @@ import z from "zod" import { and } from "drizzle-orm" import { Database } from "@/storage/db" import { eq, sql } from "drizzle-orm" +import { NotFoundError } from "@/storage/storage" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" import * as Log from "@opencode-ai/core/util/log" @@ -467,7 +468,7 @@ export const layer: Layer.Layer< const remove = Effect.fn("Project.remove")(function* (id: ProjectID) { const existing = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!existing) throw new Error(`Project not found: ${id}`) + if (!existing) throw new NotFoundError({ message: `Project not found: ${id}` }) const sessionsRow = yield* db((d) => d .select({ count: sql`count(*)` }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 26add98d2be7..4a8ec350ab17 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -614,7 +614,7 @@ describe("Project.remove", () => { test("should throw error when project not found", async () => { await expect( run((svc) => svc.remove(ProjectID.make("nonexistent-project-id"))), - ).rejects.toThrow("Project not found: nonexistent-project-id") + ).rejects.toThrow("NotFoundError") }) test("should remove project from list", async () => { diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 0515189d0a5f..fa0e611acb50 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -141,7 +141,7 @@ describe("instance HttpApi", () => { }) test("serves project delete through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) expect(current.status).toBe(200) From d18cdeb6874c84bdf6864f12b663fd79ae1590fa Mon Sep 17 00:00:00 2001 From: Georg Graf Date: Thu, 30 Apr 2026 14:05:47 +0200 Subject: [PATCH 3/3] fix(project): address review feedback for DELETE /project/:id - Remove TOCTOU window by using delete result changes count instead of a separate existence check - Dispose instance after deleting the current project in both Hono route and HttpApi handler to prevent stale instance cache --- packages/opencode/src/project/project.ts | 10 +++++----- .../instance/httpapi/handlers/project.ts | 9 +++++++-- .../src/server/routes/instance/project.ts | 18 ++++++++++++------ 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index b0515c820be7..5f3dd0c2df34 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -468,8 +468,6 @@ export const layer: Layer.Layer< }) const remove = Effect.fn("Project.remove")(function* (id: ProjectID) { - const existing = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!existing) throw new NotFoundError({ message: `Project not found: ${id}` }) const sessionsRow = yield* db((d) => d .select({ count: sql`count(*)` }) @@ -477,9 +475,11 @@ export const layer: Layer.Layer< .where(eq(SessionTable.project_id, id)) .get(), ) - const sessionsRemoved = sessionsRow?.count ?? 0 - yield* db((d) => d.delete(ProjectTable).where(eq(ProjectTable.id, id)).run()) - return { deleted: true, sessionsRemoved } + const result = yield* db( + (d) => d.delete(ProjectTable).where(eq(ProjectTable.id, id)).run() as unknown as { changes: number }, + ) + if (result.changes === 0) throw new NotFoundError({ message: `Project not found: ${id}` }) + return { deleted: true, sessionsRemoved: sessionsRow?.count ?? 0 } }) return Service.of({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts index fb098edaf05f..dcfec52b1027 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -6,7 +6,7 @@ import { ProjectID } from "@/project/schema" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { markInstanceForReload } from "../lifecycle" +import { markInstanceForDisposal, markInstanceForReload } from "../lifecycle" export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) => Effect.gen(function* () { @@ -44,7 +44,12 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", const remove_ = Effect.fn("ProjectHttpApi.remove")(function* (ctx: { params: { projectID: ProjectID } }) { - return yield* svc.remove(ctx.params.projectID) + const instance = yield* InstanceState.context + const result = yield* svc.remove(ctx.params.projectID) + if (ctx.params.projectID === instance.project.id) { + yield* markInstanceForDisposal(instance) + } + return result }) return handlers diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 2b461ef1d389..d7159b143dd4 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -144,11 +144,17 @@ export const ProjectRoutes = lazy(() => }, }), validator("param", z.object({ projectID: ProjectID.zod })), - async (c) => - jsonRequest("ProjectRoutes.delete", c, function* () { - const projectID = c.req.valid("param").projectID - const svc = yield* Project.Service - return yield* svc.remove(projectID) - }), + async (c) => { + const projectID = c.req.valid("param").projectID + const result = await runRequest( + "ProjectRoutes.delete", + c, + Project.Service.use((svc) => svc.remove(projectID)), + ) + if (projectID === Instance.project.id) { + await Instance.dispose() + } + return c.json(result) + }, ), )