Explorar el Código

feat(httpapi): bridge instance read endpoints (#24258)

Kit Langton hace 1 día
padre
commit
d5bfaef53d

+ 7 - 9
packages/opencode/specs/effect/http-api.md

@@ -139,8 +139,8 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 | `project`                | `bridged` partial | reads only; git-init remains Hono                              |
 | `file`                   | `bridged` partial | list/content/status only                                       |
 | `mcp`                    | `bridged` partial | status only                                                    |
-| `workspace`              | `implemented`     | `HttpApi` group exists, but bridge mounting needs verification |
-| top-level instance reads | `next`            | path, vcs, command, agent, skill, lsp, formatter               |
+| `workspace`              | `bridged`         | list, get, enter                                                |
+| top-level instance reads | `bridged` partial | path and vcs reads; command, agent, skill, lsp, formatter next  |
 | experimental JSON routes | `next/later`      | console, tool, worktree, resource, global session list         |
 | `session`                | `later/special`   | large stateful surface plus streaming                          |
 | `sync`                   | `later`           | process/control side effects                                   |
@@ -150,11 +150,9 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 
 ## Next PRs
 
-1. Add bridge-level auth and instance-context tests for the current `HttpApi` bridge.
-2. Produce a generated route inventory from Hono registrations and update `Current Route Status` with exact paths.
-3. Fix the `workspace` status: mount it if it should be reachable, or remove it from the composed `HttpApi` layer.
-4. Port the top-level JSON reads.
-5. Start the Effect OpenAPI/SDK generation path for already-bridged routes.
+1. Produce a generated route inventory from Hono registrations and update `Current Route Status` with exact paths.
+2. Continue porting top-level JSON reads.
+3. Start the Effect OpenAPI/SDK generation path for already-bridged routes.
 
 ## Checklist
 
@@ -164,9 +162,9 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 - [x] Provide auth, instance lookup, and observability in the Effect route layer.
 - [x] Attach auth middleware in route modules.
 - [x] Support `auth_token` as a query security scheme.
-- [ ] Add bridge-level auth and instance tests.
+- [x] Add bridge-level auth and instance tests.
 - [ ] Complete exact Hono route inventory.
-- [ ] Resolve implemented-but-unmounted route groups.
+- [x] Resolve implemented-but-unmounted route groups.
 - [ ] Port remaining JSON routes.
 - [ ] Generate SDK/OpenAPI from Effect routes.
 - [ ] Flip ported JSON routes to default-on with fallback.

+ 22 - 25
packages/opencode/src/project/vcs.ts

@@ -8,7 +8,8 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { FileWatcher } from "@/file/watcher"
 import { Git } from "@/git"
 import { Log } from "@/util"
-import z from "zod"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
 const log = Log.create({ service: "vcs" })
 
@@ -101,8 +102,8 @@ const compare = Effect.fnUntraced(function* (
   )
 })
 
-export const Mode = z.enum(["git", "branch"])
-export type Mode = z.infer<typeof Mode>
+export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Mode = Schema.Schema.Type<typeof Mode>
 
 export const Event = {
   BranchUpdated: BusEvent.define(
@@ -113,28 +114,24 @@ export const Event = {
   ),
 }
 
-export const Info = z
-  .object({
-    branch: z.string().optional(),
-    default_branch: z.string().optional(),
-  })
-  .meta({
-    ref: "VcsInfo",
-  })
-export type Info = z.infer<typeof Info>
-
-export const FileDiff = z
-  .object({
-    file: z.string(),
-    patch: z.string(),
-    additions: z.number(),
-    deletions: z.number(),
-    status: z.enum(["added", "deleted", "modified"]).optional(),
-  })
-  .meta({
-    ref: "VcsFileDiff",
-  })
-export type FileDiff = z.infer<typeof FileDiff>
+export const Info = Schema.Struct({
+  branch: Schema.optional(Schema.String),
+  default_branch: Schema.optional(Schema.String),
+})
+  .annotate({ identifier: "VcsInfo" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Schema.Schema.Type<typeof Info>
+
+export const FileDiff = Schema.Struct({
+  file: Schema.String,
+  patch: Schema.String,
+  additions: Schema.Number,
+  deletions: Schema.Number,
+  status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
+})
+  .annotate({ identifier: "VcsFileDiff" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type FileDiff = Schema.Schema.Type<typeof FileDiff>
 
 export interface Interface {
   readonly init: () => Effect.Effect<void>

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

@@ -0,0 +1,103 @@
+import { Global } from "@/global"
+import { Vcs } from "@/project"
+import * as InstanceState from "@/effect/instance-state"
+import { Effect, Layer, Schema } from "effect"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { Authorization } from "./auth"
+
+const PathInfo = Schema.Struct({
+  home: Schema.String,
+  state: Schema.String,
+  config: Schema.String,
+  worktree: Schema.String,
+  directory: Schema.String,
+}).annotate({ identifier: "Path" })
+
+const VcsDiffQuery = Schema.Struct({
+  mode: Vcs.Mode,
+})
+
+export const InstancePaths = {
+  path: "/path",
+  vcs: "/vcs",
+  vcsDiff: "/vcs/diff",
+} as const
+
+export const InstanceApi = HttpApi.make("instance")
+  .add(
+    HttpApiGroup.make("instance")
+      .add(
+        HttpApiEndpoint.get("path", InstancePaths.path, {
+          success: PathInfo,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "path.get",
+            summary: "Get paths",
+            description: "Retrieve the current working directory and related path information for the OpenCode instance.",
+          }),
+        ),
+        HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
+          success: Vcs.Info,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "vcs.get",
+            summary: "Get VCS info",
+            description: "Retrieve version control system (VCS) information for the current project, such as git branch.",
+          }),
+        ),
+        HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
+          query: VcsDiffQuery,
+          success: Schema.Array(Vcs.FileDiff),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "vcs.diff",
+            summary: "Get VCS diff",
+            description: "Retrieve the current git diff for the working tree or against the default branch.",
+          }),
+        ),
+      )
+      .annotateMerge(
+        OpenApi.annotations({
+          title: "instance",
+          description: "Experimental HttpApi instance read routes.",
+        }),
+      )
+      .middleware(Authorization),
+  )
+  .annotateMerge(
+    OpenApi.annotations({
+      title: "opencode experimental HttpApi",
+      version: "0.0.1",
+      description: "Experimental HttpApi surface for selected instance routes.",
+    }),
+  )
+
+export const instanceHandlers = Layer.unwrap(
+  Effect.gen(function* () {
+    const vcs = yield* Vcs.Service
+
+    const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
+      const ctx = yield* InstanceState.context
+      return {
+        home: Global.Path.home,
+        state: Global.Path.state,
+        config: Global.Path.config,
+        worktree: ctx.worktree,
+        directory: ctx.directory,
+      }
+    })
+
+    const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
+      const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
+      return { branch, default_branch }
+    })
+
+    const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
+      return yield* vcs.diff(ctx.query.mode)
+    })
+
+    return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
+      handlers.handle("path", getPath).handle("vcs", getVcs).handle("vcsDiff", getVcsDiff),
+    )
+  }),
+).pipe(Layer.provide(Vcs.defaultLayer))

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

