Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8bbc815
feat(project): add DELETE /project/:id endpoint (#25004)
Apr 29, 2026
6d1dc5e
fix: use NotFoundError in Project.remove and use isolated test direct…
Apr 29, 2026
dd0fbd0
chore: merge upstream dev into feat/issue-25004-delete-project-endpoint
Apr 29, 2026
b00cacf
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf Apr 30, 2026
d18cdeb
fix(project): address review feedback for DELETE /project/:id
Apr 30, 2026
3d5f6e0
chore: merge upstream dev, resolve test conflict
Apr 30, 2026
eeb7f4f
chore: merge upstream dev (head)
Apr 30, 2026
8349997
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf Apr 30, 2026
21d32a8
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf May 1, 2026
ed3f935
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf May 1, 2026
08ac03f
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf May 1, 2026
30fbeee
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf May 1, 2026
bb1926a
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf May 1, 2026
15779cd
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf May 3, 2026
135b092
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf May 3, 2026
f8627bc
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf May 3, 2026
cb0c511
Merge branch 'dev' into feat/issue-25004-delete-project-endpoint
georgernstgraf May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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 { NotFoundError } from "@/storage/storage"
import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql"
import * as Log from "@opencode-ai/core/util/log"
Expand Down Expand Up @@ -127,6 +128,7 @@ export interface Interface {
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly remove: (id: ProjectID) => Effect.Effect<{ deleted: boolean; sessionsRemoved: number }>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
Expand Down Expand Up @@ -490,6 +492,21 @@ export const layer: Layer.Layer<
yield* emitUpdated(fromRow(result))
})

const remove = Effect.fn("Project.remove")(function* (id: ProjectID) {
const sessionsRow = yield* db((d) =>
Comment on lines +495 to +496
d
.select({ count: sql<number>`count(*)` })
.from(SessionTable)
.where(eq(SessionTable.project_id, id))
.get(),
)
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({
init,
fromDirectory,
Expand All @@ -502,6 +519,7 @@ export const layer: Layer.Layer<
sandboxes,
addSandbox,
removeSandbox,
remove,
})
}),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,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({
Expand All @@ -75,3 +89,4 @@ export const ProjectApi = HttpApi.make("project")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,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* () {
Expand Down Expand Up @@ -39,6 +39,22 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
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 }
}) {
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
.handle("list", list)
.handle("current", current)
.handle("initGit", initGit)
.handle("update", update)
.handle("remove", remove_)
}),
)
38 changes: 38 additions & 0 deletions packages/opencode/src/server/routes/instance/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,43 @@ 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) => {
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)
},
),
)
31 changes: 31 additions & 0 deletions packages/opencode/test/project/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,3 +601,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("NotFoundError")
})

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()
})
})
30 changes: 30 additions & 0 deletions packages/opencode/test/server/httpapi-instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,34 @@ describe("instance HttpApi", () => {
)
}),
)

it.live("serves project delete", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })

const current = yield* HttpClientRequest.get("/project/current").pipe(
directoryHeader(dir),
HttpClient.execute,
)
expect(current.status).toBe(200)
const project = (yield* current.json) as { id: string }

const response = yield* HttpClientRequest.make("DELETE")(`/project/${project.id}`).pipe(
directoryHeader(dir),
HttpClient.execute,
)
expect(response.status).toBe(200)
expect(yield* response.json).toMatchObject({ deleted: true, sessionsRemoved: 0 })

const list = yield* HttpClientRequest.get("/project").pipe(
directoryHeader(dir),
HttpClient.execute,
)
expect(list.status).toBe(200)
const projects = (yield* list.json) as Array<{ id: string }>
expect(projects).not.toContainEqual(
expect.objectContaining({ id: project.id }),
)
}),
)
})
Loading