fix(worktree): fork workspace worktree boot (#25723)
This commit is contained in:
@@ -291,16 +291,15 @@ export const layer: Layer.Layer<
|
|||||||
|
|
||||||
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
|
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
|
||||||
yield* setup(info)
|
yield* setup(info)
|
||||||
yield* boot(info, startCommand)
|
yield* boot(info, startCommand).pipe(
|
||||||
|
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
|
||||||
|
Effect.forkIn(scope),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
|
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
|
||||||
const info = yield* makeWorktreeInfo(input?.name)
|
const info = yield* makeWorktreeInfo(input?.name)
|
||||||
yield* setup(info)
|
yield* createFromInfo(info, input?.startCommand)
|
||||||
yield* boot(info, input?.startCommand).pipe(
|
|
||||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
|
|
||||||
Effect.forkIn(scope),
|
|
||||||
)
|
|
||||||
return info
|
return info
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -178,12 +178,13 @@ describe("Worktree", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("createFromInfo", () => {
|
describe("createFromInfo", () => {
|
||||||
wintest("creates and bootstraps git worktree", () =>
|
wintest("creates git worktree and boots asynchronously", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const svc = yield* Worktree.Service
|
const svc = yield* Worktree.Service
|
||||||
const info = yield* svc.makeWorktreeInfo("from-info-test")
|
const info = yield* svc.makeWorktreeInfo("from-info-test")
|
||||||
|
const ready = waitReady()
|
||||||
yield* svc.createFromInfo(info)
|
yield* svc.createFromInfo(info)
|
||||||
|
|
||||||
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
|
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
|
||||||
@@ -191,6 +192,7 @@ describe("Worktree", () => {
|
|||||||
const normalizedDir = info.directory.replace(/\\/g, "/")
|
const normalizedDir = info.directory.replace(/\\/g, "/")
|
||||||
expect(normalizedList).toContain(normalizedDir)
|
expect(normalizedList).toContain(normalizedDir)
|
||||||
|
|
||||||
|
yield* Effect.promise(() => ready)
|
||||||
yield* svc.remove({ directory: info.directory })
|
yield* svc.remove({ directory: info.directory })
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true },
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { HttpRouter } from "effect/unstable/http"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||||
|
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||||
|
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||||
|
import { withTimeout } from "../../src/util/timeout"
|
||||||
|
import { resetDatabase } from "../fixture/db"
|
||||||
|
import { TestInstance } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const stateLayer = Layer.effectDiscard(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const original = {
|
||||||
|
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||||
|
OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||||
|
}
|
||||||
|
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||||
|
await resetDatabase()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const it = testEffect(stateLayer)
|
||||||
|
type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
|
||||||
|
|
||||||
|
function serverScoped() {
|
||||||
|
return Effect.acquireRelease(
|
||||||
|
Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })),
|
||||||
|
(server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(server: TestServer, input: string, init?: RequestInit) {
|
||||||
|
return Effect.promise(() =>
|
||||||
|
server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
|
||||||
|
return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
|
||||||
|
expect(current.status).toBe(200)
|
||||||
|
const project = (yield* Effect.promise(() => current.json())) as { id: string }
|
||||||
|
const updated = yield* request(
|
||||||
|
input.server,
|
||||||
|
`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ commands: { start: input.command } }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(updated.status).toBe(200)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("worktree endpoint reproduction", () => {
|
||||||
|
it.instance(
|
||||||
|
"direct HttpApi worktree create returns without waiting for boot",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const server = yield* serverScoped()
|
||||||
|
|
||||||
|
const response = yield* withRequestTimeout(
|
||||||
|
request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}),
|
||||||
|
"direct worktree create",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) })
|
||||||
|
}),
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
it.instance(
|
||||||
|
"workspace worktree create does not hang",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const server = yield* serverScoped()
|
||||||
|
|
||||||
|
const response = yield* withRequestTimeout(
|
||||||
|
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||||
|
}),
|
||||||
|
"workspace worktree create",
|
||||||
|
8_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(yield* Effect.promise(() => response.json())).toMatchObject({
|
||||||
|
type: "worktree",
|
||||||
|
directory: expect.any(String),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
it.instance(
|
||||||
|
"workspace worktree create returns without waiting for project start command",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const server = yield* serverScoped()
|
||||||
|
yield* setProjectStartCommand({
|
||||||
|
server,
|
||||||
|
directory: test.directory,
|
||||||
|
command: 'bun -e "setTimeout(() => {}, 2000)"',
|
||||||
|
})
|
||||||
|
|
||||||
|
const started = Date.now()
|
||||||
|
const response = yield* withRequestTimeout(
|
||||||
|
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||||
|
}),
|
||||||
|
"workspace worktree create with project start command",
|
||||||
|
6_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(Date.now() - started).toBeLessThan(1_500)
|
||||||
|
}),
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user