@@ -11,6 +11,7 @@ import { Filesystem } from "@/util"
 import { authorizationLayer } from "./auth"
 import { ConfigApi, configHandlers } from "./config"
 import { FileApi, fileHandlers } from "./file"
+import { InstanceApi, instanceHandlers } from "./instance"
 import { McpApi, mcpHandlers } from "./mcp"
 import { PermissionApi, permissionHandlers } from "./permission"
 import { ProjectApi, projectHandlers } from "./project"
@@ -63,6 +64,7 @@ const instance = HttpRouter.middleware()(
 export const routes = Layer.mergeAll(
   HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
   HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
+  HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)),
   HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
   HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
   HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),

+ 7 - 3
packages/opencode/src/server/routes/instance/index.ts

@@ -17,6 +17,7 @@ import { PermissionRoutes } from "./permission"
 import { Flag } from "@/flag/flag"
 import { ExperimentalHttpApiServer } from "./httpapi/server"
 import { FilePaths } from "./httpapi/file"
+import { InstancePaths } from "./httpapi/instance"
 import { McpPaths } from "./httpapi/mcp"
 import { ProjectRoutes } from "./project"
 import { SessionRoutes } from "./session"
@@ -53,6 +54,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
     app.get(FilePaths.list, (c) => handler(c.req.raw, context))
     app.get(FilePaths.content, (c) => handler(c.req.raw, context))
     app.get(FilePaths.status, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
     app.get(McpPaths.status, (c) => handler(c.req.raw, context))
   }
 
@@ -142,7 +146,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
             description: "VCS info",
             content: {
               "application/json": {
-                schema: resolver(Vcs.Info),
+                schema: resolver(Vcs.Info.zod),
               },
             },
           },
@@ -168,7 +172,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
             description: "VCS diff",
             content: {
               "application/json": {
-                schema: resolver(Vcs.FileDiff.array()),
+                schema: resolver(Vcs.FileDiff.zod.array()),
               },
             },
           },
@@ -177,7 +181,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
       validator(
         "query",
         z.object({
-          mode: Vcs.Mode,
+          mode: Vcs.Mode.zod,
         }),
       ),
       async (c) =>

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

@@ -0,0 +1,53 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import type { UpgradeWebSocket } from "hono/ws"
+import path from "path"
+import { Flag } from "../../src/flag/flag"
+import { Instance } from "../../src/project/instance"
+import { InstanceRoutes } from "../../src/server/routes/instance"
+import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
+import { Log } from "../../src/util"
+import { resetDatabase } from "../fixture/db"
+import { tmpdir } from "../fixture/fixture"
+
+void Log.init({ print: false })
+
+const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
+const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
+
+function app() {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+  return InstanceRoutes(websocket)
+}
+
+afterEach(async () => {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
+  await Instance.disposeAll()
+  await resetDatabase()
+})
+
+describe("instance HttpApi", () => {
+  test("serves path and VCS read endpoints through Hono bridge", async () => {
+    await using tmp = await tmpdir({ git: true })
+    await Bun.write(path.join(tmp.path, "changed.txt"), "hello")
+
+    const vcsDiff = new URL(`http://localhost${InstancePaths.vcsDiff}`)
+    vcsDiff.searchParams.set("mode", "git")
+
+    const [paths, vcs, diff] = await Promise.all([
+      app().request(InstancePaths.path, { headers: { "x-opencode-directory": tmp.path } }),
+      app().request(InstancePaths.vcs, { headers: { "x-opencode-directory": tmp.path } }),
+      app().request(vcsDiff, { headers: { "x-opencode-directory": tmp.path } }),
+    ])
+
+    expect(paths.status).toBe(200)
+    expect(await paths.json()).toMatchObject({ directory: tmp.path, worktree: tmp.path })
+
+    expect(vcs.status).toBe(200)
+    expect(await vcs.json()).toMatchObject({ branch: expect.any(String) })
+
+    expect(diff.status).toBe(200)
+    expect(await diff.json()).toContainEqual(
+      expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
+    )
+  })
+})