feat(core): project copying and tracking directories (#30139)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user