Kaynağa Gözat

feat(project): support project metadata updates

Brendan Allan 2 hafta önce
ebeveyn
işleme
9afd059fa1

+ 29 - 2
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<NotFoundError>()("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<ReadonlyArray<Info>>
   readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
+  readonly update: (projectID: ID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
   readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
   /**
    * 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 })
   }),
 )
 

+ 3 - 0
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"),

+ 1 - 0
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,
         })

+ 1 - 0
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"),

+ 25 - 0
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}`))
 }

+ 1 - 0
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,
   }),
 )

+ 1 - 0
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,
   }),
 )

+ 1 - 0
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,
   }),
 )

+ 1 - 0
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,
   }),
 )

+ 10 - 0
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>()(
   "InvalidRequestError",
@@ -62,6 +63,15 @@ export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFo
   { httpApiStatus: 404 },
 ) {}
 
+export class ProjectNotFoundError extends Schema.TaggedErrorClass<ProjectNotFoundError>()(
+  "ProjectNotFoundError",
+  {
+    projectID: Project.ID,
+    message: Schema.String,
+  },
+  { httpApiStatus: 404 },
+) {}
+
 export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
   "SessionNotFoundError",
   {

+ 15 - 0
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,

+ 7 - 0
packages/schema/src/project.ts

@@ -56,5 +56,12 @@ export const Info = Schema.Struct({
 }).annotate({ identifier: "Project" })
 export interface Info extends Schema.Schema.Type<typeof Info> {}
 
+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<typeof UpdateInput> {}
+
 const Updated = ephemeral({ type: "project.updated", schema: Info.fields })
 export const Event = { Updated, Definitions: inventory(Updated) }

+ 16 - 0
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 }),