Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton
38a44903c5 refactor: clarify internal workspace adapter type 2026-05-01 18:13:31 -04:00
Kit Langton
aaeff9b3ef refactor: simplify workspace adapter registry 2026-05-01 18:07:37 -04:00
Kit Langton
34e332c139 fix: preserve workspace adapter context 2026-05-01 17:49:57 -04:00
13 changed files with 269 additions and 136 deletions

View File

@@ -1,45 +1,72 @@
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types"
import { WorktreeAdapter } from "./worktree"
import { Effect, Schema } from "effect"
import type { WorkspaceAdapter as PluginWorkspaceAdapter, WorkspaceInfo as PluginWorkspaceInfo } from "@opencode-ai/plugin"
import { EffectBridge } from "@/effect/bridge"
import { errorMessage } from "@/util/error"
import { type InternalWorkspaceAdapter, WorkspaceAdapterError, type WorkspaceAdapterEntry, WorkspaceInfo } from "../types"
import type { Interface as WorktreeService } from "@/worktree"
import { WorktreeAdapterEntry, worktreeAdapter } from "./worktree"
const BUILTIN: Record<string, WorkspaceAdapter> = {
worktree: WorktreeAdapter,
}
const BUILTIN: WorkspaceAdapterEntry[] = [{ type: "worktree", ...WorktreeAdapterEntry }]
const state = new Map<ProjectID, Map<string, WorkspaceAdapter>>()
export const makeBuiltinAdapters = (worktree: WorktreeService) =>
new Map<string, InternalWorkspaceAdapter>([["worktree", worktreeAdapter(worktree)]])
export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter {
const custom = state.get(projectID)?.get(type)
const plugins = new Map<ProjectID, Map<string, InternalWorkspaceAdapter>>()
const emptyBuiltinAdapters = new Map<string, InternalWorkspaceAdapter>()
export function getAdapter(
projectID: ProjectID,
type: string,
builtin: ReadonlyMap<string, InternalWorkspaceAdapter> = emptyBuiltinAdapters,
): InternalWorkspaceAdapter {
const custom = plugins.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin
const adapter = builtin.get(type)
if (adapter) return adapter
throw new Error(`Unknown workspace adapter: ${type}`)
}
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, adapter]) => {
return {
type,
name: adapter.name,
description: adapter.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
const custom = [...(plugins.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
type,
name: adapter.name,
description: adapter.description,
}))
return [...builtin, ...custom]
return [...BUILTIN, ...custom]
}
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`
export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) {
const adapters = state.get(projectID) ?? new Map<string, WorkspaceAdapter>()
adapters.set(type, adapter)
state.set(projectID, adapters)
const adapterError = (cause: unknown) => new WorkspaceAdapterError({ message: errorMessage(cause), cause })
const decodeInfo = (value: PluginWorkspaceInfo) =>
Schema.decodeEffect(WorkspaceInfo)(value).pipe(Effect.mapError(adapterError))
function runPromiseAdapter<A>(fn: () => A | Promise<A>) {
return Effect.gen(function* () {
const bridge = yield* EffectBridge.make()
return yield* bridge.run(Effect.tryPromise({
try: () => Promise.resolve().then(fn),
catch: adapterError,
}))
})
}
function fromPromiseAdapter(adapter: PluginWorkspaceAdapter): InternalWorkspaceAdapter {
return {
name: adapter.name,
description: adapter.description,
configure: (info) => runPromiseAdapter(() => adapter.configure(info)).pipe(Effect.flatMap(decodeInfo)),
create: (info, env, from) => runPromiseAdapter(() => adapter.create(info, env, from)),
remove: (info) => runPromiseAdapter(() => adapter.remove(info)),
target: (info) => runPromiseAdapter(() => adapter.target(info)),
}
}
export function registerAdapter(projectID: ProjectID, type: string, adapter: PluginWorkspaceAdapter) {
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`.
const adapters = plugins.get(projectID) ?? new Map<string, InternalWorkspaceAdapter>()
adapters.set(type, fromPromiseAdapter(adapter))
plugins.set(projectID, adapters)
}

View File

