diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 5107fde3e4..8e668bca41 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -101,6 +101,10 @@ export const UpdatePayload = Schema.Struct({ }).annotate({ identifier: "ProjectUpdateInput" }) export type UpdatePayload = Types.DeepMutable> +export class NotFoundError extends Schema.TaggedErrorClass()("Project.NotFoundError", { + projectID: ProjectID, +}) {} + // --------------------------------------------------------------------------- // Effect service // --------------------------------------------------------------------------- @@ -116,7 +120,7 @@ export interface Interface { readonly discover: (input: Info) => Effect.Effect readonly list: () => Effect.Effect readonly get: (id: ProjectID) => Effect.Effect - readonly update: (input: UpdateInput) => Effect.Effect + readonly update: (input: UpdateInput) => Effect.Effect readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect readonly setInitialized: (id: ProjectID) => Effect.Effect readonly sandboxes: (id: ProjectID) => Effect.Effect @@ -372,7 +376,9 @@ export const layer: Layer.Layer< const base64 = Buffer.from(buffer).toString("base64") const mime = AppFileSystem.mimeType(shortest) const url = `data:${mime};base64,${base64}` - yield* update({ projectID: input.id, icon: { url } }) + yield* update({ projectID: input.id, icon: { url } }).pipe( + Effect.catchTag("Project.NotFoundError", () => Effect.void), + ) }) const list = Effect.fn("Project.list")(function* () { @@ -400,7 +406,7 @@ export const layer: Layer.Layer< .returning() .get(), ) - if (!result) throw new Error(`Project not found: ${input.projectID}`) + if (!result) return yield* new NotFoundError({ projectID: input.projectID }) const data = fromRow(result) yield* emitUpdated(data) return data diff --git a/packages/opencode/src/server/routes/instance/httpapi/errors.ts b/packages/opencode/src/server/routes/instance/httpapi/errors.ts index c1a0691c7f..5e35d6a79a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/errors.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/errors.ts @@ -166,6 +166,15 @@ export class PtyForbiddenError extends Schema.TaggedErrorClass()( + "ProjectNotFoundError", + { + projectID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class ApiNotFoundError extends Schema.ErrorClass("NotFoundError")( { name: Schema.Literal("NotFoundError"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index f95199eb01..b7be4044fc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -2,6 +2,7 @@ import { Project } from "@/project/project" import { ProjectID } from "@/project/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ProjectNotFoundError } from "../errors" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" @@ -53,7 +54,7 @@ export const ProjectApi = HttpApi.make("project") query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Project.Info, "Updated project information"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ProjectNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "project.update", 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 9e8ca4cfa3..1b61204c4c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -4,6 +4,7 @@ import { ProjectID } from "@/project/schema" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" +import { ProjectNotFoundError } from "../errors" import { markInstanceForReload } from "../lifecycle" export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) => @@ -35,7 +36,16 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", params: { projectID: ProjectID } payload: Project.UpdatePayload }) { - return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) + return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe( + Effect.catchTag("Project.NotFoundError", (error) => + Effect.fail( + new ProjectNotFoundError({ + projectID: error.projectID, + message: `Project not found: ${error.projectID}`, + }), + ), + ), + ) }) return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 5688d13d1a..56f4ae3f61 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -22,7 +22,7 @@ const encoder = new TextEncoder() const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer) const it = testEffect(layer) -function run(fn: (svc: Project.Interface) => Effect.Effect) { +function run(fn: (svc: Project.Interface) => Effect.Effect) { return Effect.gen(function* () { const svc = yield* Project.Service return yield* fn(svc) @@ -481,7 +481,7 @@ describe("Project.update", () => { }), ) - it.live("should throw error when project not found", () => + it.live("should fail when project not found", () => Effect.gen(function* () { const exit = yield* run((svc) => svc.update({ @@ -492,9 +492,7 @@ describe("Project.update", () => { expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause) - expect(error instanceof Error ? error.message : String(error)).toContain( - "Project not found: nonexistent-project-id", - ) + expect(error).toMatchObject({ _tag: "Project.NotFoundError", projectID: "nonexistent-project-id" }) } }), ) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 5c822c1109..f2b132cb73 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -177,6 +177,15 @@ const scenarios: Scenario[] = [ }, "status", ), + http.protected + .patch("/project/{projectID}", "project.update.missing") + .mutating() + .at((ctx) => ({ + path: route("/project/{projectID}", { projectID: "project_httpapi_missing" }), + headers: ctx.headers(), + body: { name: "Missing Project" }, + })) + .json(404, object, "status"), http.protected .post("/project/git/init", "project.initGit") .mutating() diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 48244b5abd..2087ad830f 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -9,6 +9,7 @@ import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/co import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { PermissionID } from "../../src/permission/schema" +import { ProjectID } from "../../src/project/schema" import { QuestionID } from "../../src/question/schema" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { HEADER as FenceHeader } from "../../src/server/shared/fence" @@ -205,6 +206,30 @@ describe("instance HttpApi", () => { }), ) + it.live("returns typed not found bodies for missing projects", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const projectID = ProjectID.make("project_missing") + const response = yield* Effect.promise(() => + HttpApiApp.webHandler().handler( + new Request(`http://localhost/project/${projectID}`, { + method: "PATCH", + headers: { "x-opencode-directory": dir, "content-type": "application/json" }, + body: JSON.stringify({ name: "Missing" }), + }), + handlerContext, + ), + ) + + expect(response.status).toBe(404) + expect(yield* Effect.promise(() => response.json())).toEqual({ + _tag: "ProjectNotFoundError", + projectID, + message: `Project not found: ${projectID}`, + }) + }), + ) + it.live("serves path and VCS read endpoints", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index c8069bd312..74a3aa68c1 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -208,4 +208,12 @@ describe("PublicApi OpenAPI v2 errors", () => { "PtyForbiddenError", ) }) + + test("documents project not-found errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(componentName(responseRef(spec.paths["/project/{projectID}"]?.patch?.responses?.["404"]) ?? "")).toBe( + "ProjectNotFoundError", + ) + }) })