Просмотр исходного кода

feat(httpapi): bridge project git init endpoint (#24394)

Kit Langton 4 месяцев назад
Родитель
Сommit
5904f599a9

+ 2 - 2
packages/opencode/specs/effect/http-api.md

@@ -110,7 +110,7 @@ Good near-term candidates:
 - top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter`
 - top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter`
 - simple mutations: `POST /instance/dispose`
 - simple mutations: `POST /instance/dispose`
 - experimental JSON reads: console, tool, worktree list, resource list
 - experimental JSON reads: console, tool, worktree list, resource list
-- deferred JSON mutations: `PATCH /config`, project git init, workspace/worktree create/remove/reset, file search, MCP auth flows
+- deferred JSON mutations: workspace/worktree create/remove/reset, file search, MCP auth flows
 
 
 Keep large or stateful groups for later:
 Keep large or stateful groups for later:
 
 
@@ -176,7 +176,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 | `permission`              | `bridged`         | list and reply                                                                                     |
 | `permission`              | `bridged`         | list and reply                                                                                     |
 | `provider`                | `bridged`         | list, auth, OAuth authorize/callback                                                               |
 | `provider`                | `bridged`         | list, auth, OAuth authorize/callback                                                               |
 | `config`                  | `bridged`         | read, providers, update                                                                            |
 | `config`                  | `bridged`         | read, providers, update                                                                            |
-| `project`                 | `bridged` partial | reads only; git-init remains Hono                                                                  |
+| `project`                 | `bridged`         | list, current, git init                                                                            |
 | `file`                    | `bridged` partial | find text/file/symbol, list/content/status                                                         |
 | `file`                    | `bridged` partial | find text/file/symbol, list/content/status                                                         |
 | `mcp`                     | `bridged` partial | status only                                                                                        |
 | `mcp`                     | `bridged` partial | status only                                                                                        |
 | `workspace`               | `bridged`         | list, get, enter                                                                                   |
 | `workspace`               | `bridged`         | list, get, enter                                                                                   |

+ 19 - 0
packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts

@@ -3,6 +3,10 @@ import { Effect } from "effect"
 import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
 import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
 
 
 const disposeAfterResponse = new WeakMap<object, InstanceContext>()
 const disposeAfterResponse = new WeakMap<object, InstanceContext>()
+const reloadAfterResponse = new WeakMap<
+  object,
+  InstanceContext & { next: Parameters<typeof Instance.reload>[0] }
+>()
 
 
 export const markInstanceForDisposal = (ctx: InstanceContext) =>
 export const markInstanceForDisposal = (ctx: InstanceContext) =>
   HttpEffect.appendPreResponseHandler((request, response) =>
   HttpEffect.appendPreResponseHandler((request, response) =>
@@ -12,10 +16,25 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
     }),
     }),
   )
   )
 
 
+export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
+  HttpEffect.appendPreResponseHandler((request, response) =>
+    Effect.sync(() => {
+      reloadAfterResponse.set(request.source, { ...ctx, next })
+      return response
+    }),
+  )
+
 export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
 export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
     const response = yield* effect
     const response = yield* effect
     const request = yield* HttpServerRequest.HttpServerRequest
     const request = yield* HttpServerRequest.HttpServerRequest
+    const reload = reloadAfterResponse.get(request.source)
+    if (reload) {
+      reloadAfterResponse.delete(request.source)
+      yield* Effect.promise(() => Instance.restore(reload, () => Instance.reload(reload.next)))
+      return response
+    }
+
     const ctx = disposeAfterResponse.get(request.source)
     const ctx = disposeAfterResponse.get(request.source)
     if (!ctx) return response
     if (!ctx) return response
     disposeAfterResponse.delete(request.source)
     disposeAfterResponse.delete(request.source)

+ 26 - 1
packages/opencode/src/server/routes/instance/httpapi/project.ts

@@ -1,8 +1,11 @@
 import * as InstanceState from "@/effect/instance-state"
 import * as InstanceState from "@/effect/instance-state"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Project } from "@/project"
 import { Project } from "@/project"
+import { InstanceBootstrap } from "@/project/bootstrap"
 import { Effect, Layer, Schema } from "effect"
 import { Effect, Layer, 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"
+import { markInstanceForReload } from "./lifecycle"
 
 
 const root = "/project"
 const root = "/project"
 
 
@@ -28,6 +31,15 @@ export const ProjectApi = HttpApi.make("project")
             description: "Retrieve the currently active project that OpenCode is working with.",
             description: "Retrieve the currently active project that OpenCode is working with.",
           }),
           }),
         ),
         ),