@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
import { Cause, Effect, Schema } from "effect"
import type { Interface as WorktreeService } from "@/worktree"
import { type InternalWorkspaceAdapter, WorkspaceAdapterError, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name,
@@ -8,47 +9,68 @@ const WorktreeConfig = Schema.Struct({
})
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
async function loadWorktree() {
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
return { AppRuntime, Worktree }
}
export const WorktreeAdapter: WorkspaceAdapter = {
export const WorktreeAdapterEntry = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
return {
...info,
name: next.name,
branch: next.branch,
directory: next.directory,
}
},
async create(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
svc.createFromInfo({
name: config.name,
directory: config.directory,
branch: config.branch,
}),
),
)
},
async remove(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
},
target(info) {
const config = decodeWorktreeConfig(info)
return {
type: "local",
directory: config.directory,
}
},
}
const adapterError = (message: string, cause: unknown) => new WorkspaceAdapterError({ message, cause })
const catchWorktreeError = <A, R>(effect: Effect.Effect<A, never, R>) =>
effect.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.fail(adapterError(Cause.pretty(cause), cause)),
),
)
const decodeConfig = (info: WorkspaceInfo) =>
Effect.try({
try: () => decodeWorktreeConfig(info),
catch: (cause) => adapterError(cause instanceof Error ? cause.message : String(cause), cause),
})
export function worktreeAdapter(worktree: WorktreeService): InternalWorkspaceAdapter {
return {
...WorktreeAdapterEntry,
configure(info) {
return catchWorktreeError(
Effect.gen(function* () {
const next = yield* worktree.makeWorktreeInfo()
return {
...info,
name: next.name,
branch: next.branch,
directory: next.directory,
}
}),
)
},
create(info) {
return Effect.gen(function* () {
const config = yield* decodeConfig(info)
yield* catchWorktreeError(
worktree.createFromInfo({
name: config.name,
directory: config.directory,
branch: config.branch,
}),
)
})
},
remove(info) {
return Effect.gen(function* () {
const config = yield* decodeConfig(info)
yield* catchWorktreeError(worktree.remove({ directory: config.directory }))
})
},
target(info) {
return Effect.gen(function* () {
const config = yield* decodeConfig(info)
return {
type: "local" as const,
directory: config.directory,
}
})
},
}
}

View File

