feat(httpapi): bridge worktree mutations (#24371)

This commit is contained in:
Kit Langton
2026-04-25 15:35:15 -04:00
committed by GitHub
parent 474024f9e6
commit a369130226
6 changed files with 164 additions and 45 deletions

View File

@@ -164,7 +164,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
| `mcp` | `bridged` partial | status only |
| `workspace` | `bridged` | list, get, enter |
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
| experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list, resource list; global session list remains later |
| experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list/mutations, resource list; global session list remains later |
| `session` | `later/special` | large stateful surface plus streaming |
| `sync` | `later` | process/control side effects |
| `event` | `special` | SSE |

View File

@@ -230,14 +230,14 @@ export const ExperimentalRoutes = lazy(() =>
description: "Worktree created",
content: {
"application/json": {
schema: resolver(Worktree.Info),
schema: resolver(Worktree.Info.zod),
},
},
},
...errors(400),
},
}),
validator("json", Worktree.CreateInput.optional()),
validator("json", Worktree.CreateInput.zod.optional()),
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
const body = c.req.valid("json")
@@ -286,7 +286,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
validator("json", Worktree.RemoveInput),
validator("json", Worktree.RemoveInput.zod),
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
const body = c.req.valid("json")
@@ -315,7 +315,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
validator("json", Worktree.ResetInput),
validator("json", Worktree.ResetInput.zod),
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
const body = c.req.valid("json")

View File

@@ -4,6 +4,7 @@ import { InstanceState } from "@/effect"
import { MCP } from "@/mcp"
import { Project } from "@/project"
import { ToolRegistry } from "@/tool"
import { Worktree } from "@/worktree"
import { Effect, Layer, Option, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -36,6 +37,7 @@ export const ExperimentalPaths = {
consoleOrgs: "/experimental/console/orgs",
toolIDs: "/experimental/tool/ids",
worktree: "/experimental/worktree",
worktreeReset: "/experimental/worktree/reset",
resource: "/experimental/resource",
} as const
@@ -80,6 +82,36 @@ export const ExperimentalApi = HttpApi.make("experimental")
description: "List all sandbox worktrees for the current project.",
}),
),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
payload: Schema.optional(Worktree.CreateInput),
success: Worktree.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.create",
summary: "Create worktree",
description: "Create a new git worktree for the current project and run any configured startup scripts.",
}),
),
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
payload: Worktree.RemoveInput,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.remove",
summary: "Remove worktree",
description: "Remove a git worktree and delete its branch.",
}),
),
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
payload: Worktree.ResetInput,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.reset",
summary: "Reset worktree",
description: "Reset a worktree branch to the primary default branch.",
}),
),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
success: Schema.Record(Schema.String, MCP.Resource),
}).annotateMerge(
@@ -113,6 +145,7 @@ export const experimentalHandlers = Layer.unwrap(
const mcp = yield* MCP.Service
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
@@ -159,6 +192,28 @@ export const experimentalHandlers = Layer.unwrap(
return yield* project.sandboxes(ctx.project.id)
})
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
payload: Worktree.CreateInput | undefined
}) {
return yield* worktreeSvc.create(ctx.payload)
})
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
payload: Worktree.RemoveInput
}) {
const ctx = yield* InstanceState.context
yield* worktreeSvc.remove(input.payload)
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
return true
})
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
payload: Worktree.ResetInput
}) {
yield* worktreeSvc.reset(ctx.payload)
return true
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
@@ -169,6 +224,9 @@ export const experimentalHandlers = Layer.unwrap(
.handle("consoleOrgs", listConsoleOrgs)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("resource", resource),
)
}),
@@ -178,4 +236,5 @@ export const experimentalHandlers = Layer.unwrap(
Layer.provide(MCP.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Worktree.defaultLayer),
)

View File

@@ -50,6 +50,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
app.get("/provider", (c) => handler(c.req.raw, context))
app.get("/provider/auth", (c) => handler(c.req.raw, context))

