refactor(server): expose global worktree api

This commit is contained in:
James Long
2026-08-12 21:57:21 +00:00
parent 25a0aa1301
commit 668d569da6
19 changed files with 4833 additions and 1673 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ import { Permission } from "@opencode-ai/schema/permission"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Project } from "@opencode-ai/schema/project"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { Worktree } from "@opencode-ai/schema/worktree"
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Provider } from "@opencode-ai/schema/provider"
@@ -60,7 +60,7 @@ const effectTypeReferences = [
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
...namespaceTypes("Worktree", "@opencode-ai/schema/worktree", Worktree),
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
+21 -29
View File
@@ -34,7 +34,7 @@ import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import type { Pty } from "@opencode-ai/schema/pty"
import type { Question } from "@opencode-ai/schema/question"
import type { Reference } from "@opencode-ai/schema/reference"
import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
import type { Worktree } from "@opencode-ai/schema/worktree"
import type { Vcs } from "@opencode-ai/schema/vcs"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { WebSearch } from "@opencode-ai/schema/websearch"
@@ -1230,17 +1230,9 @@ export type Endpoint13_1Input = {
export type Endpoint13_1Output = Project.Current
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
export type Endpoint13_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint13_2Output = Project.Directories
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly current: ProjectCurrentOperation<E>
readonly directories: ProjectDirectoriesOperation<E>
}
export type Endpoint14_0Input = {
@@ -1549,36 +1541,36 @@ export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type Endpoint24_0Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy: ProjectCopy.StrategyID
readonly directory: AbsolutePath
readonly name?: string | undefined
}
export type Endpoint24_0Output = ProjectCopy.Copy
export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint24_0Input = { readonly projectID: Project.ID }
export type Endpoint24_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint24_1Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy: Worktree.StrategyID
readonly directory: AbsolutePath
readonly force: boolean
readonly name?: string | undefined
}
export type Endpoint24_1Output = void
export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint24_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint24_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly directory: AbsolutePath
readonly force: boolean
}
export type Endpoint24_2Output = void
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export interface ProjectCopyApi<E = never> {
readonly create: ProjectCopyCreateOperation<E>
readonly remove: ProjectCopyRemoveOperation<E>
readonly refresh: ProjectCopyRefreshOperation<E>
export type Endpoint24_3Input = { readonly projectID: Project.ID }
export type Endpoint24_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
export interface WorktreeApi<E = never> {
readonly list: WorktreeListOperation<E>
readonly create: WorktreeCreateOperation<E>
readonly remove: WorktreeRemoveOperation<E>
readonly refresh: WorktreeRefreshOperation<E>
}
export type Endpoint25_0Input = {
@@ -1691,7 +1683,7 @@ export interface AppApi<E = never> {
readonly shell: ShellApi<E>
readonly question: QuestionApi<E>
readonly reference: ReferenceApi<E>
readonly projectCopy: ProjectCopyApi<E>
readonly worktree: WorktreeApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
+22 -33
View File
@@ -139,8 +139,6 @@ import type {
Endpoint13_0Output,
Endpoint13_1Input,
Endpoint13_1Output,
Endpoint13_2Input,
Endpoint13_2Output,
Endpoint14_0Input,
Endpoint14_0Output,
Endpoint14_1Input,
@@ -216,6 +214,8 @@ import type {
Endpoint24_1Output,
Endpoint24_2Input,
Endpoint24_2Output,
Endpoint24_3Input,
Endpoint24_3Output,
Endpoint25_0Input,
Endpoint25_0Output,
Endpoint25_1Input,
@@ -876,19 +876,7 @@ const Endpoint13_1 = (raw: RawClient["server.project"]) => (input?: Endpoint13_1
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint13_2 = (raw: RawClient["server.project"]) => (input: Endpoint13_2Input) =>
preserveEffect<Endpoint13_2Output>()(
raw["project.directories"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup13 = (raw: RawClient["server.project"]) => ({
list: Endpoint13_0(raw),
current: Endpoint13_1(raw),
directories: Endpoint13_2(raw),
})
const adaptGroup13 = (raw: RawClient["server.project"]) => ({ list: Endpoint13_0(raw), current: Endpoint13_1(raw) })
const Endpoint14_0 = (raw: RawClient["server.form"]) => (input?: Endpoint14_0Input) =>
preserveEffect<Endpoint14_0Output>()(
@@ -1212,36 +1200,37 @@ const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
const Endpoint24_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_0Input) =>
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
raw["projectCopy.create"]({
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["projectCopy.remove"]({
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["worktree.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
preserveEffect<Endpoint24_3Output>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint24_0(raw),
remove: Endpoint24_1(raw),
refresh: Endpoint24_2(raw),
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint24_0(raw),
create: Endpoint24_1(raw),
remove: Endpoint24_2(raw),
refresh: Endpoint24_3(raw),
})
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
@@ -1334,7 +1323,7 @@ const adaptClient = (raw: RawClient) => ({
shell: adaptGroup21(raw["server.shell"]),
question: adaptGroup22(raw["server.question"]),
reference: adaptGroup23(raw["server.reference"]),
projectCopy: adaptGroup24(raw["server.projectCopy"]),
worktree: adaptGroup24(raw["server.worktree"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
migration: adaptGroup27(raw["server.migration"]),
+1 -1
View File
@@ -33,7 +33,7 @@ export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
+29 -33
View File
@@ -133,8 +133,6 @@ import type {
ProjectListOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectDirectoriesInput,
ProjectDirectoriesOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
@@ -206,12 +204,14 @@ import type {
QuestionRejectOutput,
ReferenceListInput,
ReferenceListOutput,
ProjectCopyCreateInput,
ProjectCopyCreateOutput,
ProjectCopyRemoveInput,
ProjectCopyRemoveOutput,
ProjectCopyRefreshInput,
ProjectCopyRefreshOutput,
WorktreeListInput,
WorktreeListOutput,
WorktreeCreateInput,
WorktreeCreateOutput,
WorktreeRemoveInput,
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
@@ -1255,18 +1255,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
directories: (input: ProjectDirectoriesInput, requestOptions?: RequestOptions) =>
request<ProjectDirectoriesOutput>(
{
method: "GET",
path: `/api/project/${encodeURIComponent(input.projectID)}/directories`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
form: {
request: {
@@ -1736,13 +1724,23 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
projectCopy: {
create: (input: ProjectCopyCreateInput, requestOptions?: RequestOptions) =>
request<ProjectCopyCreateOutput>(
worktree: {
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
request<WorktreeListOutput>(
{
method: "GET",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
request<WorktreeCreateOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
successStatus: 200,
declaredStatuses: [400, 401],
@@ -1750,12 +1748,11 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
remove: (input: ProjectCopyRemoveInput, requestOptions?: RequestOptions) =>
request<ProjectCopyRemoveOutput>(
remove: (input: WorktreeRemoveInput, requestOptions?: RequestOptions) =>
request<WorktreeRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -1763,12 +1760,11 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
refresh: (input: ProjectCopyRefreshInput, requestOptions?: RequestOptions) =>
request<ProjectCopyRefreshOutput>(
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
request<WorktreeRefreshOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input["location"] },
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+25 -41
View File
@@ -291,8 +291,6 @@ export type ProjectTime = { created: number; updated: number; initialized?: numb
export type ProjectCurrent = { id: string; directory: string; canonical: string }
export type ProjectDirectory = { directory: string; strategy?: string }
export type FormMetadata = { [x: string]: JsonValue }
export type FormValue = string | number | boolean | Array<string>
@@ -370,7 +368,9 @@ export type ReferenceGitSource = {
hidden?: boolean
}
export type ProjectCopyCopy = { directory: string }
export type WorktreeDirectory = { directory: string; strategy?: string }
export type WorktreeInfo = { directory: string }
export type VcsBranch = { current?: string; default?: string }
@@ -828,20 +828,20 @@ export type PluginUpdated = {
data: {}
}
export type ProjectDirectoriesUpdated = {
export type WorktreeUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.directories.updated"
type: "worktree.updated"
location?: LocationRef
data: { projectID: string }
}
export type ProjectDirectoryResolved = {
export type WorktreeResolved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.directory.resolved"
type: "worktree.resolved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { projectID: string; directory: string; previous: string }
@@ -1369,8 +1369,6 @@ export type Project = {
sandboxes: Array<string>
}
export type ProjectDirectories = Array<ProjectDirectory>
export type FormAnswer = { [x: string]: FormValue }
export type PermissionRequest = {
@@ -1520,6 +1518,8 @@ export type SessionStatus2 = {
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type WorktreeList = Array<WorktreeDirectory>
export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule>
@@ -2102,8 +2102,8 @@ export type V2Event =
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectDirectoriesUpdated
| ProjectDirectoryResolved
| WorktreeUpdated
| WorktreeResolved
| CommandUpdated
| ConfigUpdated
| SkillUpdated
@@ -2304,12 +2304,12 @@ export type QuestionNotFoundError = {
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
export type ProjectCopyError = {
readonly name: "ProjectCopyError"
export type WorktreeError = {
readonly name: "WorktreeError"
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
}
export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
export type HealthGetOutput = ServiceHealth
@@ -4348,15 +4348,6 @@ export type ProjectCurrentInput = {
export type ProjectCurrentOutput = ProjectCurrent
export type ProjectDirectoriesInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectDirectoriesOutput = ProjectDirectories
export type FormRequestListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -5642,37 +5633,30 @@ export type ReferenceListOutput = {
data: Array<ReferenceInfo>
}
export type ProjectCopyCreateInput = {
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeListOutput = WorktreeList
export type WorktreeCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
}
export type ProjectCopyCreateOutput = ProjectCopyCopy
export type WorktreeCreateOutput = WorktreeInfo
export type ProjectCopyRemoveInput = {
export type WorktreeRemoveInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"]
}
export type ProjectCopyRemoveOutput = void
export type WorktreeRemoveOutput = void
export type ProjectCopyRefreshInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type ProjectCopyRefreshOutput = void
export type WorktreeRefreshOutput = void
export type VcsGetInput = {
readonly location?: {
+33 -18
View File
@@ -29,7 +29,7 @@ test("exposes every standard HTTP API group", () => {
"shell",
"question",
"reference",
"projectCopy",
"worktree",
"vcs",
"debug",
"migration",
@@ -49,7 +49,8 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
expect(Object.keys(client.project)).toEqual(["list", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
test("config.get returns ordered config entries for a location", async () => {
@@ -251,30 +252,44 @@ test("file.read returns binary content from the public HTTP contract", async ()
)
})
test("project methods use the public HTTP contract", async () => {
const requests: string[] = []
test("worktree methods use the global project contract", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
requests.push(url)
if (url.includes("/directories")) return Response.json([])
return Response.json({ id: "proj_test", directory: "/tmp/project" })
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
if (request.method === "GET") return Response.json([{ directory: "/tmp/project" }])
if (request.method === "POST" && !request.url.endsWith("/refresh"))
return Response.json({ directory: "/tmp/worktrees/api" })
return new Response(null, { status: 204 })
},
})
const current = await client.project.current({ location: { workspace: "wrk_test" } })
const directories = await client.project.directories({
projectID: current.id,
location: { directory: current.directory },
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
expect(
await client.worktree.create({
projectID: "proj_test",
strategy: "git",
directory: "/tmp/worktrees",
name: "api",
}),
).toEqual({ directory: "/tmp/worktrees/api" })
await client.worktree.remove({
projectID: "proj_test",
directory: "/tmp/worktrees/api",
force: false,
})
await client.worktree.refresh({ projectID: "proj_test" })
expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
expect(directories).toEqual([])
expect(requests).toEqual([
"http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
"http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
])
expect(await requests[1]?.json()).toEqual({ strategy: "git", directory: "/tmp/worktrees", name: "api" })
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
})
test("shell list and remove use the public HTTP contract", async () => {
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -29,7 +29,7 @@ import { WebSearchGroup } from "./groups/websearch.js"
import { McpGroup } from "./groups/mcp.js"
import { CredentialGroup } from "./groups/credential.js"
import { ProjectGroup } from "./groups/project.js"
import { ProjectCopyGroup } from "./groups/project-copy.js"
import { WorktreeGroup } from "./groups/worktree.js"
import { VcsGroup } from "./groups/vcs.js"
import { MigrationGroup } from "./groups/migration.js"
import { ConfigGroup } from "./groups/config.js"
@@ -52,7 +52,6 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ProjectCopyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
@@ -89,6 +88,7 @@ type ApiGroups<
| typeof ServerGroup
| typeof DebugGroup
| typeof MigrationGroup
| typeof WorktreeGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
@@ -172,7 +172,7 @@ const makeApiFromGroup = <
.add(ShellGroup.middleware(locationMiddleware))
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(ProjectCopyGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
.add(VcsGroup.middleware(locationMiddleware))
.add(DebugGroup)
.add(MigrationGroup)
+1 -1
View File
@@ -60,7 +60,7 @@ export const groupNames = {
"server.question": "question",
"server.reference": "reference",
"server.project": "project",
"server.projectCopy": "projectCopy",
"server.worktree": "worktree",
"server.vcs": "vcs",
"server.config": "config",
} as const
@@ -1,56 +0,0 @@
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { Project } from "@opencode-ai/schema/project"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const root = "/api/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: Project.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: Project.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: Project.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." }))
+1 -16
View File
@@ -31,24 +31,9 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
}),
),
)
.add(
HttpApiEndpoint.get("project.directories", `${root}/:projectID/directories`, {
params: { projectID: Project.ID },
query: LocationQuery,
success: Project.Directories,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.project.directories",
summary: "List project directories",
description: "List known local absolute directories for a project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "project",
description: "Location-scoped project routes.",
description: "Project routes.",
}),
)
+76
View File
@@ -0,0 +1,76 @@
import { Project } from "@opencode-ai/schema/project"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
const root = "/api/experimental/project/:projectID/worktree"
export class WorktreeError extends Schema.ErrorClass<WorktreeError>("WorktreeError")(
{
name: Schema.Literal("WorktreeError"),
data: Schema.Struct({
message: Schema.String,
forceRequired: Schema.optional(Schema.Boolean),
}),
},
{ httpApiStatus: 400 },
) {}
const CreatePayload = Schema.Struct(Struct.omit(Worktree.CreateInput.fields, ["projectID"]))
const RemovePayload = Schema.Struct(Struct.omit(Worktree.RemoveInput.fields, ["projectID"]))
export const WorktreeGroup = HttpApiGroup.make("server.worktree")
.add(
HttpApiEndpoint.get("worktree.list", root, {
params: { projectID: Project.ID },
success: Worktree.List,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.list",
summary: "List worktrees",
description: "List known local worktrees for a project.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.create", root, {
params: { projectID: Project.ID },
payload: CreatePayload,
success: Worktree.Info,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.create",
summary: "Create worktree",
description: "Create a worktree for a project.",
}),
),
)
.add(
HttpApiEndpoint.delete("worktree.remove", root, {
params: { projectID: Project.ID },
payload: RemovePayload,
success: HttpApiSchema.NoContent,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.remove",
summary: "Remove worktree",
description: "Remove a managed worktree from a project.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.refresh", `${root}/refresh`, {
params: { projectID: Project.ID },
success: HttpApiSchema.NoContent,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.refresh",
summary: "Refresh worktrees",
description: "Reconcile stored worktrees with the project repositories.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Project worktree management routes." }))
+2 -2
View File
@@ -25,7 +25,7 @@ import { WebSearchHandler } from "./handlers/websearch"
import { McpHandler } from "./handlers/mcp"
import { CredentialHandler } from "./handlers/credential"
import { ProjectHandler } from "./handlers/project"
import { ProjectCopyHandler } from "./handlers/project-copy"
import { WorktreeHandler } from "./handlers/worktree"
import { VcsHandler } from "./handlers/vcs"
import { EventFeed } from "./event-feed"
import { MigrationHandler } from "./handlers/migration"
@@ -59,7 +59,7 @@ export const handlers = Layer.mergeAll(
ShellHandler,
QuestionHandler,
ReferenceHandler,
ProjectCopyHandler,
WorktreeHandler,
VcsHandler,
ConfigHandler,
)
@@ -1,68 +0,0 @@
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 "@opencode-ai/protocol/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
}
-3
View File
@@ -15,8 +15,5 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl
canonical: location.project.canonical,
}),
),
)
.handle("project.directories", (ctx) =>
Project.Service.use((project) => project.directories({ projectID: ctx.params.projectID })),
),
)
+53
View File
@@ -0,0 +1,53 @@
import { Git } from "@opencode-ai/core/git"
import { Worktree } from "@opencode-ai/core/worktree"
import { WorktreeError } from "@opencode-ai/protocol/groups/worktree"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
export const WorktreeHandler = HttpApiBuilder.group(Api, "server.worktree", (handlers) =>
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
return handlers
.handle("worktree.list", (ctx) => worktrees.list(ctx.params.projectID))
.handle("worktree.create", (ctx) =>
badRequest(worktrees.create({ ...ctx.payload, projectID: ctx.params.projectID })),
)
.handle("worktree.remove", (ctx) =>
badRequest(worktrees.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
)
.handle("worktree.refresh", (ctx) =>
badRequest(worktrees.refresh({ projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
)
}),
)
function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
return effect.pipe(
Effect.mapError(
(error) =>
new WorktreeError({
name: "WorktreeError",
data: {
message: message(error),
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
},
}),
),
)
}
function message(error: Worktree.Error) {
if (error instanceof Worktree.SourceDirectoryNotFoundError)
return `Worktree source not found for project: ${error.projectID}`
if (error instanceof Worktree.DestinationExistsError) return `Worktree destination already exists: ${error.directory}`
if (error instanceof Worktree.DirectoryUnavailableError) return `Worktree directory unavailable: ${error.directory}`
if (error instanceof Worktree.InvalidDirectoryError) return `Invalid worktree directory: ${error.directory}`
if (error instanceof Worktree.StrategyUnavailableError) return `Worktree strategy unavailable: ${error.strategy}`
return error.message
}
+2
View File
@@ -28,6 +28,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Workspace } from "@opencode-ai/core/workspace"
import { Worktree } from "@opencode-ai/core/worktree"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -52,6 +53,7 @@ const applicationServiceNodes = [
httpClient,
Job.node,
Project.node,
Worktree.node,
Session.node,
SessionTransfer.node,
PluginRuntime.providerNode,
+71
View File
@@ -0,0 +1,71 @@
import fs from "node:fs/promises"
import path from "node:path"
import { $ } from "bun"
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("lists, creates, and removes worktrees by project ID", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-worktree-endpoint-")),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: path.join(tmp.path, "config") },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const headers = { authorization: `Basic ${btoa("opencode:secret")}` }
const location = new URL("/api/location", base)
location.searchParams.set("location[directory]", project)
const resolved = yield* Effect.promise(() => fetch(location, { headers }).then((response) => response.json()))
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/experimental/project/${resolved.project.id}/worktree`, base)
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(initial).toEqual([{ directory: project }])
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const listed = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
)
expect(removed.status).toBe(204)
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}