+        HttpApiEndpoint.post("initGit", `${root}/git/init`, {
+          success: Project.Info,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "project.initGit",
+            summary: "Initialize git repository",
+            description: "Create a git repository for the current project and return the refreshed project info.",
+          }),
+        ),
       )
       )
       .annotateMerge(
       .annotateMerge(
         OpenApi.annotations({
         OpenApi.annotations({
@@ -57,8 +69,21 @@ export const projectHandlers = Layer.unwrap(
       return (yield* InstanceState.context).project
       return (yield* InstanceState.context).project
     })
     })
 
 
+    const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () {
+      const ctx = yield* InstanceState.context
+      const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project })
+      if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree) return next
+      yield* markInstanceForReload(ctx, {
+        directory: ctx.directory,
+        worktree: ctx.directory,
+        project: next,
+        init: () => AppRuntime.runPromise(InstanceBootstrap),
+      })
+      return next
+    })
+
     return HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
     return HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
-      handlers.handle("list", list).handle("current", current),
+      handlers.handle("list", list).handle("current", current).handle("initGit", initGit),
     )
     )
   }),
   }),
 ).pipe(Layer.provide(Project.defaultLayer))
 ).pipe(Layer.provide(Project.defaultLayer))

+ 1 - 0
packages/opencode/src/server/routes/instance/index.ts

@@ -61,6 +61,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
     app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
     app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
     app.get("/project", (c) => handler(c.req.raw, context))
     app.get("/project", (c) => handler(c.req.raw, context))
     app.get("/project/current", (c) => handler(c.req.raw, context))
     app.get("/project/current", (c) => handler(c.req.raw, context))
+    app.post("/project/git/init", (c) => handler(c.req.raw, context))
     app.get(FilePaths.findText, (c) => handler(c.req.raw, context))
     app.get(FilePaths.findText, (c) => handler(c.req.raw, context))
     app.get(FilePaths.findFile, (c) => handler(c.req.raw, context))
     app.get(FilePaths.findFile, (c) => handler(c.req.raw, context))
     app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context))
     app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context))

+ 10 - 8
packages/opencode/test/server/httpapi-experimental.test.ts

@@ -107,14 +107,16 @@ describe("experimental HttpApi", () => {
     expect(listed.status).toBe(200)
     expect(listed.status).toBe(200)
     expect(await listed.json()).toContain(info.directory)
     expect(await listed.json()).toContain(info.directory)
 
 
-    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)
+    if (process.platform !== "win32") {
+      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)
+    }
 
 
     const removed = await app().request(ExperimentalPaths.worktree, {
     const removed = await app().request(ExperimentalPaths.worktree, {
       method: "DELETE",
       method: "DELETE",

+ 36 - 0
packages/opencode/test/server/httpapi-instance.test.ts

@@ -20,6 +20,24 @@ function app() {
   return InstanceRoutes(websocket)
   return InstanceRoutes(websocket)
 }
 }
 
 
+async function waitDisposed(directory: string) {
+  return await new Promise<void>((resolve, reject) => {
+    const timer = setTimeout(() => {
+      GlobalBus.off("event", onEvent)
+      reject(new Error("timed out waiting for instance disposal"))
+    }, 10_000)
+
+    function onEvent(event: { directory?: string; payload: { type?: string } }) {
+      if (event.payload.type !== "server.instance.disposed" || 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()
@@ -79,6 +97,24 @@ describe("instance HttpApi", () => {
     expect(await formatter.json()).toEqual([])
     expect(await formatter.json()).toEqual([])
   })
   })
 
 
+  test("serves project git init through Hono bridge", async () => {
+    await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
+    const disposed = waitDisposed(tmp.path)
+
+    const response = await app().request("/project/git/init", {
+      method: "POST",
+      headers: { "x-opencode-directory": tmp.path },
+    })
+
+    expect(response.status).toBe(200)
+    expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
+    await disposed
+
+    const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
+    expect(current.status).toBe(200)
+    expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
+  })
+
   test("serves instance dispose through Hono bridge", async () => {
   test("serves instance dispose through Hono bridge", async () => {
     await using tmp = await tmpdir()
     await using tmp = await tmpdir()