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

@ -2,6 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { Flag } from "@opencode-ai/core/flag/flag"
@ -14,7 +15,6 @@ 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"
@ -138,7 +138,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 projectDirectories = yield* ProjectDirectories.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const { db } = yield* Database.Service
@ -197,6 +197,12 @@ export const layer = Layer.effect(
.run()
}
// Project directories may be shared across distinct
// checkouts which have diverged. Clear the directory
// list and rely on it being re-populated to ensure
// accuracy
yield* d.delete(ProjectDirectoryTable).where(eq(ProjectDirectoryTable.project_id, oldID)).run()
yield* d
.update(SessionTable)
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })
@ -221,27 +227,11 @@ export const layer = Layer.effect(
}) {
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" },
)
yield* projectDirectories
.create({
directory: opened,
projectID: input.projectID,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("project directory persistence failed", { projectID: input.projectID, cause }),
@ -505,7 +495,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(ProjectDirectories.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
@ -520,7 +510,7 @@ export const node = LayerNode.make(layer, [
AppProcess.node,
CrossSpawnSpawner.node,
ProjectV2.node,
ProjectCopy.node,
ProjectDirectories.node,
EventV2Bridge.node,
RuntimeFlags.node,
Database.node,

View file

@ -1,87 +1,31 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
const root = "/experimental/project/:projectID/copy"
const CopyQuery = Schema.Struct({
workspace: WorkspaceRoutingQueryFields.workspace,
export const GenerateNamePayload = Schema.Struct({
context: Schema.optional(Schema.String),
})
export const CreatePayload = Schema.Struct({
strategy: ProjectCopy.StrategyID,
directory: ProjectCopy.CreateInput.fields.directory,
name: ProjectCopy.CreateInput.fields.name,
context: ProjectCopy.CreateInput.fields.context,
})
export const RemovePayload = Schema.Struct({
directory: ProjectCopy.RemoveInput.fields.directory,
force: ProjectCopy.RemoveInput.fields.force,
})
export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
{
name: Schema.Literal("ProjectCopyError"),
data: Schema.Struct({
message: Schema.String,
forceRequired: Schema.optional(Schema.Boolean),
}),
},
{ httpApiStatus: 400 },
) {}
export const ProjectCopyApi = HttpApi.make("projectCopy").add(
HttpApiGroup.make("projectCopy")
export const ProjectCopyApi = HttpApi.make("projectCopyName").add(
HttpApiGroup.make("projectCopyName")
.add(
HttpApiEndpoint.post("create", root, {
params: { projectID: ProjectV2.ID },
query: CopyQuery,
payload: CreatePayload,
success: described(ProjectCopy.Copy, "Project copy created"),
error: ApiProjectCopyError,
}).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: CopyQuery,
payload: RemovePayload,
success: described(HttpApiSchema.NoContent, "Project copy removed"),
error: ApiProjectCopyError,
}).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`, {
HttpApiEndpoint.post("generateName", "/experimental/project/:projectID/copy/generate-name", {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: HttpApiSchema.NoContent,
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
error: ApiProjectCopyError,
payload: GenerateNamePayload,
success: Schema.Struct({ name: Schema.String }),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.refresh",
summary: "Refresh project copies",
description: "Discover local project copies using one or all configured strategies.",
identifier: "experimental.projectCopy.generateName",
summary: "Generate project copy name",
description: "Generate a short name for a project copy from task context.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy naming routes." }))
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),

View file

@ -1,75 +1,45 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { Git } from "@opencode-ai/core/git"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstanceState } from "@/effect/instance-state"
import { Agent } from "@/agent/agent"
import { Provider } from "@/provider/provider"
import { LLM } from "@/session/llm"
import { MessageID, SessionID } from "@/session/schema"
import { Slug } from "@opencode-ai/core/util/slug"
import { LLMEvent } from "@opencode-ai/llm"
import { Effect, Stream } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy"
import { Agent } from "@/agent/agent"
import { LLM } from "@/session/llm"
import { LLMEvent } from "@opencode-ai/llm"
import { MessageID, SessionID } from "@/session/schema"
import { Provider } from "@/provider/provider"
import { Slug } from "@opencode-ai/core/util/slug"
const FALLBACK_AGENT: Agent.Info = {
name: "title",
mode: "primary" as const,
const COPY_NAME_AGENT: Agent.Info = {
name: "project-copy-name",
mode: "primary",
permission: [],
options: {},
native: true,
prompt: "",
}
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
return effect.pipe(
Effect.mapError(
(error) =>
new ApiProjectCopyError({
name: "ProjectCopyError",
data: {
message: message(error),
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
},
}),
),
)
}
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopyName", (handlers) =>
Effect.gen(function* () {
const llm = yield* LLM.Service
const agent = yield* Agent.Service
const provider = yield* Provider.Service
const service = yield* ProjectCopy.Service
const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
const text = context?.trim()
if (!text) return Slug.create()
const [titleAgent, fallback] = yield* Effect.all(
[
agent.get("title").pipe(Effect.catch(() => Effect.succeed(FALLBACK_AGENT))),
provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))),
],
{ concurrency: 2 },
)
const fallback = yield* provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!fallback) return Slug.create()
const model = titleAgent.model
? yield* provider.getModel(titleAgent.model.providerID, titleAgent.model.modelID)
: ((yield* provider.getSmallModel(fallback.providerID)) ??
(yield* provider.getModel(fallback.providerID, fallback.modelID)))
const model =
(yield* provider.getSmallModel(fallback.providerID)) ??
(yield* provider.getModel(fallback.providerID, fallback.modelID))
const sessionID = SessionID.descending()
const result = yield* llm
.stream({
agent: titleAgent,
agent: COPY_NAME_AGENT,
user: {
id: MessageID.ascending(),
sessionID,
role: "user",
time: { created: Date.now() },
agent: titleAgent.name,
agent: COPY_NAME_AGENT.name,
model: { providerID: model.providerID, modelID: model.id },
},
system: [],
@ -78,12 +48,7 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
model,
sessionID,
retries: 2,
messages: [
{
role: "user",
content: `Generate a short 3-4 word name that describes this task:\n${text}`,
},
],
messages: [{ role: "user", content: `Generate a short 2-3 word name that describes this task:\n${text}` }],
})
.pipe(
Stream.filter(LLMEvent.is.textDelta),
@ -91,47 +56,20 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
Stream.mkString,
)
const output = result.trim()
return output ? slugify(output.split(/\s+/).slice(0, 4).join(" ")) : Slug.create()
return output ? slugify(output.split(/\s+/).slice(0, 3).join(" ")) : Slug.create()
})
const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof CreatePayload.Type
}) {
const name =
ctx.payload.name ??
(yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
return yield* badRequest(
service.create({
...ctx.payload,
name,
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)
return handlers.handle("generateName", (ctx) =>
generateName(ctx.payload.context).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("project copy name generation failed", {
projectID: ctx.params.projectID,
cause,
}).pipe(Effect.as(Slug.create())),
),
Effect.map((name) => ({ name })),
),
)
}),
)
@ -143,15 +81,3 @@ function slugify(input: string) {
.replace(/^-+/, "")
.replace(/-+$/, "")
}
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.StrategyNotFoundError)
return `Project copy strategy not found for: ${error.directory}`
return error.message
}

View file

@ -26,7 +26,7 @@ function directories(projectID: ProjectV2.ID) {
Effect.orDie,
Effect.map((rows) =>
rows
.map((row) => ({ directory: row.directory, type: row.type }))
.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
.toSorted((a, b) => a.directory.localeCompare(b.directory)),
),
),
@ -41,7 +41,9 @@ describe("Project directory persistence", () => {
const result = yield* project.fromDirectory(tmp)
expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
expect(yield* directories(result.project.id)).toEqual([
{ directory: AbsolutePath.make(tmp), strategy: undefined },
])
}),
)
@ -54,7 +56,9 @@ describe("Project directory persistence", () => {
const next = yield* project.fromDirectory(tmp)
expect(next.project.id).toBe(result.project.id)
expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
expect(yield* directories(result.project.id)).toEqual([
{ directory: AbsolutePath.make(tmp), strategy: undefined },
])
}),
)
@ -73,8 +77,8 @@ describe("Project directory persistence", () => {
expect(yield* directories(main.project.id)).toEqual(
[
{ directory: tmp, type: "main" as const },
{ directory: worktree, type: "git_worktree" as const },
{ directory: AbsolutePath.make(tmp), strategy: undefined },
{ directory: AbsolutePath.make(worktree), strategy: undefined },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
@ -92,7 +96,9 @@ describe("Project directory persistence", () => {
const result = yield* project.fromDirectory(worktree)
expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
expect(yield* directories(result.project.id)).toEqual([
{ directory: AbsolutePath.make(worktree), strategy: undefined },
])
}),
)
@ -113,8 +119,8 @@ describe("Project directory persistence", () => {
expect(yield* directories(main.project.id)).toEqual(
[
{ directory: tmp, type: "main" as const },
{ directory: clone, type: "root" as const },
{ directory: AbsolutePath.make(tmp), strategy: undefined },
{ directory: AbsolutePath.make(clone), strategy: undefined },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
@ -134,7 +140,9 @@ describe("Project directory persistence", () => {
const result = yield* project.fromDirectory(worktree)
expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
expect(yield* directories(result.project.id)).toEqual([
{ directory: AbsolutePath.make(worktree), strategy: undefined },
])
}),
)
@ -163,7 +171,35 @@ describe("Project directory persistence", () => {
yield* project.fromDirectory(tmp)
expect(yield* directories(remoteID)).toEqual([{ directory: tmp, type: "main" }])
expect(yield* directories(remoteID)).toEqual([
{ directory: AbsolutePath.make(tmp), strategy: undefined },
])
}),
)
it.live("clears stale directories when the project id changes", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const project = yield* Project.Service
const original = yield* project.fromDirectory(tmp)
const stale = AbsolutePath.make(tmp + "-stale-checkout")
const { db } = yield* Database.Service
yield* db
.insert(ProjectDirectoryTable)
.values({ project_id: original.project.id, directory: stale })
.run()
.pipe(Effect.orDie)
const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/migration"))
yield* Effect.promise(() =>
$`git remote add origin git@github.com:project-directory-test/migration.git`.cwd(tmp).quiet(),
)
yield* project.fromDirectory(tmp)
expect(yield* directories(original.project.id)).toEqual([])
expect(yield* directories(remoteID)).toEqual([
{ directory: AbsolutePath.make(tmp), strategy: undefined },
])
}),
)
})

View file

@ -19,7 +19,7 @@ import { NodePath } from "@effect/platform-node"
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 { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
@ -73,7 +73,7 @@ function projectLayerWithFailure(failArg: string) {
Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
Layer.provide(mockGitFailure(failArg)),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(ProjectCopy.defaultLayer),
Layer.provide(ProjectDirectories.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(NodePath.layer),
@ -86,7 +86,7 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
return Project.layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(ProjectCopy.defaultLayer),
Layer.provide(ProjectDirectories.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(NodePath.layer),

View file

@ -222,6 +222,18 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.json(200, array, "status"),
http.protected
.post("/experimental/project/{projectID}/copy/generate-name", "experimental.projectCopy.generateName")
.seeded((ctx) => ctx.project())
.at((ctx) => ({
path: route("/experimental/project/{projectID}/copy/generate-name", { projectID: ctx.state.id }),
headers: ctx.headers(),
body: {},
}))
.json(200, (body) => {
object(body)
check(typeof body.name === "string" && body.name.length > 0, "generated copy name should be non-empty")
}),
http.protected
.post("/experimental/project/{projectID}/copy", "experimental.projectCopy.create")
.seeded((ctx) => ctx.project())

View file

@ -34,7 +34,7 @@ function json<T>(response: HttpClientResponse.HttpClientResponse) {
}
describe("project directories and copies endpoints", () => {
type ProjectDirectory = { directory: string; type: "main" | "root" | "git_worktree" }
type ProjectDirectory = { directory: string; strategy?: string }
it.instance(
"lists directories and manages git worktree copies",
@ -44,7 +44,7 @@ describe("project directories and copies endpoints", () => {
const current = yield* request(test.directory, "/project/current")
const projectID = (yield* json<{ id: string }>(current)).id
const base = `/project/${projectID}`
const copies = `/experimental/project/${projectID}/copy`
const copies = `/experimental/project/${projectID}/copy?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}`
const createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
const createdDirectory = path.join(createdParent, "copy")
yield* Effect.addFinalizer(() =>
@ -53,7 +53,15 @@ describe("project directories and copies endpoints", () => {
const initial = yield* request(test.directory, `${base}/directories`)
expect(initial.status).toBe(200)
expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory, type: "main" }])
expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory }])
const generated = yield* request(test.directory, `/experimental/project/${projectID}/copy/generate-name`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ context: undefined }),
})
expect(generated.status).toBe(200)
expect((yield* json<{ name: string }>(generated)).name).toBeString()
const create = yield* request(test.directory, copies, {
method: "POST",
@ -67,7 +75,7 @@ describe("project directories and copies endpoints", () => {
const listed = yield* request(test.directory, `${base}/directories`)
expect(yield* json<ProjectDirectory[]>(listed)).toContainEqual({
directory: created.directory,
type: "git_worktree",
strategy: "git_worktree",
})
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
@ -94,14 +102,18 @@ describe("project directories and copies endpoints", () => {
Effect.promise(() => fs.rm(externalDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${externalDirectory} HEAD`.cwd(test.directory).quiet())
const refresh = yield* request(test.directory, `${copies}/refresh`, {
const refresh = yield* request(
test.directory,
`/experimental/project/${projectID}/copy/refresh?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}`,
{
method: "POST",
})
},
)
expect(refresh.status).toBe(204)
const refreshed = yield* request(test.directory, `${base}/directories`)
expect(yield* json<ProjectDirectory[]>(refreshed)).toEqual([
{ directory: externalDirectory, type: "git_worktree" },
{ directory: test.directory, type: "main" },
{ directory: externalDirectory, strategy: "git_worktree" },
{ directory: test.directory },
])
}),
{ git: true },