From 9afd059fa10be836c0303a43a651cf0dfc9503fd Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 21 Jul 2026 19:02:33 +0800 Subject: [PATCH] feat(project): support project metadata updates --- packages/core/src/project.ts | 31 +++++++++++++++++-- packages/core/src/project/schema.ts | 3 ++ .../test/effect/layer-node/node-build.test.ts | 1 + packages/core/test/location.test.ts | 1 + packages/core/test/project.test.ts | 25 +++++++++++++++ packages/core/test/session-compact.test.ts | 1 + .../core/test/session-instructions.test.ts | 1 + packages/core/test/session-log.test.ts | 1 + packages/core/test/session-remove.test.ts | 1 + packages/protocol/src/errors.ts | 10 ++++++ packages/protocol/src/groups/project.ts | 15 +++++++++ packages/schema/src/project.ts | 7 +++++ packages/server/src/handlers/project.ts | 16 ++++++++++ 13 files changed, 111 insertions(+), 2 deletions(-) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 30ea465407..a5af18f637 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -3,7 +3,7 @@ export * as Project from "./project" import { Context, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" -import { asc, desc } from "drizzle-orm" +import { asc, desc, eq } from "drizzle-orm" import path from "path" import { AbsolutePath } from "./schema" import { Database } from "./database/database" @@ -37,6 +37,13 @@ export type DirectoriesInput = typeof DirectoriesInput.Type export const Directories = ProjectSchema.Directories export type Directories = typeof Directories.Type +export const UpdateInput = ProjectSchema.UpdateInput +export type UpdateInput = typeof UpdateInput.Type + +export class NotFoundError extends Schema.TaggedErrorClass()("Project.NotFoundError", { + projectID: ID, +}) {} + export interface Resolved { readonly previous?: ID readonly id: ID @@ -58,6 +65,7 @@ export const root = Effect.fn("Project.root")(function* ( export interface Interface { readonly list: () => Effect.Effect> readonly directories: (input: DirectoriesInput) => Effect.Effect + readonly update: (projectID: ID, input: UpdateInput) => Effect.Effect readonly resolve: (input: AbsolutePath) => Effect.Effect /** * Temporary bridge method for writing the resolved project ID to the repo-local cache. @@ -121,6 +129,25 @@ const layer = Layer.effect( return yield* projectDirectories.list(input.projectID) }) + const update = Effect.fn("Project.update")(function* (projectID: ID, input: UpdateInput) { + const row = yield* db + .update(ProjectTable) + .set({ + name: input.name, + icon_url: input.icon?.url, + icon_url_override: input.icon?.override, + icon_color: input.icon?.color, + commands: input.commands, + time_updated: Date.now(), + }) + .where(eq(ProjectTable.id, projectID)) + .returning() + .get() + .pipe(Effect.orDie) + if (!row) return yield* new NotFoundError({ projectID }) + return fromRow(row) + }) + const cached = Effect.fnUntraced(function* (dir: string) { return yield* fs.readFileString(path.join(dir, "opencode")).pipe( Effect.map((value) => value.trim()), @@ -229,7 +256,7 @@ const layer = Layer.effect( yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore) }) - return Service.of({ list, directories, resolve, commit }) + return Service.of({ list, directories, update, resolve, commit }) }), ) diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index 089b3097d2..14a0e24686 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -22,6 +22,9 @@ export type DirectoriesInput = typeof DirectoriesInput.Type export const Directories = Project.Directories export type Directories = typeof Directories.Type +export const UpdateInput = Project.UpdateInput +export type UpdateInput = typeof UpdateInput.Type + export const Vcs = Schema.Union([ Schema.Struct({ type: Schema.Literal("git"), diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index a4a2bf2860..25802ea305 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -79,6 +79,7 @@ describe("node build", () => { return Project.Service.of({ list: () => Effect.succeed([]), directories: () => Effect.succeed([]), + update: () => Effect.die("unused"), resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), commit: () => Effect.void, }) diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index d012a0463f..d33d20e879 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -14,6 +14,7 @@ const projectLayer = Layer.succeed( Project.Service.of({ list: () => Effect.succeed([]), directories: () => Effect.succeed([]), + update: () => Effect.die("unused"), resolve: () => Effect.succeed({ id: Project.ID.make("project"), diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index dc505465c7..1a0592dc03 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -66,6 +66,31 @@ describe("ProjectV2.list", () => { ) }) +describe("ProjectV2.update", () => { + it.effect("updates project metadata", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const project = yield* ProjectV2.Service + const id = ProjectV2.ID.make("updated") + yield* db + .insert(ProjectTable) + .values({ id, worktree: abs("/updated"), sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + + const result = yield* project.update(id, { + name: "Updated", + icon: { color: "#ffffff" }, + commands: { start: "bun dev" }, + }) + + expect(result.name).toBe("Updated") + expect(result.icon).toEqual({ color: "#ffffff" }) + expect(result.commands).toEqual({ start: "bun dev" }) + expect(result.time.updated).toBeGreaterThan(1) + }), + ) +}) + function remoteID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) } diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 3ad15fc730..e0e9cf36f6 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -36,6 +36,7 @@ const projects = Layer.succeed( list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), + update: () => Effect.die("unused"), commit: () => Effect.void, }), ) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index f0720cfc12..a116e9a953 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -57,6 +57,7 @@ const projects = Layer.succeed( list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), + update: () => Effect.die("unused"), commit: () => Effect.void, }), ) diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index 870f6acf02..1069bea1e0 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -22,6 +22,7 @@ const projects = Layer.succeed( list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), + update: () => Effect.die("unused"), commit: () => Effect.void, }), ) diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts index 8d61af76c8..cb0af7c0b6 100644 --- a/packages/core/test/session-remove.test.ts +++ b/packages/core/test/session-remove.test.ts @@ -19,6 +19,7 @@ const projects = Layer.succeed( list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), + update: () => Effect.die("unused"), commit: () => Effect.void, }), ) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index a678fb7346..edb8816756 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -1,5 +1,6 @@ import { Schema } from "effect" import { Skill } from "@opencode-ai/schema/skill" +import { Project } from "@opencode-ai/schema/project" export class InvalidRequestError extends Schema.TaggedErrorClass()( "InvalidRequestError", @@ -62,6 +63,15 @@ export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "ProjectNotFoundError", + { + projectID: Project.ID, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class SessionNotFoundError extends Schema.TaggedErrorClass()( "SessionNotFoundError", { diff --git a/packages/protocol/src/groups/project.ts b/packages/protocol/src/groups/project.ts index a62f1d072f..ab22f84801 100644 --- a/packages/protocol/src/groups/project.ts +++ b/packages/protocol/src/groups/project.ts @@ -2,6 +2,7 @@ import { Project } from "@opencode-ai/schema/project" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { LocationQuery, locationQueryOpenApi } from "./location.js" +import { ProjectNotFoundError } from "../errors.js" const root = "/api/project" @@ -17,6 +18,20 @@ export const ProjectGroup = HttpApiGroup.make("server.project") }), ), ) + .add( + HttpApiEndpoint.patch("project.update", `${root}/:projectID`, { + params: { projectID: Project.ID }, + payload: Project.UpdateInput, + success: Project.Info, + error: ProjectNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.project.update", + summary: "Update project", + description: "Update project metadata.", + }), + ), + ) .add( HttpApiEndpoint.get("project.current", `${root}/current`, { query: LocationQuery, diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index e8524300a9..e032354db6 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -56,5 +56,12 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Project" }) export interface Info extends Schema.Schema.Type {} +export const UpdateInput = Schema.Struct({ + name: optional(Schema.String), + icon: optional(Icon), + commands: optional(Commands), +}).annotate({ identifier: "Project.UpdateInput" }) +export interface UpdateInput extends Schema.Schema.Type {} + const Updated = ephemeral({ type: "project.updated", schema: Info.fields }) export const Event = { Updated, Definitions: inventory(Updated) } diff --git a/packages/server/src/handlers/project.ts b/packages/server/src/handlers/project.ts index 79206e6326..bac3a828f6 100644 --- a/packages/server/src/handlers/project.ts +++ b/packages/server/src/handlers/project.ts @@ -3,10 +3,26 @@ import { Project } from "@opencode-ai/core/project" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" +import { ProjectNotFoundError } from "@opencode-ai/protocol/errors" export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) => handlers .handle("project.list", () => Project.Service.use((project) => project.list())) + .handle("project.update", (ctx) => + Effect.gen(function* () { + const project = yield* Project.Service + return yield* project.update(ctx.params.projectID, ctx.payload).pipe( + Effect.catchTag( + "Project.NotFoundError", + () => + new ProjectNotFoundError({ + projectID: ctx.params.projectID, + message: `Project not found: ${ctx.params.projectID}`, + }), + ), + ) + }), + ) .handle("project.current", () => Location.Service.use((location) => Effect.succeed({ id: location.project.id, directory: location.project.directory }),