@@ -1,4 +1,4 @@
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import { ProjectID } from "@/project/schema"
import { WorkspaceID } from "./schema"
import { zod } from "@/util/effect-zod"
@@ -35,11 +35,20 @@ export type Target =
headers?: HeadersInit
}
export type WorkspaceAdapter = {
export class WorkspaceAdapterError extends Schema.TaggedErrorClass<WorkspaceAdapterError>()("WorkspaceAdapterError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export type InternalWorkspaceAdapter = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
configure(info: WorkspaceInfo): Effect.Effect<WorkspaceInfo, WorkspaceAdapterError>
create(
info: WorkspaceInfo,
env: Record<string, string | undefined>,
from?: WorkspaceInfo,
): Effect.Effect<void, WorkspaceAdapterError>
remove(info: WorkspaceInfo): Effect.Effect<void, WorkspaceAdapterError>
target(info: WorkspaceInfo): Effect.Effect<Target, WorkspaceAdapterError>
}

View File

@@ -16,8 +16,8 @@ import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdapter } from "./adapters"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { getAdapter, makeBuiltinAdapters } from "./adapters"
import { type Target, type WorkspaceInfo, WorkspaceAdapterError, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { Session } from "@/session/session"
import { SessionTable } from "@/session/session.sql"
@@ -27,6 +27,7 @@ import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod as effectZod, zodObject } from "@/util/effect-zod"
import { Worktree } from "@/worktree"
export const Info = WorkspaceInfoSchema
export type Info = WorkspaceInfo
@@ -136,14 +137,15 @@ export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>(
cause: Schema.optional(Schema.Defect),
}) {}
type CreateError = Auth.AuthError
type CreateError = Auth.AuthError | WorkspaceAdapterError | unknown
type SessionRestoreError =
| WorkspaceNotFoundError
| SessionEventsNotFoundError
| SessionRestoreHttpError
| WorkspaceAdapterError
| HttpClientError.HttpClientError
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
type SyncLoopError = SyncHttpError | WorkspaceAdapterError | HttpClientError.HttpClientError
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
@@ -152,6 +154,7 @@ export interface Interface {
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly status: () => Effect.Effect<ConnectionStatus[]>
readonly target: (workspace: Info) => Effect.Effect<Target, WorkspaceAdapterError>
readonly isSyncing: (workspaceID: WorkspaceID) => Effect.Effect<boolean>
readonly waitForSync: (
workspaceID: WorkspaceID,
@@ -170,8 +173,16 @@ export const layer = Layer.effect(
const session = yield* Session.Service
const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const worktree = yield* Worktree.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
const builtinAdapters = makeBuiltinAdapters(worktree)
const adapterFor = (space: { projectID: ProjectID; type: string }) =>
getAdapter(space.projectID, space.type, builtinAdapters)
const target = Effect.fn("Workspace.target")(function* (space: Info) {
return yield* adapterFor(space).target(space)
})
const setStatus = (id: WorkspaceID, status: ConnectionStatus["status"]) => {
const prev = connections.get(id)
@@ -335,10 +346,9 @@ export const layer = Layer.effect(
})
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
const adapter = getAdapter(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space)))
const nextTarget = yield* target(space)
if (target.type === "local") return
if (nextTarget.type === "local") return
let attempt = 0
@@ -346,8 +356,8 @@ export const layer = Layer.effect(
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
const stream = yield* connectSSE(target.url, target.headers).pipe(
Effect.tap(() => syncHistory(space, target.url, target.headers)),
const stream = yield* connectSSE(nextTarget.url, nextTarget.headers).pipe(
Effect.tap(() => syncHistory(space, nextTarget.url, nextTarget.headers)),
Effect.catch((err) =>
Effect.sync(() => {
setStatus(space.id, "error")
@@ -419,11 +429,10 @@ export const layer = Layer.effect(
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adapter = getAdapter(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space)))
const nextTarget = yield* target(space)
if (target.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
if (nextTarget.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(nextTarget.directory))) ? "connected" : "error")
return
}
@@ -458,10 +467,8 @@ export const layer = Layer.effect(
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* Effect.promise(() =>
Promise.resolve(adapter.configure({ ...input, id, name: Slug.create(), directory: null })),
)
const adapter = adapterFor(input)
const config = yield* adapter.configure({ ...input, id, name: Slug.create(), directory: null })
const info: Info = {
id,
@@ -496,7 +503,7 @@ export const layer = Layer.effect(
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
yield* Effect.promise(() => adapter.create(config, env))
yield* adapter.create(config, env)
yield* Effect.all(
[
waitEvent({
@@ -531,8 +538,7 @@ export const layer = Layer.effect(
workspaceID: input.workspaceID,
})
const adapter = getAdapter(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space)))
const nextTarget = yield* target(space)
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
@@ -573,7 +579,7 @@ export const layer = Layer.effect(
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
target: nextTarget.type === "remote" ? String(route(nextTarget.url, "/sync/replay")) : nextTarget.directory,
events: rows.length,
batches: total,
first: rows[0]?.seq,
@@ -605,10 +611,10 @@ export const layer = Layer.effect(
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
target: nextTarget.type === "remote" ? String(route(nextTarget.url, "/sync/replay")) : nextTarget.directory,
})
if (target.type === "local") {
if (nextTarget.type === "local") {
yield* sync.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
@@ -618,10 +624,10 @@ export const layer = Layer.effect(
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const url = route(nextTarget.url, "/sync/replay")
const res = yield* http.execute(
HttpClientRequest.post(url, {
headers: new Headers(target.headers),
headers: new Headers(nextTarget.headers),
body: HttpBody.jsonUnsafe({
directory: space.directory ?? "",
events,
@@ -726,8 +732,7 @@ export const layer = Layer.effect(
const info = fromRow(row)
yield* Effect.catch(
Effect.gen(function* () {
const adapter = getAdapter(info.projectID, row.type)
yield* Effect.tryPromise(() => Promise.resolve(adapter.remove(info)))
yield* adapterFor(info).remove(info)
}),
() =>
Effect.sync(() => {
@@ -818,6 +823,7 @@ export const layer = Layer.effect(
get,
remove,
status,
target,
isSyncing,
waitForSync,
startWorkspaceSyncing,
@@ -829,6 +835,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(Worktree.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)

View File

@@ -25,7 +25,6 @@ import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types"
const log = Log.create({ service: "plugin" })
@@ -139,7 +138,7 @@ export const layer = Layer.effect(
directory: ctx.directory,
experimental_workspace: {
register(type: string, adapter: PluginWorkspaceAdapter) {
registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter)
registerAdapter(ctx.project.id, type, adapter)
},
},
get serverUrl(): URL {

View File

@@ -1,6 +1,5 @@
import { getAdapter } from "@/control-plane/adapters"
import { WorkspaceID } from "@/control-plane/schema"
import type { Target } from "@/control-plane/types"
import type { Target, WorkspaceAdapterError } from "@/control-plane/types"
import { Workspace } from "@/control-plane/workspace"
import { Instance } from "@/project/instance"
import { Session } from "@/session/session"
@@ -8,7 +7,7 @@ import { HttpApiProxy } from "./proxy"
import * as Fence from "@/server/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer } from "effect"
import { Cause, Context, Data, Effect, Exit, Layer } from "effect"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
@@ -17,6 +16,7 @@ type RemoteTarget = Extract<Target, { type: "remote" }>
type RequestPlan = Data.TaggedEnum<{
MissingWorkspace: { readonly workspaceID: WorkspaceID }
TargetError: { readonly workspaceID: WorkspaceID; readonly message: string }
Local: { readonly directory: string; readonly workspaceID?: WorkspaceID }
Remote: {
readonly request: HttpServerRequest.HttpServerRequest
@@ -87,13 +87,17 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe
})
}
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> {
return Effect.gen(function* () {
const adapter = yield* Effect.sync(() => getAdapter(workspace.projectID, workspace.type))
return yield* Effect.promise(() => Promise.resolve(adapter.target(workspace)))
function targetErrorResponse(id: WorkspaceID, message: string): HttpServerResponse.HttpServerResponse {
return HttpServerResponse.text(`Workspace target unavailable: ${id}\n${message}`, {
status: 503,
contentType: "text/plain; charset=utf-8",
})
}
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target, WorkspaceAdapterError, Workspace.Service> {
return Workspace.Service.use((svc) => svc.target(workspace))
}
function proxyRemote(
client: HttpClient.HttpClient,
request: HttpServerRequest.HttpServerRequest,
@@ -135,7 +139,11 @@ function planWorkspaceRequest(
workspace: Workspace.Info,
): Effect.Effect<RequestPlan, never, Workspace.Service> {
return Effect.gen(function* () {
const target = yield* resolveTarget(workspace)
const exit = yield* resolveTarget(workspace).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
return RequestPlan.TargetError({ workspaceID: workspace.id, message: Cause.pretty(exit.cause) })
}
const target = exit.value
if (target.type === "remote") return RequestPlan.Remote({ request, workspace, target, url })
return RequestPlan.Local({ directory: target.directory, workspaceID: workspace.id })
})
@@ -170,6 +178,7 @@ function routeWorkspace<E>(
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor | Workspace.Service> {
return RequestPlan.$match(plan, {
MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)),
TargetError: ({ workspaceID, message }) => Effect.succeed(targetErrorResponse(workspaceID, message)),
Remote: ({ request, workspace, target, url }) => proxyRemote(client, request, workspace, target, url),
Local: ({ directory, workspaceID }) =>
effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))),

View File

@@ -1,6 +1,5 @@
import type { MiddlewareHandler } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { getAdapter } from "@/control-plane/adapters"
import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Workspace } from "@/control-plane/workspace"
@@ -10,7 +9,7 @@ import { Instance } from "@/project/instance"
import { Session } from "@/session/session"
import { SessionID } from "@/session/schema"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import { Cause, Effect, Exit } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { ServerProxy } from "./proxy"
@@ -91,8 +90,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next()
}
const adapter = getAdapter(workspace.projectID, workspace.type)
const target = await adapter.target(workspace)
const targetExit = await AppRuntime.runPromiseExit(Workspace.Service.use((svc) => svc.target(workspace)))
if (Exit.isFailure(targetExit)) {
return new Response(`Workspace target unavailable: ${workspace.id}\n${Cause.pretty(targetExit.cause)}`, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
}
const target = targetExit.value
if (target.type === "local") {
return WorkspaceContext.provide({

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { getAdapter, registerAdapter } from "../../src/control-plane/adapters"
import { ProjectID } from "../../src/project/schema"
import type { WorkspaceInfo } from "../../src/control-plane/types"
@@ -41,11 +42,11 @@ describe("control-plane/adapters", () => {
registerAdapter(one, type, adapter("/one"))
registerAdapter(two, type, adapter("/two"))
expect(await (await getAdapter(one, type)).target(info(one, type))).toEqual({
expect(await Effect.runPromise(getAdapter(one, type).target(info(one, type)))).toEqual({
type: "local",
directory: "/one",
})
expect(await (await getAdapter(two, type)).target(info(two, type))).toEqual({
expect(await Effect.runPromise(getAdapter(two, type).target(info(two, type)))).toEqual({
type: "local",
directory: "/two",
})
@@ -56,14 +57,14 @@ describe("control-plane/adapters", () => {
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(id, type, adapter("/one"))
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
expect(await Effect.runPromise(getAdapter(id, type).target(info(id, type)))).toEqual({
type: "local",
directory: "/one",
})
registerAdapter(id, type, adapter("/two"))
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({
expect(await Effect.runPromise(getAdapter(id, type).target(info(id, type)))).toEqual({
type: "local",
directory: "/two",
})

View File

@@ -4,6 +4,7 @@ import Http from "node:http"
import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node"
import type { WorkspaceAdapter, WorkspaceInfo as PluginWorkspaceInfo } from "@opencode-ai/plugin"
import { Effect, Layer } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { asc, eq } from "drizzle-orm"
@@ -26,7 +27,7 @@ import { testEffect } from "../lib/effect"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
import type { Target, WorkspaceInfo } from "../../src/control-plane/types"
import * as WorkspaceOld from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
@@ -48,18 +49,18 @@ const originalEnv = {
}
type RecordedCreate = {
info: WorkspaceInfo
info: PluginWorkspaceInfo
env: Record<string, string | undefined>
from?: WorkspaceInfo
from?: PluginWorkspaceInfo
}
type RecordedAdapter = {
adapter: WorkspaceAdapter
calls: {
configure: WorkspaceInfo[]
configure: PluginWorkspaceInfo[]
create: RecordedCreate[]
remove: WorkspaceInfo[]
target: WorkspaceInfo[]
remove: PluginWorkspaceInfo[]
target: PluginWorkspaceInfo[]
}
}
@@ -166,10 +167,14 @@ function eventuallyEffect(effect: Effect.Effect<void>, timeout = 1500) {
}
function recordedAdapter(input: {
target: (info: WorkspaceInfo) => Target | Promise<Target>
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void>
remove?: (info: WorkspaceInfo) => Promise<void>
target: (info: PluginWorkspaceInfo) => Target | Promise<Target>
configure?: (info: PluginWorkspaceInfo) => PluginWorkspaceInfo | Promise<PluginWorkspaceInfo>
create?: (
info: PluginWorkspaceInfo,
env: Record<string, string | undefined>,
from?: PluginWorkspaceInfo,
) => Promise<void>
remove?: (info: PluginWorkspaceInfo) => Promise<void>
}): RecordedAdapter {
const calls: RecordedAdapter["calls"] = {
configure: [],
@@ -207,7 +212,7 @@ function recordedAdapter(input: {
}
}
function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise<void> }) {
function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info: PluginWorkspaceInfo) => Promise<void> }) {
return recordedAdapter({
configure(info) {
return { ...info, directory: dir }

View File

@@ -1,4 +1,5 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import type { WorkspaceAdapter } from "@opencode-ai/plugin"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { describe, expect } from "bun:test"
@@ -8,7 +9,6 @@ import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"

View File

@@ -2,9 +2,9 @@ import { afterEach, describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import type { WorkspaceAdapter } from "@opencode-ai/plugin"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"

View File

@@ -1,4 +1,5 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import type { WorkspaceAdapter } from "@opencode-ai/plugin"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Queue } from "effect"
@@ -17,7 +18,6 @@ import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } 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"

View File

@@ -5,7 +5,7 @@ import path from "node:path"
import { Effect, Layer } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import type { WorkspaceAdapter } from "@opencode-ai/plugin"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { Session } from "@/session/session"
@@ -195,6 +195,53 @@ describe("workspace HttpApi", () => {
}),
)
it.live("creates built-in worktree workspace through HttpApi instance context", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "worktree", branch: null, extra: null }),
})
const createdBody = yield* Effect.promise(() => created.clone().text())
expect({ status: created.status, body: createdBody }).toMatchObject({ status: 200 })
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
expect(workspace.type).toBe("worktree")
expect(workspace.directory).toBeString()
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}),
)
it.live("runs promise workspace adapters with legacy instance context", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const workspaceDir = path.join(dir, ".workspace-context")
const project = yield* Project.use.fromDirectory(dir)
let adapterDirectory: string | undefined
registerAdapter(project.project.id, "promise-context", {
...localAdapter(workspaceDir),
async create() {
adapterDirectory = Instance.directory
await mkdir(workspaceDir, { recursive: true })
},
})
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "promise-context", branch: null, extra: null }),
})
expect(created.status).toBe(200)
expect(adapterDirectory).toBe(dir)
}),
)
it.live("routes local workspace requests through the workspace target directory", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true