feat(core): project copying and tracking directories (#30139)

This commit is contained in:
James Long
2026-06-02 23:26:10 -04:00
committed by GitHub
parent 7a66eae586
commit 147c6c4d51
28 changed files with 3638 additions and 10 deletions

View File

@@ -1,6 +1,6 @@
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log"
@@ -14,6 +14,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppProcess } from "@opencode-ai/core/process"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -139,6 +140,7 @@ export const layer = Layer.effect(
const proc = yield* AppProcess.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const projectV2 = yield* ProjectV2.Service
const projectCopy = yield* ProjectCopy.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const { db } = yield* Database.Service
@@ -215,6 +217,38 @@ export const layer = Layer.effect(
.pipe(Effect.orDie)
})
const saveProjectDirectory = Effect.fn("Project.saveProjectDirectory")(function* (input: {
projectID: ProjectV2.ID
directory: string
}) {
if (input.projectID === ProjectV2.ID.global) return
const opened = AbsolutePath.make(FSUtil.resolve(input.directory))
const type = yield* projectCopy.detect({ directory: opened })
yield* db
.transaction(
(d) =>
Effect.gen(function* () {
const hasMain = yield* d
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(and(eq(ProjectDirectoryTable.project_id, input.projectID), eq(ProjectDirectoryTable.type, "main")))
.get()
yield* d
.insert(ProjectDirectoryTable)
.values({ directory: opened, project_id: input.projectID, type: type ?? (hasMain ? "root" : "main") })
.onConflictDoNothing()
.run()
}),
{ behavior: "immediate" },
)
.pipe(
Effect.catchCause((cause) =>
Effect.sync(() => log.warn("project directory persistence failed", { projectID: input.projectID, cause })),
),
)
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
@@ -302,6 +336,11 @@ export const layer = Layer.effect(
.pipe(Effect.orDie)
}
yield* saveProjectDirectory({
projectID,
directory: data.directory,
})
yield* emitUpdated(result)
if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") {
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
@@ -466,6 +505,7 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(ProjectCopy.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(FSUtil.defaultLayer),

View File

@@ -12,6 +12,7 @@ import { InstanceApi } from "./groups/instance"
import { McpApi } from "./groups/mcp"
import { PermissionApi } from "./groups/permission"
import { ProjectApi } from "./groups/project"
import { ProjectCopyApi } from "./groups/project-copy"
import { ProviderApi } from "./groups/provider"
import { PtyApi, PtyConnectApi } from "./groups/pty"
import { QuestionApi } from "./groups/question"
@@ -52,6 +53,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(InstanceApi)
.addHttpApi(McpApi)
.addHttpApi(ProjectApi)
.addHttpApi(ProjectCopyApi)
.addHttpApi(PtyApi)
.addHttpApi(QuestionApi)
.addHttpApi(PermissionApi)

View File

@@ -0,0 +1,67 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/experimental/project/:projectID/copy"
export const CreatePayload = Schema.Struct({
strategy: ProjectCopy.StrategyID,
directory: ProjectCopy.CreateInput.fields.directory,
})
export const RemovePayload = Schema.Struct({
directory: ProjectCopy.RemoveInput.fields.directory,
})
export const ProjectCopyApi = HttpApi.make("projectCopy").add(
HttpApiGroup.make("projectCopy")
.add(
HttpApiEndpoint.post("create", root, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: CreatePayload,
success: described(ProjectCopy.Copy, "Project copy created"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.create",
summary: "Create project copy",
description: "Create a local physical copy of a project using the selected strategy.",
}),
),
HttpApiEndpoint.delete("remove", root, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: RemovePayload,
success: described(HttpApiSchema.NoContent, "Project copy removed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.remove",
summary: "Remove project copy",
description: "Remove a local physical copy of a project using the selected strategy.",
}),
),
HttpApiEndpoint.post("refresh", `${root}/refresh`, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: HttpApiSchema.NoContent,
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.refresh",
summary: "Refresh project copies",
description: "Discover local project copies using one or all configured strategies.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)

View File

@@ -62,6 +62,17 @@ export const ProjectApi = HttpApi.make("project")
description: "Update project properties such as name, icon, and commands.",
}),
),
HttpApiEndpoint.get("directories", `${root}/:projectID/directories`, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
success: described(ProjectV2.Directories, "Project directories"),
}).annotateMerge(
OpenApi.annotations({
identifier: "project.directories",
summary: "List project directories",
description: "List known local absolute directories for a project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({

View File

@@ -0,0 +1,53 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstanceState } from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, RemovePayload } from "../groups/project-copy"
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
return effect.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
}
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
Effect.gen(function* () {
const service = yield* ProjectCopy.Service
const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof CreatePayload.Type
}) {
return yield* badRequest(
service.create({
...ctx.payload,
projectID: ctx.params.projectID,
sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree),
}),
)
})
const remove = Effect.fn("ProjectCopyHttpApi.remove")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof RemovePayload.Type
}) {
yield* badRequest(
service.remove({
...ctx.payload,
projectID: ctx.params.projectID,
}),
)
})
const refresh = Effect.fn("ProjectCopyHttpApi.refresh")(function* (ctx: { params: { projectID: ProjectV2.ID } }) {
yield* badRequest(
service.refresh({
projectID: ctx.params.projectID,
}),
)
})
return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
}),
)

View File

@@ -10,6 +10,7 @@ import { markInstanceForReload } from "../lifecycle"
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
Effect.gen(function* () {
const svc = yield* Project.Service
const project = yield* ProjectV2.Service
const list = Effect.fn("ProjectHttpApi.list")(function* () {
return yield* svc.list()
@@ -48,6 +49,15 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
)
})
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
const directories = Effect.fn("ProjectHttpApi.directories")((ctx: { params: { projectID: ProjectV2.ID } }) =>
project.directories({ projectID: ctx.params.projectID }),
)
return handlers
.handle("list", list)
.handle("current", current)
.handle("initGit", initGit)
.handle("update", update)
.handle("directories", directories)
}),
)

View File

@@ -26,6 +26,8 @@ import { Installation } from "@/installation"
import { InstanceLayer } from "@/project/instance-layer"
import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
@@ -74,6 +76,7 @@ import { instanceHandlers } from "./handlers/instance"
import { mcpHandlers } from "./handlers/mcp"
import { permissionHandlers } from "./handlers/permission"
import { projectHandlers } from "./handlers/project"
import { projectCopyHandlers } from "./handlers/project-copy"
import { providerHandlers } from "./handlers/provider"
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
import { questionHandlers } from "./handlers/question"
@@ -135,6 +138,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
instanceHandlers,
mcpHandlers,
projectHandlers,
projectCopyHandlers,
ptyHandlers,
questionHandlers,
permissionHandlers,
@@ -204,6 +208,8 @@ export function createRoutes(
Permission.defaultLayer,
Plugin.defaultLayer,
Project.defaultLayer,
ProjectV2.defaultLayer,
ProjectCopy.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
Pty.defaultLayer,