Răsfoiți Sursa

test: cover HttpApi workspace routing middleware (#25027)

Kit Langton 3 luni în urmă
părinte
comite
9052e8a1ba

+ 38 - 0
packages/opencode/src/effect/service-use.ts

@@ -0,0 +1,38 @@
+import { Context, Effect } from "effect"
+
+type EffectMethod = (...args: ReadonlyArray<never>) => Effect.Effect<unknown, unknown, unknown>
+
+type ServiceUse<Identifier, Shape> = {
+  readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends (
+    ...args: infer Args
+  ) => infer Return
+    ? Args extends ReadonlyArray<unknown>
+      ? Return extends Effect.Effect<infer A, infer E, infer R>
+        ? (...args: Args) => Effect.Effect<A, E, R | Identifier>
+        : never
+      : never
+    : never
+}
+
+export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
+  // This is the only dynamic boundary: TypeScript knows the accessor shape,
+  // but Proxy property names are runtime values.
+  const access = new Proxy(
+    {},
+    {
+      get: (_, key) => {
+        if (typeof key !== "string") return undefined
+        return (...args: unknown[]) =>
+          tag.use((service) => {
+            // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
+            const method = service[key as keyof Shape]
+            if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`))
+            // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
+            return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
+          })
+      },
+    },
+  )
+  // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily.
+  return access as ServiceUse<Identifier, Shape>
+}

+ 4 - 1
packages/opencode/src/project/project.ts

@@ -17,6 +17,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { zod } from "@/util/effect-zod"
 import { NonNegativeInt, withStatics } from "@/util/schema"
+import { serviceUse } from "@/effect/service-use"
 
 const log = Log.create({ service: "project" })
 
@@ -178,7 +179,7 @@ export const layer: Layer.Layer<
     const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
       return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
         Effect.map((x) => x.trim()),
-        Effect.map(ProjectID.make),
+        Effect.map((x) => ProjectID.make(x)),
         Effect.catch(() => Effect.void),
       )
     })
@@ -485,6 +486,8 @@ export const defaultLayer = layer.pipe(
   Layer.provide(NodePath.layer),
 )
 
+export const use = serviceUse(Service)
+
 export function list() {
   return Database.use((db) =>
     db

+ 437 - 0
packages/opencode/test/server/httpapi-workspace-routing.test.ts

@@ -0,0 +1,437 @@
+import { NodeHttpServer, NodeServices } from "@effect/platform-node"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { describe, expect } from "bun:test"
+import { Context, Effect, Layer, Queue } from "effect"
+import {
+  HttpClient,
+  HttpClientRequest,
+  HttpRouter,
+  HttpServer,
+  HttpServerRequest,
+  HttpServerResponse,
+} from "effect/unstable/http"
+import * as Socket from "effect/unstable/socket/Socket"
+import Http from "node:http"
+import { mkdir } from "node:fs/promises"
+import path from "node:path"
+import { registerAdaptor } from "../../src/control-plane/adaptors"
+import { WorkspaceID } from "../../src/control-plane/schema"
+import type { WorkspaceAdaptor } from "../../src/control-plane/types"
+import { Workspace } from "../../src/control-plane/workspace"
+import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
+import { Project } from "../../src/project/project"
+import {
+  WorkspaceRouteContext,
+  workspaceRouterMiddleware,
+} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
+import { Database } from "../../src/storage/db"
+import { resetDatabase } from "../fixture/db"
+import { tmpdirScoped } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+const testStateLayer = Layer.effectDiscard(
+  Effect.gen(function* () {
+    const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
+    yield* Effect.promise(() => resetDatabase())
+    Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
+    yield* Effect.addFinalizer(() =>
+      Effect.promise(async () => {
+        Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
+        await resetDatabase()
+      }),
+    )
+  }),
+)
+
+const it = testEffect(
+  Layer.mergeAll(
+    testStateLayer,
+    NodeHttpServer.layerTest,
+    NodeServices.layer,
+    Project.defaultLayer,
+    Socket.layerWebSocketConstructorGlobal,
+  ),
+)
+
+type ProxiedRequest = {
+  url: string
+  method: string
+  headers: Record<string, string>
+}
+
+type TestHandler<E, R> = (
+  request: HttpServerRequest.HttpServerRequest,
+) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
+
+const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
+  Layer.provide(Socket.layerWebSocketConstructorGlobal),
+)
+
+const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
+
+const requestURL = (request: { readonly url: string }) => new URL(request.url, "http://localhost")
+
+const listenAdditionalServer = <E, R>(handler: TestHandler<E, R>) =>
+  Effect.gen(function* () {
+    const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }))
+    const server = Context.get(context, HttpServer.HttpServer)
+    yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler))
+    return HttpServer.formatAddress(server.address)
+  })
+
+const localAdaptor = (directory: string): WorkspaceAdaptor => ({
+  name: "Local Test",
+  description: "Create a local test workspace",
+  configure: (info) => ({ ...info, name: "local-test", directory }),
+  create: async () => {
+    await mkdir(directory, { recursive: true })
+  },
+  async remove() {},
+  target: () => ({ type: "local" as const, directory }),
+})
+
+const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({
+  name: "Remote Test",
+  description: "Create a remote test workspace",
+  configure: (info) => ({ ...info, name: "remote-test", directory }),
+  create: async () => {
+    await mkdir(directory, { recursive: true })
+  },
+  async remove() {},
+  target: () => ({ type: "remote" as const, url, headers }),
+})
+
+const eventStreamResponse = () =>
+  HttpServerResponse.text('data: {"payload":{"type":"server.connected","properties":{}}}\n\n', {
+    contentType: "text/event-stream",
+  })
+
+const syncResponse = (request: HttpServerRequest.HttpServerRequest) => {
+  const url = requestURL(request)
+  if (url.pathname === "/base/global/event") return Effect.succeed(eventStreamResponse())
+  if (url.pathname === "/base/sync/history") return HttpServerResponse.json([])
+  return undefined
+}
+
+const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) =>
+  Effect.acquireRelease(
+    Effect.promise(async () => {
+      registerAdaptor(input.projectID, input.type, input.adaptor)
+      return Workspace.create({
+        type: input.type,
+        branch: null,
+        extra: null,
+        projectID: input.projectID,
+      })
+    }),
+    (workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore),
+  )
+
+const createRemoteWorkspace = (input: {
+  dir: string
+  projectID: Project.Info["id"]
+  type: string
+  url: string
+  headers?: HeadersInit
+}) =>
+  // Workspace.create starts the remote sync loop. The test upstream exposes
+  // /global/event and /sync/history so middleware proxying sees the remote
+  // workspace as active, just like production would.
+  createWorkspace({
+    projectID: input.projectID,
+    type: input.type,
+    adaptor: remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url, input.headers),
+  })
+
+const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
+  createWorkspace({
+    projectID: input.projectID,
+    type: input.type,
+    adaptor: localAdaptor(input.directory),
+  })
+
+const insertRemoteWorkspaceWithoutSync = (input: {
+  dir: string
+  projectID: Project.Info["id"]
+  type: string
+  url: string
+}) =>
+  Effect.sync(() => {
+    const id = WorkspaceID.ascending()
+    registerAdaptor(input.projectID, input.type, remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url))
+    Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
+    return id
+  })
+
+const startRemoteWorkspaceHttpServer = <E, R>(
+  handler: (request: ProxiedRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
+) =>
+  listenAdditionalServer((request) =>
+    Effect.gen(function* () {
+      // Remote workspaces run a sync loop against their target server. These
+      // bootstrap routes make Workspace.isSyncing(...) true for proxy tests;
+      // everything else is the request being proxied by the middleware.
+      const sync = syncResponse(request)
+      if (sync) return yield* sync
+      return yield* handler({ url: request.url, method: request.method, headers: request.headers })
+    }),
+  )
+
+const listenRemoteWebSocket = () =>
+  listenAdditionalServer((request) => {
+    const sync = syncResponse(request)
+    if (sync) return sync
+    if (requestURL(request).pathname !== "/base/probe") return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
+    return echoWebSocket(request)
+  })
+
+const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
+  Effect.gen(function* () {
+    const socket = yield* Effect.orDie(request.upgrade)
+    const write = yield* socket.writer
+    yield* socket
+      .runRaw((message) => write(`echo:${String(message)}`), {
+        onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
+          Effect.catch(() => Effect.void),
+        ),
+      })
+      .pipe(Effect.catch(() => Effect.void))
+    return HttpServerResponse.empty()
+  })
+
+const serveRouteContextProbe = HttpRouter.add(
+  "GET",
+  "/probe",
+  Effect.gen(function* () {
+    // The fake route exposes the context installed by the middleware, so tests
+    // can assert routing decisions without pulling in the production API tree.
+    const route = yield* WorkspaceRouteContext
+    return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
+  }),
+).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
+
+describe("HttpApi workspace routing middleware", () => {
+  it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
+    Effect.gen(function* () {
+      const dir = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.use.fromDirectory(dir)
+      let forwarded: ProxiedRequest | undefined
+
+      // This starts a second HTTP server that stands in for the opencode server
+      // backing a remote workspace. The client below still calls the local test
+      // server; only the middleware should call this server.
+      const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => {
+        forwarded = request
+        const url = requestURL(request)
+        return HttpServerResponse.json(
+          {
+            proxied: true,
+            path: url.pathname,
+            keep: url.searchParams.get("keep"),
+            workspace: url.searchParams.get("workspace"),
+          },
+          { status: 201, headers: { "x-remote": "yes" } },
+        )
+      })
+      // The adaptor target tells the middleware where to proxy selected remote
+      // workspace requests. Appending /probe to this base should produce
+      // `${remoteUrl}/base/probe` on the fake remote server above.
+      const workspace = yield* createRemoteWorkspace({
+        dir,
+        projectID: project.project.id,
+        type: "remote-http-target",
+        url: `${remoteUrl}/base`,
+        headers: { "x-target-auth": "secret" },
+      })
+
+      // The local /probe handler should not run. Selecting a remote workspace
+      // should make the middleware call HttpApiProxy.http instead.
+      yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
+        Layer.provide(workspaceRoutingTestLayer),
+        HttpRouter.serve,
+        Layer.build,
+      )
+
+      const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
+        HttpClientRequest.setHeaders({
+          "content-type": "application/json",
+          "x-opencode-directory": "/secret/path",
+          "x-opencode-workspace": "internal",
+        }),
+        HttpClient.execute,
+      )
+
+      expect(response.status).toBe(201)
+      expect(response.headers["x-remote"]).toBe("yes")
+      expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null })
+      const forwardedURL = forwarded ? requestURL(forwarded) : undefined
+      // These assertions are the routing contract: append the original path to
+      // the remote base URL, preserve normal query params, and remove workspace.
+      expect(forwardedURL?.pathname).toBe("/base/probe")
+      expect(forwardedURL?.searchParams.get("keep")).toBe("yes")
+      expect(forwardedURL?.searchParams.get("workspace")).toBeNull()
+      expect(forwarded?.method).toBe("PATCH")
+      expect(forwarded?.headers["content-type"]).toBe("application/json")
+      expect(forwarded?.headers["x-target-auth"]).toBe("secret")
+      expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined()
+      expect(forwarded?.headers["x-opencode-workspace"]).toBeUndefined()
+    }),
+  )
+
+  it.live("returns 503 when a remote workspace is not actively syncing", () =>
+    Effect.gen(function* () {
+      const dir = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.use.fromDirectory(dir)
+      const workspaceID = yield* insertRemoteWorkspaceWithoutSync({
+        dir,
+        projectID: project.project.id,
+        type: "remote-not-syncing",
+        url: "http://127.0.0.1:1/base",
+      })
+
+      yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
+        Layer.provide(workspaceRoutingTestLayer),
+        HttpRouter.serve,
+        Layer.build,
+      )
+
+      const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
+
+      expect(response.status).toBe(503)
+      expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`)
+    }),
+  )
+
+  it.live("proxies remote workspace WebSocket requests through the selected workspace target", () =>
+    Effect.gen(function* () {
+      const dir = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.use.fromDirectory(dir)
+      const remoteUrl = yield* listenRemoteWebSocket()
+      const workspace = yield* createRemoteWorkspace({
+        dir,
+        projectID: project.project.id,
+        type: "remote-websocket-target",
+        url: `${remoteUrl}/base`,
+      })
+
+      // The client connects to the local test server. The middleware should
+      // detect the WebSocket upgrade and proxy it to the remote /base/probe.
+      yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
+        Layer.provide(workspaceRoutingTestLayer),
+        HttpRouter.serve,
+        Layer.build,
+      )
+
+      const socket = yield* Socket.makeWebSocket(
+        `${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
+        {
+          closeCodeIsError: () => false,
+          protocols: "chat",
+        },
+      )
+      const messages = yield* Queue.unbounded<string>()
+      yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped)
+      const write = yield* socket.writer
+
+      expect(yield* Queue.take(messages)).toBe("protocol:chat")
+      yield* write("hello")
+      expect(yield* Queue.take(messages)).toBe("echo:hello")
+    }),
+  )
+
+  it.live("returns a missing workspace response for unknown workspace ids", () =>
+    Effect.gen(function* () {
+      const workspaceID = WorkspaceID.ascending("wrk_missing")
+      // If the middleware resolves the workspace first, this handler is never
+      // reached and the response should be the middleware error response.
+      yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
+        Layer.provide(workspaceRoutingTestLayer),
+        HttpRouter.serve,
+        Layer.build,
+      )
+
+      const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
+
+      expect(response.status).toBe(500)
+      expect(yield* response.text).toBe(`Workspace not found: ${workspaceID}`)
+    }),
+  )
+
+  it.live("keeps control-plane routes local even when workspace is selected", () =>
+    Effect.gen(function* () {
+      const dir = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.use.fromDirectory(dir)
+
+      const workspaceDir = path.join(dir, ".workspace-local")
+      const workspace = yield* createLocalWorkspace({
+        projectID: project.project.id,
+        type: "control-plane-target",
+        directory: workspaceDir,
+      })
+
+      // GET /session is a control-plane route: it lists sessions for the main
+      // process and should not be redirected into the selected workspace target.
+      yield* HttpRouter.add(
+        "GET",
+        "/session",
+        Effect.gen(function* () {
+          const route = yield* WorkspaceRouteContext
+          return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
+        }),
+      ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
+
+      const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
+
+      expect(response.status).toBe(200)
+      expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id })
+    }),
+  )
+
+  it.live("uses directory query/header fallback when no workspace is selected", () =>
+    Effect.gen(function* () {
+      const dir = yield* tmpdirScoped()
+      const queryDir = path.join(dir, "query-target")
+      const headerDir = path.join(dir, "header-target")
+      yield* serveRouteContextProbe
+
+      // Without a selected workspace, the middleware falls back to request
+      // directory hints before using the process cwd.
+      const queryResponse = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(queryDir)}`)
+      const headerResponse = yield* HttpClientRequest.get("/probe").pipe(
+        HttpClientRequest.setHeader("x-opencode-directory", headerDir),
+        HttpClient.execute,
+      )
+
+      expect(queryResponse.status).toBe(200)
+      expect(yield* queryResponse.json).toEqual({ directory: queryDir })
+      expect(headerResponse.status).toBe(200)
+      expect(yield* headerResponse.json).toEqual({ directory: headerDir })
+    }),
+  )
+
+  it.live("routes local workspace requests through WorkspaceRouteContext", () =>
+    Effect.gen(function* () {
+      const dir = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.use.fromDirectory(dir)
+
+      const workspaceDir = path.join(dir, ".workspace-local")
+      const workspace = yield* createLocalWorkspace({
+        projectID: project.project.id,
+        type: "local-target",
+        directory: workspaceDir,
+      })
+
+      yield* serveRouteContextProbe
+
+      // /probe is not a control-plane route, so selecting a local workspace
+      // should swap the route context to the workspace target directory.
+      const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`)
+
+      expect(response.status).toBe(200)
+      expect(yield* response.json).toEqual({
+        directory: workspaceDir,
+        workspaceID: workspace.id,
+      })
+    }),
+  )
+})

+ 1 - 1
packages/opencode/test/server/workspace-proxy.test.ts

@@ -46,7 +46,7 @@ function echoWebSocket(request: HttpServerRequest.HttpServerRequest) {
     // The upstream announces the negotiated protocol, then echoes every
     // received frame. The assertions use those messages to prove proxy flow.
     yield* socket
-      .runRaw((message) => write(`echo:${message}`), {
+      .runRaw((message) => write(`echo:${String(message)}`), {
         onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
           Effect.catch(() => Effect.void),
         ),