httpapi-instance-context.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import { NodeHttpServer, NodeServices } from "@effect/platform-node"
  2. import { describe, expect } from "bun:test"
  3. import { Effect, Fiber, Layer, Schema } from "effect"
  4. import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
  5. import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
  6. import * as Socket from "effect/unstable/socket/Socket"
  7. import { mkdir } from "node:fs/promises"
  8. import path from "node:path"
  9. import { registerAdapter } from "../../src/control-plane/adapters"
  10. import { WorkspaceV2 } from "@kirincode-ai/core/workspace"
  11. import type { WorkspaceAdapter } from "../../src/control-plane/types"
  12. import { Workspace } from "../../src/control-plane/workspace"
  13. import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
  14. import { Project } from "../../src/project/project"
  15. import { Session } from "../../src/session/session"
  16. import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
  17. import {
  18. InstanceContextMiddleware,
  19. instanceContextLayer,
  20. } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
  21. import {
  22. WorkspaceRoutingMiddleware,
  23. WorkspaceRoutingQuery,
  24. workspaceRoutingLayer,
  25. } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
  26. import { resetDatabase } from "../fixture/db"
  27. import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
  28. import { withFixedWorkspaceID } from "../fixture/flag"
  29. import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
  30. import { waitGlobalBusEvent } from "./global-bus"
  31. import { testEffect } from "../lib/effect"
  32. const testStateLayer = Layer.effectDiscard(
  33. Effect.gen(function* () {
  34. yield* Effect.promise(() => resetDatabase())
  35. yield* Effect.addFinalizer(() =>
  36. Effect.promise(async () => {
  37. await disposeAllInstances()
  38. await resetDatabase()
  39. }),
  40. )
  41. }),
  42. )
  43. const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true })
  44. const it = testEffect(Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, workspaceLayer))
  45. const instanceContextTestLayer = Layer.mergeAll(
  46. instanceContextLayer,
  47. workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
  48. )
  49. const localAdapter = (directory: string): WorkspaceAdapter => ({
  50. name: "Local Test",
  51. description: "Create a local test workspace",
  52. configure: (info) => ({ ...info, name: "local-test", directory }),
  53. create: async () => {
  54. await mkdir(directory, { recursive: true })
  55. },
  56. async remove() {},
  57. target: () => ({ type: "local" as const, directory }),
  58. })
  59. const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
  60. Effect.acquireRelease(
  61. Effect.gen(function* () {
  62. registerAdapter(input.projectID, input.type, localAdapter(input.directory))
  63. const workspace = yield* Workspace.Service
  64. return yield* workspace.create({
  65. type: input.type,
  66. branch: null,
  67. extra: null,
  68. projectID: input.projectID,
  69. })
  70. }),
  71. (info) => Workspace.use.remove(info.id).pipe(Effect.ignore),
  72. )
  73. const probeInstanceContext = Effect.gen(function* () {
  74. const instance = yield* InstanceRef
  75. const workspaceID = yield* WorkspaceRef
  76. return {
  77. directory: instance?.directory,
  78. worktree: instance?.worktree,
  79. projectID: instance?.project.id,
  80. workspaceID,
  81. }
  82. })
  83. const ProbeResult = Schema.Struct({
  84. directory: Schema.optional(Schema.String),
  85. worktree: Schema.optional(Schema.String),
  86. projectID: Schema.optional(Schema.String),
  87. workspaceID: Schema.optional(Schema.String),
  88. })
  89. const ProbeApi = HttpApi.make("instance-context-probe").add(
  90. HttpApiGroup.make("probe")
  91. .add(
  92. HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
  93. HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
  94. HttpApiEndpoint.post("dispose", "/dispose-probe", {
  95. query: WorkspaceRoutingQuery,
  96. success: Schema.Boolean,
  97. }),
  98. )
  99. .middleware(InstanceContextMiddleware)
  100. .middleware(WorkspaceRoutingMiddleware),
  101. )
  102. const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
  103. handlers
  104. .handle("get", () => probeInstanceContext)
  105. .handle("session", () => probeInstanceContext)
  106. .handle(
  107. "dispose",
  108. Effect.fn("InstanceContextProbe.dispose")(function* () {
  109. const instance = yield* InstanceRef
  110. if (!instance) return false
  111. yield* markInstanceForDisposal(instance)
  112. return true
  113. }),
  114. ),
  115. )
  116. const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe(
  117. Layer.provide(probeHandlers),
  118. Layer.provide(instanceContextTestLayer),
  119. Layer.provide(Layer.mock(Session.Service)({})),
  120. )
  121. const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build)
  122. const waitDisposedEvent = waitGlobalBusEvent({
  123. message: "timed out waiting for instance disposal",
  124. predicate: (event) => event.payload.type === "server.instance.disposed",
  125. }).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
  126. const serveDisposeProbe = () =>
  127. HttpRouter.serve(probeRoutes, { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }).pipe(
  128. Layer.build,
  129. )
  130. describe("HttpApi instance context middleware", () => {
  131. it.live("provides instance context from the routed directory", () =>
  132. Effect.gen(function* () {
  133. const dir = yield* tmpdirScoped({ git: true })
  134. const project = yield* Project.use.fromDirectory(dir)
  135. yield* serveProbe()
  136. const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`)
  137. expect(response.status).toBe(200)
  138. expect(yield* response.json).toEqual({
  139. directory: dir,
  140. worktree: dir,
  141. projectID: project.project.id,
  142. workspaceID: null,
  143. })
  144. }),
  145. )
  146. it.live("falls back to the raw directory when URI decoding fails", () =>
  147. Effect.gen(function* () {
  148. yield* serveProbe()
  149. const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A")
  150. expect(response.status).toBe(200)
  151. expect(yield* response.json).toMatchObject({
  152. directory: path.join(process.cwd(), "%E0%A4%A"),
  153. })
  154. }),
  155. )
  156. it.live("provides selected workspace id on control-plane routes", () =>
  157. Effect.gen(function* () {
  158. const dir = yield* tmpdirScoped({ git: true })
  159. const project = yield* Project.use.fromDirectory(dir)
  160. const workspaceDir = path.join(dir, ".workspace-local")
  161. const workspace = yield* createLocalWorkspace({
  162. projectID: project.project.id,
  163. type: "instance-context-workspace-ref",
  164. directory: workspaceDir,
  165. })
  166. yield* serveProbe()
  167. const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
  168. HttpClientRequest.setHeader("x-opencode-directory", dir),
  169. HttpClient.execute,
  170. )
  171. expect(response.status).toBe(200)
  172. expect(yield* response.json).toMatchObject({
  173. directory: dir,
  174. workspaceID: workspace.id,
  175. })
  176. }),
  177. )
  178. it.live("uses workspace routing output instead of raw directory hints", () =>
  179. Effect.gen(function* () {
  180. const dir = yield* tmpdirScoped({ git: true })
  181. const project = yield* Project.use.fromDirectory(dir)
  182. const workspaceDir = path.join(dir, ".workspace-local")
  183. const workspace = yield* createLocalWorkspace({
  184. projectID: project.project.id,
  185. type: "instance-context-routing-output",
  186. directory: workspaceDir,
  187. })
  188. yield* serveProbe()
  189. const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe(
  190. HttpClientRequest.setHeader("x-opencode-directory", dir),
  191. HttpClient.execute,
  192. )
  193. expect(response.status).toBe(200)
  194. expect(yield* response.json).toMatchObject({
  195. directory: workspaceDir,
  196. workspaceID: workspace.id,
  197. })
  198. }),
  199. )
  200. it.live("uses configured workspace id instead of routing to the requested workspace", () =>
  201. Effect.gen(function* () {
  202. const fixedWorkspaceID = WorkspaceV2.ID.ascending()
  203. yield* withFixedWorkspaceID(fixedWorkspaceID)
  204. const dir = yield* tmpdirScoped({ git: true })
  205. const project = yield* Project.use.fromDirectory(dir)
  206. const workspaceDir = path.join(dir, ".workspace-local")
  207. const workspace = yield* createLocalWorkspace({
  208. projectID: project.project.id,
  209. type: "instance-context-fixed-workspace-ref",
  210. directory: workspaceDir,
  211. })
  212. yield* serveProbe()
  213. const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe(
  214. HttpClientRequest.setHeader("x-opencode-directory", dir),
  215. HttpClient.execute,
  216. )
  217. expect(response.status).toBe(200)
  218. expect(yield* response.json).toMatchObject({
  219. directory: dir,
  220. workspaceID: fixedWorkspaceID,
  221. })
  222. }),
  223. )
  224. it.live("falls through to local instead of MissingWorkspace when configured workspace id is set", () =>
  225. Effect.gen(function* () {
  226. const fixedWorkspaceID = WorkspaceV2.ID.ascending()
  227. yield* withFixedWorkspaceID(fixedWorkspaceID)
  228. const dir = yield* tmpdirScoped({ git: true })
  229. yield* Project.use.fromDirectory(dir)
  230. yield* serveProbe()
  231. // Reference a workspace id that is not registered locally. Without the
  232. // configured env override, this would short-circuit to a 500
  233. // MissingWorkspace response. With the env set, planRequest must skip the
  234. // MissingWorkspace branch and fall through to Local with the configured
  235. // workspace id.
  236. const unknownWorkspaceID = WorkspaceV2.ID.ascending()
  237. const response = yield* HttpClientRequest.get(`/probe?workspace=${unknownWorkspaceID}`).pipe(
  238. HttpClientRequest.setHeader("x-opencode-directory", dir),
  239. HttpClient.execute,
  240. )
  241. expect(response.status).toBe(200)
  242. expect(yield* response.json).toMatchObject({
  243. directory: dir,
  244. workspaceID: fixedWorkspaceID,
  245. })
  246. }),
  247. )
  248. it.live("keeps configured workspace id on control-plane routes without remote routing", () =>
  249. Effect.gen(function* () {
  250. const fixedWorkspaceID = WorkspaceV2.ID.ascending()
  251. yield* withFixedWorkspaceID(fixedWorkspaceID)
  252. const dir = yield* tmpdirScoped({ git: true })
  253. const project = yield* Project.use.fromDirectory(dir)
  254. const workspaceDir = path.join(dir, ".workspace-local")
  255. const workspace = yield* createLocalWorkspace({
  256. projectID: project.project.id,
  257. type: "instance-context-fixed-workspace-control-plane",
  258. directory: workspaceDir,
  259. })
  260. // /session is matched by isLocalWorkspaceRoute, so shouldStayOnControlPlane
  261. // is true. Combined with the env override, the route must stay Local with
  262. // the configured workspace id (not divert to the requested workspace's
  263. // local directory).
  264. yield* serveProbe()
  265. const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
  266. HttpClientRequest.setHeader("x-opencode-directory", dir),
  267. HttpClient.execute,
  268. )
  269. expect(response.status).toBe(200)
  270. expect(yield* response.json).toMatchObject({
  271. directory: dir,
  272. workspaceID: fixedWorkspaceID,
  273. })
  274. }),
  275. )
  276. it.live("preserves selected workspace id on instance disposal events", () =>
  277. Effect.gen(function* () {
  278. const dir = yield* tmpdirScoped({ git: true })
  279. const project = yield* Project.use.fromDirectory(dir)
  280. const workspaceDir = path.join(dir, ".workspace-local")
  281. const workspace = yield* createLocalWorkspace({
  282. projectID: project.project.id,
  283. type: "instance-context-dispose-event",
  284. directory: workspaceDir,
  285. })
  286. yield* serveDisposeProbe()
  287. const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped({ startImmediately: true }))
  288. const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe(
  289. HttpClient.execute,
  290. )
  291. expect(response.status).toBe(200)
  292. expect(yield* response.json).toBe(true)
  293. expect(yield* Fiber.join(disposed)).toEqual({ directory: workspaceDir, workspace: workspace.id })
  294. }),
  295. )
  296. })