feat(httpapi): bridge worktree mutations (#24371)
This commit is contained in:
@@ -164,7 +164,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
|
|||||||
| `mcp` | `bridged` partial | status only |
|
| `mcp` | `bridged` partial | status only |
|
||||||
| `workspace` | `bridged` | list, get, enter |
|
| `workspace` | `bridged` | list, get, enter |
|
||||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
| 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 |
|
| `session` | `later/special` | large stateful surface plus streaming |
|
||||||
| `sync` | `later` | process/control side effects |
|
| `sync` | `later` | process/control side effects |
|
||||||
| `event` | `special` | SSE |
|
| `event` | `special` | SSE |
|
||||||
|
|||||||
@@ -230,14 +230,14 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
description: "Worktree created",
|
description: "Worktree created",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Worktree.Info),
|
schema: resolver(Worktree.Info.zod),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...errors(400),
|
...errors(400),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator("json", Worktree.CreateInput.optional()),
|
validator("json", Worktree.CreateInput.zod.optional()),
|
||||||
async (c) =>
|
async (c) =>
|
||||||
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
|
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
@@ -286,7 +286,7 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
...errors(400),
|
...errors(400),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator("json", Worktree.RemoveInput),
|
validator("json", Worktree.RemoveInput.zod),
|
||||||
async (c) =>
|
async (c) =>
|
||||||
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
|
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
@@ -315,7 +315,7 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
...errors(400),
|
...errors(400),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator("json", Worktree.ResetInput),
|
validator("json", Worktree.ResetInput.zod),
|
||||||
async (c) =>
|
async (c) =>
|
||||||
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
|
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { InstanceState } from "@/effect"
|
|||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { Project } from "@/project"
|
import { Project } from "@/project"
|
||||||
import { ToolRegistry } from "@/tool"
|
import { ToolRegistry } from "@/tool"
|
||||||
|
import { Worktree } from "@/worktree"
|
||||||
import { Effect, Layer, Option, Schema } from "effect"
|
import { Effect, Layer, Option, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
import { Authorization } from "./auth"
|
||||||
@@ -36,6 +37,7 @@ export const ExperimentalPaths = {
|
|||||||
consoleOrgs: "/experimental/console/orgs",
|
consoleOrgs: "/experimental/console/orgs",
|
||||||
toolIDs: "/experimental/tool/ids",
|
toolIDs: "/experimental/tool/ids",
|
||||||
worktree: "/experimental/worktree",
|
worktree: "/experimental/worktree",
|
||||||
|
worktreeReset: "/experimental/worktree/reset",
|
||||||
resource: "/experimental/resource",
|
resource: "/experimental/resource",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -80,6 +82,36 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
description: "List all sandbox worktrees for the current project.",
|
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, {
|
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
||||||
success: Schema.Record(Schema.String, MCP.Resource),
|
success: Schema.Record(Schema.String, MCP.Resource),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -113,6 +145,7 @@ export const experimentalHandlers = Layer.unwrap(
|
|||||||
const mcp = yield* MCP.Service
|
const mcp = yield* MCP.Service
|
||||||
const project = yield* Project.Service
|
const project = yield* Project.Service
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const worktreeSvc = yield* Worktree.Service
|
||||||
|
|
||||||
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
||||||
const [state, groups] = yield* Effect.all(
|
const [state, groups] = yield* Effect.all(
|
||||||
@@ -159,6 +192,28 @@ export const experimentalHandlers = Layer.unwrap(
|
|||||||
return yield* project.sandboxes(ctx.project.id)
|
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* () {
|
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
|
||||||
return yield* mcp.resources()
|
return yield* mcp.resources()
|
||||||
})
|
})
|
||||||
@@ -169,6 +224,9 @@ export const experimentalHandlers = Layer.unwrap(
|
|||||||
.handle("consoleOrgs", listConsoleOrgs)
|
.handle("consoleOrgs", listConsoleOrgs)
|
||||||
.handle("toolIDs", toolIDs)
|
.handle("toolIDs", toolIDs)
|
||||||
.handle("worktree", worktree)
|
.handle("worktree", worktree)
|
||||||
|
.handle("worktreeCreate", worktreeCreate)
|
||||||
|
.handle("worktreeRemove", worktreeRemove)
|
||||||
|
.handle("worktreeReset", worktreeReset)
|
||||||
.handle("resource", resource),
|
.handle("resource", resource),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -178,4 +236,5 @@ export const experimentalHandlers = Layer.unwrap(
|
|||||||
Layer.provide(MCP.defaultLayer),
|
Layer.provide(MCP.defaultLayer),
|
||||||
Layer.provide(Project.defaultLayer),
|
Layer.provide(Project.defaultLayer),
|
||||||
Layer.provide(ToolRegistry.defaultLayer),
|
Layer.provide(ToolRegistry.defaultLayer),
|
||||||
|
Layer.provide(Worktree.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
|||||||
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
|
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
|
||||||
app.get(ExperimentalPaths.toolIDs, (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.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(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
|
||||||
app.get("/provider", (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))
|
app.get("/provider/auth", (c) => handler(c.req.raw, context))
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|||||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { InstanceState } from "@/effect"
|
import { InstanceState } from "@/effect"
|
||||||
|
import { zod as effectZod } from "@/util/effect-zod"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const log = Log.create({ service: "worktree" })
|
const log = Log.create({ service: "worktree" })
|
||||||
|
|
||||||
@@ -39,48 +41,38 @@ export const Event = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Info = z
|
export const Info = Schema.Struct({
|
||||||
.object({
|
name: Schema.String,
|
||||||
name: z.string(),
|
branch: Schema.String,
|
||||||
branch: z.string(),
|
directory: Schema.String,
|
||||||
directory: z.string(),
|
})
|
||||||
})
|
.annotate({ identifier: "Worktree" })
|
||||||
.meta({
|
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||||
ref: "Worktree",
|
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
|
export const RemoveInput = Schema.Struct({
|
||||||
.object({
|
directory: Schema.String,
|
||||||
name: z.string().optional(),
|
})
|
||||||
startCommand: z.string().optional().describe("Additional startup script to run after the project's start command"),
|
.annotate({ identifier: "WorktreeRemoveInput" })
|
||||||
})
|
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||||
.meta({
|
export type RemoveInput = Schema.Schema.Type<typeof RemoveInput>
|
||||||
ref: "WorktreeCreateInput",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type CreateInput = z.infer<typeof CreateInput>
|
export const ResetInput = Schema.Struct({
|
||||||
|
directory: Schema.String,
|
||||||
export const RemoveInput = z
|
})
|
||||||
.object({
|
.annotate({ identifier: "WorktreeResetInput" })
|
||||||
directory: z.string(),
|
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||||
})
|
export type ResetInput = Schema.Schema.Type<typeof ResetInput>
|
||||||
.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 NotGitError = NamedError.create(
|
export const NotGitError = NamedError.create(
|
||||||
"WorktreeNotGitError",
|
"WorktreeNotGitError",
|
||||||
@@ -210,7 +202,7 @@ export const layer: Layer.Layer<
|
|||||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
|
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
|
||||||
if (branchCheck.code === 0) continue
|
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" })
|
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
|
import path from "path"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
||||||
import { Log } from "../../src/util"
|
import { Log } from "../../src/util"
|
||||||
|
import { Worktree } from "../../src/worktree"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
@@ -18,6 +21,24 @@ function app() {
|
|||||||
return InstanceRoutes(websocket)
|
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 () => {
|
afterEach(async () => {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
@@ -67,4 +88,48 @@ describe("experimental HttpApi", () => {
|
|||||||
expect(resources.status).toBe(200)
|
expect(resources.status).toBe(200)
|
||||||
expect(await resources.json()).toEqual({})
|
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([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user