View File

@@ -20,6 +20,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { InstanceState } from "@/effect"
import { zod as effectZod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const log = Log.create({ service: "worktree" })
@@ -39,48 +41,38 @@ export const Event = {
),
}
export const Info = z
.object({
name: z.string(),
branch: z.string(),
directory: z.string(),
})
.meta({
ref: "Worktree",
})
export const Info = Schema.Struct({
name: Schema.String,
branch: Schema.String,
directory: Schema.String,
})
.annotate({ identifier: "Worktree" })
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export type Info = z.infer<typeof Info>
export const CreateInput = Schema.Struct({
name: Schema.optional(Schema.String),
startCommand: Schema.optional(
Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }),
),
})
.annotate({ identifier: "WorktreeCreateInput" })
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const CreateInput = z
.object({
name: z.string().optional(),
startCommand: z.string().optional().describe("Additional startup script to run after the project's start command"),
})
.meta({
ref: "WorktreeCreateInput",
})
export const RemoveInput = Schema.Struct({
directory: Schema.String,
})
.annotate({ identifier: "WorktreeRemoveInput" })
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
export type RemoveInput = Schema.Schema.Type<typeof RemoveInput>
export type CreateInput = z.infer<typeof CreateInput>
export const RemoveInput = z
.object({
directory: z.string(),
})
.meta({
ref: "WorktreeRemoveInput",
})
export type RemoveInput = z.infer<typeof RemoveInput>
export const ResetInput = z
.object({
directory: z.string(),
})
.meta({
ref: "WorktreeResetInput",
})
export type ResetInput = z.infer<typeof ResetInput>
export const ResetInput = Schema.Struct({
directory: Schema.String,
})
.annotate({ identifier: "WorktreeResetInput" })
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
export type ResetInput = Schema.Schema.Type<typeof ResetInput>
export const NotGitError = NamedError.create(
"WorktreeNotGitError",
@@ -210,7 +202,7 @@ export const layer: Layer.Layer<
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
if (branchCheck.code === 0) continue
return Info.parse({ name, branch, directory })
return { name, branch, directory }
}
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
})

View File

@@ -1,10 +1,13 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
import { Log } from "../../src/util"
import { Worktree } from "../../src/worktree"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
@@ -18,6 +21,24 @@ function app() {
return InstanceRoutes(websocket)
}
async function waitReady(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for worktree.ready"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
})
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
@@ -67,4 +88,48 @@ describe("experimental HttpApi", () => {
expect(resources.status).toBe(200)
expect(await resources.json()).toEqual({})
})
test("serves worktree mutations through Hono bridge", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const created = await app().request(ExperimentalPaths.worktree, {
method: "POST",
headers,
body: JSON.stringify({ name: "api-test" }),
})
expect(created.status).toBe(200)
const info = (await created.json()) as Worktree.Info
expect(info).toMatchObject({ name: "api-test", branch: "opencode/api-test" })
await waitReady(info.directory)
const listed = await app().request(ExperimentalPaths.worktree, { headers })
expect(listed.status).toBe(200)
expect(await listed.json()).toContain(info.directory)
await Bun.write(path.join(info.directory, "dirty.txt"), "dirty")
const reset = await app().request(ExperimentalPaths.worktreeReset, {
method: "POST",
headers,
body: JSON.stringify({ directory: info.directory }),
})
expect(reset.status).toBe(200)
expect(await reset.json()).toBe(true)
expect(await Bun.file(path.join(info.directory, "dirty.txt")).exists()).toBe(false)
const removed = await app().request(ExperimentalPaths.worktree, {
method: "DELETE",
headers,
body: JSON.stringify({ directory: info.directory }),
})
expect(removed.status).toBe(200)
expect(await removed.json()).toBe(true)
const afterRemove = await app().request(ExperimentalPaths.worktree, { headers })
expect(afterRemove.status).toBe(200)
expect(await afterRemove.json()).toEqual([])
})
})