| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506 |
- import { afterEach, describe, expect, mock } from "bun:test"
- import { mkdir } from "node:fs/promises"
- import path from "node:path"
- import { Effect, Layer, Stream } from "effect"
- import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { Flag } from "@kirincode-ai/core/flag/flag"
- import { registerAdapter } from "../../src/control-plane/adapters"
- import { WorkspaceV2 } from "@kirincode-ai/core/workspace"
- import type { WorkspaceAdapter } from "../../src/control-plane/types"
- import { Workspace } from "../../src/control-plane/workspace"
- import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
- import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
- import { Session } from "@/session/session"
- import { Database } from "@kirincode-ai/core/database/database"
- import { Ripgrep } from "@kirincode-ai/core/ripgrep"
- import { Server } from "../../src/server/server"
- import { resetDatabase } from "../fixture/db"
- import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
- import { InstanceBootstrap } from "../../src/project/bootstrap"
- import { InstanceStore } from "../../src/project/instance-store"
- import { Project } from "../../src/project/project"
- import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
- import { testEffect } from "../lib/effect"
- import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
- const originalWorkspaces = Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES
- const appLayer = AppNodeBuilder.build(
- LayerNode.group([Project.node, Session.node, Workspace.node, InstanceStore.node, Database.node, Ripgrep.node]),
- [[InstanceStore.bootstrapNode, InstanceBootstrap.node]],
- )
- const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer))
- function request(path: string, directory: string, init: RequestInit = {}) {
- return requestInDirectory(path, directory, init)
- }
- function requestDefault(path: string, directory: string, init: RequestInit = {}) {
- return requestInDirectory(path, directory, init)
- }
- function requestServer(path: string, directory: string, init: RequestInit = {}) {
- const headers = new Headers(init.headers)
- headers.set("x-opencode-directory", directory)
- return Effect.promise(() => Promise.resolve(Server.Default().app.request(path, { ...init, headers })))
- }
- function localAdapter(directory: string): WorkspaceAdapter {
- return {
- name: "Local Test",
- description: "Create a local test workspace",
- configure(info) {
- return {
- ...info,
- name: "local-test",
- directory,
- }
- },
- async create() {
- await mkdir(directory, { recursive: true })
- },
- async remove() {},
- target() {
- return {
- type: "local" as const,
- directory,
- }
- },
- }
- }
- function listedAdapter(directory: string, type: string): WorkspaceAdapter {
- return {
- name: "Listed Test",
- description: "List a local test workspace",
- configure(info) {
- return { ...info, name: "unused", directory }
- },
- async create() {},
- async remove() {},
- list(context) {
- return [
- {
- type,
- name: "listed-test",
- branch: "listed/main",
- directory,
- extra: { listed: true },
- projectID: context?.instance?.project.id ?? missingAdapterContext(),
- },
- ]
- },
- target() {
- return {
- type: "local" as const,
- directory,
- }
- },
- }
- }
- function missingAdapterContext(): never {
- throw new Error("missing workspace adapter context")
- }
- function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
- return {
- name: "Remote Test",
- description: "Create a remote test workspace",
- configure(info) {
- return {
- ...info,
- name: "remote-test",
- directory,
- }
- },
- async create() {
- await mkdir(directory, { recursive: true })
- },
- async remove() {},
- target() {
- return {
- type: "remote" as const,
- url,
- headers,
- }
- },
- }
- }
- type ProxiedRequest = {
- url: string
- method: string
- headers: Record<string, string>
- body: string
- }
- function listenRemoteHttp(handler: (request: ProxiedRequest) => Response | Promise<Response>) {
- return Bun.serve({
- port: 0,
- async fetch(request) {
- return handler({
- url: request.url,
- method: request.method,
- headers: Object.fromEntries(request.headers.entries()),
- body: await request.text(),
- })
- },
- })
- }
- function eventStreamResponse() {
- return new Response(
- new ReadableStream({
- start(controller) {
- controller.enqueue(
- new TextEncoder().encode('data: {"payload":{"type":"server.connected","properties":{}}}\n\n'),
- )
- },
- }),
- {
- status: 200,
- headers: {
- "content-type": "text/event-stream",
- },
- },
- )
- }
- afterEach(async () => {
- mock.restore()
- Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
- await disposeAllInstances()
- await resetDatabase()
- })
- describe("workspace HttpApi", () => {
- it.live("serves read endpoints", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped({ git: true })
- const [adapters, workspaces, status] = yield* Effect.all([
- request(WorkspacePaths.adapters, dir),
- request(WorkspacePaths.list, dir),
- request(WorkspacePaths.status, dir),
- ])
- expect(adapters.status).toBe(200)
- expect(yield* adapters.json).toContainEqual({
- type: "worktree",
- name: "Worktree",
- description: "Create a git worktree",
- })
- expect(workspaces.status).toBe(200)
- expect(yield* workspaces.json).toEqual([])
- expect(status.status).toBe(200)
- expect(yield* status.json).toEqual([])
- }),
- )
- it.live("serves mutation endpoints", () =>
- Effect.gen(function* () {
- Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
- const dir = yield* tmpdirScoped({ git: true })
- const project = yield* Project.use.fromDirectory(dir)
- registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
- const created = yield* request(WorkspacePaths.list, dir, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ type: "local-test", branch: null }),
- })
- expect(created.status).toBe(200)
- const workspace = (yield* created.json) as Workspace.Info
- expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
- const session = yield* Session.use.create({}).pipe(provideInstance(dir))
- const warped = yield* request(WorkspacePaths.warp, dir, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
- })
- expect(warped.status).toBe(204)
- const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
- expect(removed.status).toBe(200)
- expect(yield* removed.json).toMatchObject({ id: workspace.id })
- const listed = yield* request(WorkspacePaths.list, dir)
- expect(listed.status).toBe(200)
- expect(yield* listed.json).toEqual([])
- }),
- )
- it.live("serves list sync endpoint", () =>
- Effect.gen(function* () {
- Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
- const dir = yield* tmpdirScoped({ git: true })
- const project = yield* Project.use.fromDirectory(dir)
- const type = `listed-${Math.random().toString(36).slice(2)}`
- registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type))
- const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" })
- expect(response.status).toBe(204)
- const listed = yield* request(WorkspacePaths.list, dir)
- expect(yield* listed.json).toMatchObject([
- {
- type,
- name: "listed-test",
- branch: "listed/main",
- directory: path.join(dir, ".listed"),
- extra: { listed: true },
- },
- ])
- }),
- )
- it.live("returns a declared not found error when warping into a missing workspace", () =>
- Effect.gen(function* () {
- const dir = yield* tmpdirScoped({ git: true })
- const session = yield* Session.use.create({}).pipe(provideInstance(dir))
- const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_warp")
- const response = yield* request(WorkspacePaths.warp, dir, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ id: workspaceID, sessionID: session.id }),
- })
- expect(response.status).toBe(404)
- expect(yield* response.json).toEqual({
- name: "NotFoundError",
- data: { message: `Workspace not found: ${workspaceID}` },
- })
- }),
- )
- it.live("creates workspace with the TUI payload shape", () =>
- Effect.gen(function* () {
- Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
- const dir = yield* tmpdirScoped({ git: true })
- const project = yield* Project.use.fromDirectory(dir)
- registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
- const created = yield* request(WorkspacePaths.list, dir, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ type: "local-test", branch: null }),
- })
- expect(created.status).toBe(200)
- expect((yield* created.json) as Workspace.Info).toMatchObject({
- type: "local-test",
- name: "local-test",
- })
- }),
- )
- it.live("creates a real git worktree workspace via the builtin adapter", () =>
- Effect.gen(function* () {
- Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
- const dir = yield* tmpdirScoped({ git: true })
- const created = yield* requestServer(WorkspacePaths.list, dir, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ type: "worktree", branch: null }),
- })
- const body = yield* Effect.promise(() => created.text())
- expect({ status: created.status, body }).toMatchObject({ status: 200 })
- const workspace = JSON.parse(body) as Workspace.Info
- expect(workspace).toMatchObject({ type: "worktree" })
- }),
- )
- it.live("routes local workspace requests through the workspace target directory", () =>
- Effect.gen(function* () {
- Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
- const dir = yield* tmpdirScoped({ git: true })
- const workspaceDir = path.join(dir, ".workspace-local")
- const project = yield* Project.use.fromDirectory(dir)
- registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir))
- const created = yield* request(WorkspacePaths.list, dir, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ type: "local-target", branch: null }),
- })
- const workspace = (yield* created.json) as Workspace.Info
- const url = new URL(`http://localhost${InstancePaths.path}`)
- url.searchParams.set("workspace", workspace.id)
- const response = yield* request(url.toString(), dir)
- expect(response.status).toBe(200)
- expect(yield* response.json).toMatchObject({ directory: workspaceDir })
- yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
- }),
- )
- it.live("proxies remote workspace HTTP requests with sanitized forwarding", () =>
- Effect.gen(function* () {
- Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
- const dir = yield* tmpdirScoped({ git: true })
- const proxied: ProxiedRequest[] = []
- const remote = listenRemoteHttp((request) => {
- proxied.push(request)
- const url = new URL(request.url)
- if (url.pathname === "/base/global/event") return eventStreamResponse()
- if (url.pathname === "/base/event") return eventStreamResponse()
- if (url.pathname === "/base/sync/history") return Response.json([])
- return new Response(
- JSON.stringify({
- proxied: true,
- path: url.pathname,
- keep: url.searchParams.get("keep"),
- workspace: url.searchParams.get("workspace"),
- }),
- {
- status: 201,
- statusText: "Created",
- headers: {
- "content-length": "999",
- "content-type": "application/json",
- "x-remote": "yes",
- },
- },
- )
- })
- const project = yield* Project.use.fromDirectory(dir)
- registerAdapter(
- project.project.id,
- "remote-target",
- remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
- "x-target-auth": "secret",
- }),
- )
- const created = yield* requestDefault(WorkspacePaths.list, dir, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ type: "remote-target", branch: null }),
- })
- const workspace = (yield* created.json) as Workspace.Info
- const url = new URL("http://localhost/config")
- url.searchParams.set("workspace", workspace.id)
- url.searchParams.set("keep", "yes")
- try {
- const response = yield* requestDefault(url.toString(), dir, {
- method: "PATCH",
- headers: {
- "accept-encoding": "br",
- "content-type": "application/json",
- "x-opencode-workspace": "internal",
- },
- body: JSON.stringify({ $schema: "https://kirincode.ai/config.json" }),
- })
- const responseBody = yield* response.text
- expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
- expect(response.headers["content-length"]).toBeUndefined()
- expect(response.headers["x-remote"]).toBe("yes")
- expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null })
- const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config")
- expect(forwarded).toEqual([
- {
- url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`,
- method: "PATCH",
- headers: expect.objectContaining({
- "content-type": "application/json",
- "x-target-auth": "secret",
- }),
- body: JSON.stringify({ $schema: "https://kirincode.ai/config.json" }),
- },
- ])
- expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
- expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
- const eventURL = new URL(`http://localhost${EventPaths.event}`)
- eventURL.searchParams.set("workspace", workspace.id)
- const eventResponse = yield* request(eventURL.toString(), dir)
- expect(eventResponse.status).toBe(200)
- expect(eventResponse.headers["content-type"]).toContain("text/event-stream")
- const event = Array.from(yield* eventResponse.stream.pipe(Stream.take(1), Stream.runCollect))[0]
- expect(new TextDecoder().decode(event)).toContain("server.connected")
- expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
- } finally {
- void remote.stop(true)
- yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
- }
- }),
- )
- it.live("proxies remote workspace requests selected from session ownership", () =>
- Effect.gen(function* () {
- Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
- const dir = yield* tmpdirScoped({ git: true })
- const proxied: ProxiedRequest[] = []
- const remote = listenRemoteHttp((request) => {
- proxied.push(request)
- const url = new URL(request.url)
- if (url.pathname === "/base/global/event") return eventStreamResponse()
- if (url.pathname === "/base/sync/history") return Response.json([])
- return Response.json({ proxied: true, path: new URL(request.url).pathname })
- })
- const project = yield* Project.use.fromDirectory(dir)
- registerAdapter(
- project.project.id,
- "remote-session-target",
- remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
- )
- const created = yield* requestDefault(WorkspacePaths.list, dir, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ type: "remote-session-target", branch: null }),
- })
- const workspace = (yield* created.json) as Workspace.Info
- const sessionResponse = yield* requestDefault("/session", dir, { method: "POST" })
- const session = (yield* sessionResponse.json) as Session.Info
- const warped = yield* requestDefault(WorkspacePaths.warp, dir, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
- })
- expect(warped.status).toBe(204)
- try {
- const response = yield* requestDefault(`http://localhost/session/${session.id}/message`, dir, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }),
- })
- const responseBody = yield* response.text
- expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 })
- expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` })
- expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([
- expect.objectContaining({
- url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`,
- method: "POST",
- }),
- ])
- const aborted = yield* request(`http://localhost/session/${session.id}/abort`, dir, { method: "POST" })
- expect(aborted.status).toBe(200)
- expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/abort`)).toEqual([
- expect.objectContaining({
- url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/abort`,
- method: "POST",
- body: "",
- }),
- ])
- } finally {
- void remote.stop(true)
- yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
- }
- }),
- )
- })
|