httpapi-workspace.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import { afterEach, describe, expect, mock } from "bun:test"
  2. import { mkdir } from "node:fs/promises"
  3. import path from "node:path"
  4. import { Effect, Layer, Stream } from "effect"
  5. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  7. import { Flag } from "@kirincode-ai/core/flag/flag"
  8. import { registerAdapter } from "../../src/control-plane/adapters"
  9. import { WorkspaceV2 } from "@kirincode-ai/core/workspace"
  10. import type { WorkspaceAdapter } from "../../src/control-plane/types"
  11. import { Workspace } from "../../src/control-plane/workspace"
  12. import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
  13. import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
  14. import { Session } from "@/session/session"
  15. import { Database } from "@kirincode-ai/core/database/database"
  16. import { Ripgrep } from "@kirincode-ai/core/ripgrep"
  17. import { Server } from "../../src/server/server"
  18. import { resetDatabase } from "../fixture/db"
  19. import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
  20. import { InstanceBootstrap } from "../../src/project/bootstrap"
  21. import { InstanceStore } from "../../src/project/instance-store"
  22. import { Project } from "../../src/project/project"
  23. import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
  24. import { testEffect } from "../lib/effect"
  25. import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
  26. const originalWorkspaces = Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES
  27. const appLayer = AppNodeBuilder.build(
  28. LayerNode.group([Project.node, Session.node, Workspace.node, InstanceStore.node, Database.node, Ripgrep.node]),
  29. [[InstanceStore.bootstrapNode, InstanceBootstrap.node]],
  30. )
  31. const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer))
  32. function request(path: string, directory: string, init: RequestInit = {}) {
  33. return requestInDirectory(path, directory, init)
  34. }
  35. function requestDefault(path: string, directory: string, init: RequestInit = {}) {
  36. return requestInDirectory(path, directory, init)
  37. }
  38. function requestServer(path: string, directory: string, init: RequestInit = {}) {
  39. const headers = new Headers(init.headers)
  40. headers.set("x-opencode-directory", directory)
  41. return Effect.promise(() => Promise.resolve(Server.Default().app.request(path, { ...init, headers })))
  42. }
  43. function localAdapter(directory: string): WorkspaceAdapter {
  44. return {
  45. name: "Local Test",
  46. description: "Create a local test workspace",
  47. configure(info) {
  48. return {
  49. ...info,
  50. name: "local-test",
  51. directory,
  52. }
  53. },
  54. async create() {
  55. await mkdir(directory, { recursive: true })
  56. },
  57. async remove() {},
  58. target() {
  59. return {
  60. type: "local" as const,
  61. directory,
  62. }
  63. },
  64. }
  65. }
  66. function listedAdapter(directory: string, type: string): WorkspaceAdapter {
  67. return {
  68. name: "Listed Test",
  69. description: "List a local test workspace",
  70. configure(info) {
  71. return { ...info, name: "unused", directory }
  72. },
  73. async create() {},
  74. async remove() {},
  75. list(context) {
  76. return [
  77. {
  78. type,
  79. name: "listed-test",
  80. branch: "listed/main",
  81. directory,
  82. extra: { listed: true },
  83. projectID: context?.instance?.project.id ?? missingAdapterContext(),
  84. },
  85. ]
  86. },
  87. target() {
  88. return {
  89. type: "local" as const,
  90. directory,
  91. }
  92. },
  93. }
  94. }
  95. function missingAdapterContext(): never {
  96. throw new Error("missing workspace adapter context")
  97. }
  98. function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
  99. return {
  100. name: "Remote Test",
  101. description: "Create a remote test workspace",
  102. configure(info) {
  103. return {
  104. ...info,
  105. name: "remote-test",
  106. directory,
  107. }
  108. },
  109. async create() {
  110. await mkdir(directory, { recursive: true })
  111. },
  112. async remove() {},
  113. target() {
  114. return {
  115. type: "remote" as const,
  116. url,
  117. headers,
  118. }
  119. },
  120. }
  121. }
  122. type ProxiedRequest = {
  123. url: string
  124. method: string
  125. headers: Record<string, string>
  126. body: string
  127. }
  128. function listenRemoteHttp(handler: (request: ProxiedRequest) => Response | Promise<Response>) {
  129. return Bun.serve({
  130. port: 0,
  131. async fetch(request) {
  132. return handler({
  133. url: request.url,
  134. method: request.method,
  135. headers: Object.fromEntries(request.headers.entries()),
  136. body: await request.text(),
  137. })
  138. },
  139. })
  140. }
  141. function eventStreamResponse() {
  142. return new Response(
  143. new ReadableStream({
  144. start(controller) {
  145. controller.enqueue(
  146. new TextEncoder().encode('data: {"payload":{"type":"server.connected","properties":{}}}\n\n'),
  147. )
  148. },
  149. }),
  150. {
  151. status: 200,
  152. headers: {
  153. "content-type": "text/event-stream",
  154. },
  155. },
  156. )
  157. }
  158. afterEach(async () => {
  159. mock.restore()
  160. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
  161. await disposeAllInstances()
  162. await resetDatabase()
  163. })
  164. describe("workspace HttpApi", () => {
  165. it.live("serves read endpoints", () =>
  166. Effect.gen(function* () {
  167. const dir = yield* tmpdirScoped({ git: true })
  168. const [adapters, workspaces, status] = yield* Effect.all([
  169. request(WorkspacePaths.adapters, dir),
  170. request(WorkspacePaths.list, dir),
  171. request(WorkspacePaths.status, dir),
  172. ])
  173. expect(adapters.status).toBe(200)
  174. expect(yield* adapters.json).toContainEqual({
  175. type: "worktree",
  176. name: "Worktree",
  177. description: "Create a git worktree",
  178. })
  179. expect(workspaces.status).toBe(200)
  180. expect(yield* workspaces.json).toEqual([])
  181. expect(status.status).toBe(200)
  182. expect(yield* status.json).toEqual([])
  183. }),
  184. )
  185. it.live("serves mutation endpoints", () =>
  186. Effect.gen(function* () {
  187. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
  188. const dir = yield* tmpdirScoped({ git: true })
  189. const project = yield* Project.use.fromDirectory(dir)
  190. registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
  191. const created = yield* request(WorkspacePaths.list, dir, {
  192. method: "POST",
  193. headers: { "content-type": "application/json" },
  194. body: JSON.stringify({ type: "local-test", branch: null }),
  195. })
  196. expect(created.status).toBe(200)
  197. const workspace = (yield* created.json) as Workspace.Info
  198. expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
  199. const session = yield* Session.use.create({}).pipe(provideInstance(dir))
  200. const warped = yield* request(WorkspacePaths.warp, dir, {
  201. method: "POST",
  202. headers: { "content-type": "application/json" },
  203. body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
  204. })
  205. expect(warped.status).toBe(204)
  206. const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
  207. expect(removed.status).toBe(200)
  208. expect(yield* removed.json).toMatchObject({ id: workspace.id })
  209. const listed = yield* request(WorkspacePaths.list, dir)
  210. expect(listed.status).toBe(200)
  211. expect(yield* listed.json).toEqual([])
  212. }),
  213. )
  214. it.live("serves list sync endpoint", () =>
  215. Effect.gen(function* () {
  216. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
  217. const dir = yield* tmpdirScoped({ git: true })
  218. const project = yield* Project.use.fromDirectory(dir)
  219. const type = `listed-${Math.random().toString(36).slice(2)}`
  220. registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type))
  221. const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" })
  222. expect(response.status).toBe(204)
  223. const listed = yield* request(WorkspacePaths.list, dir)
  224. expect(yield* listed.json).toMatchObject([
  225. {
  226. type,
  227. name: "listed-test",
  228. branch: "listed/main",
  229. directory: path.join(dir, ".listed"),
  230. extra: { listed: true },
  231. },
  232. ])
  233. }),
  234. )
  235. it.live("returns a declared not found error when warping into a missing workspace", () =>
  236. Effect.gen(function* () {
  237. const dir = yield* tmpdirScoped({ git: true })
  238. const session = yield* Session.use.create({}).pipe(provideInstance(dir))
  239. const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_warp")
  240. const response = yield* request(WorkspacePaths.warp, dir, {
  241. method: "POST",
  242. headers: { "content-type": "application/json" },
  243. body: JSON.stringify({ id: workspaceID, sessionID: session.id }),
  244. })
  245. expect(response.status).toBe(404)
  246. expect(yield* response.json).toEqual({
  247. name: "NotFoundError",
  248. data: { message: `Workspace not found: ${workspaceID}` },
  249. })
  250. }),
  251. )
  252. it.live("creates workspace with the TUI payload shape", () =>
  253. Effect.gen(function* () {
  254. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
  255. const dir = yield* tmpdirScoped({ git: true })
  256. const project = yield* Project.use.fromDirectory(dir)
  257. registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
  258. const created = yield* request(WorkspacePaths.list, dir, {
  259. method: "POST",
  260. headers: { "content-type": "application/json" },
  261. body: JSON.stringify({ type: "local-test", branch: null }),
  262. })
  263. expect(created.status).toBe(200)
  264. expect((yield* created.json) as Workspace.Info).toMatchObject({
  265. type: "local-test",
  266. name: "local-test",
  267. })
  268. }),
  269. )
  270. it.live("creates a real git worktree workspace via the builtin adapter", () =>
  271. Effect.gen(function* () {
  272. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
  273. const dir = yield* tmpdirScoped({ git: true })
  274. const created = yield* requestServer(WorkspacePaths.list, dir, {
  275. method: "POST",
  276. headers: { "content-type": "application/json" },
  277. body: JSON.stringify({ type: "worktree", branch: null }),
  278. })
  279. const body = yield* Effect.promise(() => created.text())
  280. expect({ status: created.status, body }).toMatchObject({ status: 200 })
  281. const workspace = JSON.parse(body) as Workspace.Info
  282. expect(workspace).toMatchObject({ type: "worktree" })
  283. }),
  284. )
  285. it.live("routes local workspace requests through the workspace target directory", () =>
  286. Effect.gen(function* () {
  287. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
  288. const dir = yield* tmpdirScoped({ git: true })
  289. const workspaceDir = path.join(dir, ".workspace-local")
  290. const project = yield* Project.use.fromDirectory(dir)
  291. registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir))
  292. const created = yield* request(WorkspacePaths.list, dir, {
  293. method: "POST",
  294. headers: { "content-type": "application/json" },
  295. body: JSON.stringify({ type: "local-target", branch: null }),
  296. })
  297. const workspace = (yield* created.json) as Workspace.Info
  298. const url = new URL(`http://localhost${InstancePaths.path}`)
  299. url.searchParams.set("workspace", workspace.id)
  300. const response = yield* request(url.toString(), dir)
  301. expect(response.status).toBe(200)
  302. expect(yield* response.json).toMatchObject({ directory: workspaceDir })
  303. yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
  304. }),
  305. )
  306. it.live("proxies remote workspace HTTP requests with sanitized forwarding", () =>
  307. Effect.gen(function* () {
  308. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
  309. const dir = yield* tmpdirScoped({ git: true })
  310. const proxied: ProxiedRequest[] = []
  311. const remote = listenRemoteHttp((request) => {
  312. proxied.push(request)
  313. const url = new URL(request.url)
  314. if (url.pathname === "/base/global/event") return eventStreamResponse()
  315. if (url.pathname === "/base/event") return eventStreamResponse()
  316. if (url.pathname === "/base/sync/history") return Response.json([])
  317. return new Response(
  318. JSON.stringify({
  319. proxied: true,
  320. path: url.pathname,
  321. keep: url.searchParams.get("keep"),
  322. workspace: url.searchParams.get("workspace"),
  323. }),
  324. {
  325. status: 201,
  326. statusText: "Created",
  327. headers: {
  328. "content-length": "999",
  329. "content-type": "application/json",
  330. "x-remote": "yes",
  331. },
  332. },
  333. )
  334. })
  335. const project = yield* Project.use.fromDirectory(dir)
  336. registerAdapter(
  337. project.project.id,
  338. "remote-target",
  339. remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
  340. "x-target-auth": "secret",
  341. }),
  342. )
  343. const created = yield* requestDefault(WorkspacePaths.list, dir, {
  344. method: "POST",
  345. headers: { "content-type": "application/json" },
  346. body: JSON.stringify({ type: "remote-target", branch: null }),
  347. })
  348. const workspace = (yield* created.json) as Workspace.Info
  349. const url = new URL("http://localhost/config")
  350. url.searchParams.set("workspace", workspace.id)
  351. url.searchParams.set("keep", "yes")
  352. try {
  353. const response = yield* requestDefault(url.toString(), dir, {
  354. method: "PATCH",
  355. headers: {
  356. "accept-encoding": "br",
  357. "content-type": "application/json",
  358. "x-opencode-workspace": "internal",
  359. },
  360. body: JSON.stringify({ $schema: "https://kirincode.ai/config.json" }),
  361. })
  362. const responseBody = yield* response.text
  363. expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
  364. expect(response.headers["content-length"]).toBeUndefined()
  365. expect(response.headers["x-remote"]).toBe("yes")
  366. expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null })
  367. const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config")
  368. expect(forwarded).toEqual([
  369. {
  370. url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`,
  371. method: "PATCH",
  372. headers: expect.objectContaining({
  373. "content-type": "application/json",
  374. "x-target-auth": "secret",
  375. }),
  376. body: JSON.stringify({ $schema: "https://kirincode.ai/config.json" }),
  377. },
  378. ])
  379. expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
  380. expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
  381. const eventURL = new URL(`http://localhost${EventPaths.event}`)
  382. eventURL.searchParams.set("workspace", workspace.id)
  383. const eventResponse = yield* request(eventURL.toString(), dir)
  384. expect(eventResponse.status).toBe(200)
  385. expect(eventResponse.headers["content-type"]).toContain("text/event-stream")
  386. const event = Array.from(yield* eventResponse.stream.pipe(Stream.take(1), Stream.runCollect))[0]
  387. expect(new TextDecoder().decode(event)).toContain("server.connected")
  388. expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
  389. } finally {
  390. void remote.stop(true)
  391. yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
  392. }
  393. }),
  394. )
  395. it.live("proxies remote workspace requests selected from session ownership", () =>
  396. Effect.gen(function* () {
  397. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
  398. const dir = yield* tmpdirScoped({ git: true })
  399. const proxied: ProxiedRequest[] = []
  400. const remote = listenRemoteHttp((request) => {
  401. proxied.push(request)
  402. const url = new URL(request.url)
  403. if (url.pathname === "/base/global/event") return eventStreamResponse()
  404. if (url.pathname === "/base/sync/history") return Response.json([])
  405. return Response.json({ proxied: true, path: new URL(request.url).pathname })
  406. })
  407. const project = yield* Project.use.fromDirectory(dir)
  408. registerAdapter(
  409. project.project.id,
  410. "remote-session-target",
  411. remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
  412. )
  413. const created = yield* requestDefault(WorkspacePaths.list, dir, {
  414. method: "POST",
  415. headers: { "content-type": "application/json" },
  416. body: JSON.stringify({ type: "remote-session-target", branch: null }),
  417. })
  418. const workspace = (yield* created.json) as Workspace.Info
  419. const sessionResponse = yield* requestDefault("/session", dir, { method: "POST" })
  420. const session = (yield* sessionResponse.json) as Session.Info
  421. const warped = yield* requestDefault(WorkspacePaths.warp, dir, {
  422. method: "POST",
  423. headers: { "content-type": "application/json" },
  424. body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
  425. })
  426. expect(warped.status).toBe(204)
  427. try {
  428. const response = yield* requestDefault(`http://localhost/session/${session.id}/message`, dir, {
  429. method: "POST",
  430. headers: { "content-type": "application/json" },
  431. body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }),
  432. })
  433. const responseBody = yield* response.text
  434. expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 })
  435. expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` })
  436. expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([
  437. expect.objectContaining({
  438. url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`,
  439. method: "POST",
  440. }),
  441. ])
  442. const aborted = yield* request(`http://localhost/session/${session.id}/abort`, dir, { method: "POST" })
  443. expect(aborted.status).toBe(200)
  444. expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/abort`)).toEqual([
  445. expect.objectContaining({
  446. url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/abort`,
  447. method: "POST",
  448. body: "",
  449. }),
  450. ])
  451. } finally {
  452. void remote.stop(true)
  453. yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
  454. }
  455. }),
  456. )
  457. })