feat(core): refactor project copies for v2 (#31943)

This commit is contained in:
James Long 2026-06-12 14:45:16 -04:00 committed by GitHub
commit c2e6b18076
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1461 additions and 829 deletions

View file

@ -17,6 +17,7 @@ import { Authorization } from "./middleware/authorization"
import { LocationGroup } from "./groups/location"
import { IntegrationGroup } from "./groups/integration"
import { CredentialGroup } from "./groups/credential"
import { ProjectCopyGroup } from "./groups/project-copy"
export const Api = HttpApi.make("server")
.add(HealthGroup)
@ -35,6 +36,7 @@ export const Api = HttpApi.make("server")
.add(EventGroup)
.add(QuestionGroup)
.add(ReferenceGroup)
.add(ProjectCopyGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode HttpApi",

View file

@ -74,14 +74,23 @@ export const LocationGroup = HttpApiGroup.make("server.location")
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
const directory =
query.get("location[directory]") ||
(request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd())
return Location.Ref.make({
directory: AbsolutePath.make(
query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
),
directory: AbsolutePath.make(directory),
workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined,
})
}
function decode(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
export const layer = Layer.effect(
LocationMiddleware,
Effect.gen(function* () {

View file

@ -0,0 +1,57 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
const root = "/experimental/project/:projectID/copy"
export class ProjectCopyError extends Schema.ErrorClass<ProjectCopyError>("ProjectCopyError")(
{
name: Schema.Literal("ProjectCopyError"),
data: Schema.Struct({
message: Schema.String,
forceRequired: Schema.optional(Schema.Boolean),
}),
},
{ httpApiStatus: 400 },
) {}
const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"]))
const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"]))
export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
.add(
HttpApiEndpoint.post("projectCopy.create", root, {
params: { projectID: ProjectV2.ID },
query: LocationQuery,
payload: CreatePayload,
success: ProjectCopy.Copy,
error: ProjectCopyError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })),
)
.add(
HttpApiEndpoint.delete("projectCopy.remove", root, {
params: { projectID: ProjectV2.ID },
query: LocationQuery,
payload: RemovePayload,
success: HttpApiSchema.NoContent,
error: ProjectCopyError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })),
)
.add(
HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, {
params: { projectID: ProjectV2.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: ProjectCopyError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })),
)
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
.middleware(LocationMiddleware)

View file

@ -22,6 +22,7 @@ import { LocationHandler } from "./handlers/location"
import { IntegrationHandler } from "./handlers/integration"
import { CredentialHandler } from "./handlers/credential"
import { Credential } from "@opencode-ai/core/credential"
import { ProjectCopyHandler } from "./handlers/project-copy"
export const handlers = Layer.mergeAll(
HealthHandler,
@ -40,6 +41,7 @@ export const handlers = Layer.mergeAll(
EventHandler,
QuestionHandler,
ReferenceHandler,
ProjectCopyHandler,
).pipe(
Layer.provide(sessionLocationLayer),
Layer.provide(locationLayer),

View file

@ -0,0 +1,69 @@
import { Location } from "@opencode-ai/core/location"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { Git } from "@opencode-ai/core/git"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ProjectCopyError } from "../groups/project-copy"
export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) =>
Effect.succeed(
handlers
.handle("projectCopy.create", (ctx) =>
Effect.gen(function* () {
const copies = yield* ProjectCopy.Service
const location = yield* Location.Service
return yield* badRequest(
copies.create({
...ctx.payload,
projectID: ctx.params.projectID,
sourceDirectory: location.project.directory,
}),
)
}),
)
.handle("projectCopy.remove", (ctx) =>
ProjectCopy.Service.use((copies) =>
badRequest(copies.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
),
)
.handle("projectCopy.refresh", (ctx) =>
ProjectCopy.Service.use((copies) =>
badRequest(copies.refresh({ projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
),
),
),
)
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
return effect.pipe(
Effect.mapError(
(error) =>
new ProjectCopyError({
name: "ProjectCopyError",
data: {
message: message(error),
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
},
}),
),
)
}
function message(error: ProjectCopy.Error) {
if (error instanceof ProjectCopy.SourceDirectoryNotFoundError)
return `Project copy source not found: ${error.directory}`
if (error instanceof ProjectCopy.DestinationExistsError)
return `Project copy destination already exists: ${error.directory}`
if (error instanceof ProjectCopy.DirectoryUnavailableError)
return `Project copy directory unavailable: ${error.directory}`
if (error instanceof ProjectCopy.InvalidDirectoryError)
return `Invalid project copy directory: ${error.directory}`
if (error instanceof ProjectCopy.StrategyUnavailableError)
return `Project copy strategy unavailable: ${error.strategy}`
return error.message
}