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
@@ -1,45 +1,72 @@
import type { ProjectID } from "@/project/schema" import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types" import { Effect, Schema } from "effect"
import { WorktreeAdapter } from "./worktree" 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> = { const BUILTIN: WorkspaceAdapterEntry[] = [{ type: "worktree", ...WorktreeAdapterEntry }]
worktree: WorktreeAdapter,
}
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 plugins = new Map<ProjectID, Map<string, InternalWorkspaceAdapter>>()
const custom = state.get(projectID)?.get(type) 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 if (custom) return custom
const builtin = BUILTIN[type] const adapter = builtin.get(type)
if (builtin) return builtin if (adapter) return adapter
throw new Error(`Unknown workspace adapter: ${type}`) throw new Error(`Unknown workspace adapter: ${type}`)
} }
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> { export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
const builtin = await Promise.all( const custom = [...(plugins.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
Object.entries(BUILTIN).map(async ([type, adapter]) => {
return {
type,
name: adapter.name,
description: adapter.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
type, type,
name: adapter.name, name: adapter.name,
description: adapter.description, description: adapter.description,
})) }))
return [...builtin, ...custom] return [...BUILTIN, ...custom]
} }
// Plugins can be loaded per-project so we need to scope them. If you const adapterError = (cause: unknown) => new WorkspaceAdapterError({ message: errorMessage(cause), cause })
// want to install a global one pass `ProjectID.global` const decodeInfo = (value: PluginWorkspaceInfo) =>
export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) { Schema.decodeEffect(WorkspaceInfo)(value).pipe(Effect.mapError(adapterError))
const adapters = state.get(projectID) ?? new Map<string, WorkspaceAdapter>()
adapters.set(type, adapter) function runPromiseAdapter<A>(fn: () => A | Promise<A>) {
state.set(projectID, adapters) 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)
} }
@@ -1,5 +1,6 @@
import { Schema } from "effect" import { Cause, Effect, Schema } from "effect"
import { type WorkspaceAdapter, WorkspaceInfo } from "../types" import type { Interface as WorktreeService } from "@/worktree"
import { type InternalWorkspaceAdapter, WorkspaceAdapterError, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({ const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name, name: WorkspaceInfo.fields.name,
@@ -8,47 +9,68 @@ const WorktreeConfig = Schema.Struct({
}) })
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
async function loadWorktree() { export const WorktreeAdapterEntry = {
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
return { AppRuntime, Worktree }
}
export const WorktreeAdapter: WorkspaceAdapter = {
name: "Worktree", name: "Worktree",
description: "Create a git 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())) const adapterError = (message: string, cause: unknown) => new WorkspaceAdapterError({ message, cause })
return {
...info, const catchWorktreeError = <A, R>(effect: Effect.Effect<A, never, R>) =>
name: next.name, effect.pipe(
branch: next.branch, Effect.catchCause((cause) =>
directory: next.directory, Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.fail(adapterError(Cause.pretty(cause), cause)),
} ),
}, )
async create(info) {
const { AppRuntime, Worktree } = await loadWorktree() const decodeConfig = (info: WorkspaceInfo) =>
const config = decodeWorktreeConfig(info) Effect.try({
await AppRuntime.runPromise( try: () => decodeWorktreeConfig(info),
Worktree.Service.use((svc) => catch: (cause) => adapterError(cause instanceof Error ? cause.message : String(cause), cause),
svc.createFromInfo({ })
name: config.name,
directory: config.directory, export function worktreeAdapter(worktree: WorktreeService): InternalWorkspaceAdapter {
branch: config.branch, return {
}), ...WorktreeAdapterEntry,
), configure(info) {
) return catchWorktreeError(
}, Effect.gen(function* () {
async remove(info) { const next = yield* worktree.makeWorktreeInfo()
const { AppRuntime, Worktree } = await loadWorktree() return {
const config = decodeWorktreeConfig(info) ...info,
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory }))) name: next.name,
}, branch: next.branch,
target(info) { directory: next.directory,
const config = decodeWorktreeConfig(info) }
return { }),
type: "local", )
directory: config.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,
}
})
},
}
} }
+15 -6
View File
@@ -1,4 +1,4 @@
import { Schema } from "effect" import { Effect, Schema } from "effect"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { WorkspaceID } from "./schema" import { WorkspaceID } from "./schema"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
@@ -35,11 +35,20 @@ export type Target =
headers?: HeadersInit 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 name: string
description: string description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo> configure(info: WorkspaceInfo): Effect.Effect<WorkspaceInfo, WorkspaceAdapterError>
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void> create(
remove(info: WorkspaceInfo): Promise<void> info: WorkspaceInfo,
target(info: WorkspaceInfo): Target | Promise<Target> 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>
} }
@@ -16,8 +16,8 @@ import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug" import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql" import { WorkspaceTable } from "./workspace.sql"
import { getAdapter } from "./adapters" import { getAdapter, makeBuiltinAdapters } from "./adapters"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { type Target, type WorkspaceInfo, WorkspaceAdapterError, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema" import { WorkspaceID } from "./schema"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionTable } from "@/session/session.sql" import { SessionTable } from "@/session/session.sql"
@@ -27,6 +27,7 @@ import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context" import { WorkspaceContext } from "./workspace-context"
import { NonNegativeInt, withStatics } from "@/util/schema" import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod as effectZod, zodObject } from "@/util/effect-zod" import { zod as effectZod, zodObject } from "@/util/effect-zod"
import { Worktree } from "@/worktree"
export const Info = WorkspaceInfoSchema export const Info = WorkspaceInfoSchema
export type Info = WorkspaceInfo export type Info = WorkspaceInfo
@@ -136,14 +137,15 @@ export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>(
cause: Schema.optional(Schema.Defect), cause: Schema.optional(Schema.Defect),
}) {} }) {}
type CreateError = Auth.AuthError type CreateError = Auth.AuthError | WorkspaceAdapterError | unknown
type SessionRestoreError = type SessionRestoreError =
| WorkspaceNotFoundError | WorkspaceNotFoundError
| SessionEventsNotFoundError | SessionEventsNotFoundError
| SessionRestoreHttpError | SessionRestoreHttpError
| WorkspaceAdapterError
| HttpClientError.HttpClientError | HttpClientError.HttpClientError
type WaitForSyncError = SyncTimeoutError | SyncAbortedError type WaitForSyncError = SyncTimeoutError | SyncAbortedError
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError type SyncLoopError = SyncHttpError | WorkspaceAdapterError | HttpClientError.HttpClientError
export interface Interface { export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError> readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
@@ -152,6 +154,7 @@ export interface Interface {
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined> readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined> readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly status: () => Effect.Effect<ConnectionStatus[]> readonly status: () => Effect.Effect<ConnectionStatus[]>
readonly target: (workspace: Info) => Effect.Effect<Target, WorkspaceAdapterError>
readonly isSyncing: (workspaceID: WorkspaceID) => Effect.Effect<boolean> readonly isSyncing: (workspaceID: WorkspaceID) => Effect.Effect<boolean>
readonly waitForSync: ( readonly waitForSync: (
workspaceID: WorkspaceID, workspaceID: WorkspaceID,
@@ -170,8 +173,16 @@ export const layer = Layer.effect(
const session = yield* Session.Service const session = yield* Session.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service const sync = yield* SyncEvent.Service
const worktree = yield* Worktree.Service
const connections = new Map<WorkspaceID, ConnectionStatus>() const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>() 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 setStatus = (id: WorkspaceID, status: ConnectionStatus["status"]) => {
const prev = connections.get(id) const prev = connections.get(id)
@@ -335,10 +346,9 @@ export const layer = Layer.effect(
}) })
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
const adapter = getAdapter(space.projectID, space.type) const nextTarget = yield* target(space)
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space)))
if (target.type === "local") return if (nextTarget.type === "local") return
let attempt = 0 let attempt = 0
@@ -346,8 +356,8 @@ export const layer = Layer.effect(
log.info("connecting to global sync", { workspace: space.name }) log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting") setStatus(space.id, "connecting")
const stream = yield* connectSSE(target.url, target.headers).pipe( const stream = yield* connectSSE(nextTarget.url, nextTarget.headers).pipe(
Effect.tap(() => syncHistory(space, target.url, target.headers)), Effect.tap(() => syncHistory(space, nextTarget.url, nextTarget.headers)),
Effect.catch((err) => Effect.catch((err) =>
Effect.sync(() => { Effect.sync(() => {
setStatus(space.id, "error") setStatus(space.id, "error")
@@ -419,11 +429,10 @@ export const layer = Layer.effect(
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adapter = getAdapter(space.projectID, space.type) const nextTarget = yield* target(space)
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space)))
if (target.type === "local") { if (nextTarget.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(nextTarget.directory))) ? "connected" : "error")
return return
} }
@@ -458,10 +467,8 @@ export const layer = Layer.effect(
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id) const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type) const adapter = adapterFor(input)
const config = yield* Effect.promise(() => const config = yield* adapter.configure({ ...input, id, name: Slug.create(), directory: null })
Promise.resolve(adapter.configure({ ...input, id, name: Slug.create(), directory: null })),
)
const info: Info = { const info: Info = {
id, id,
@@ -496,7 +503,7 @@ export const layer = Layer.effect(
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
} }
yield* Effect.promise(() => adapter.create(config, env)) yield* adapter.create(config, env)
yield* Effect.all( yield* Effect.all(
[ [
waitEvent({ waitEvent({
@@ -531,8 +538,7 @@ export const layer = Layer.effect(
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
}) })
const adapter = getAdapter(space.projectID, space.type) const nextTarget = yield* target(space)
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space)))
yield* sync.run(Session.Event.Updated, { yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -573,7 +579,7 @@ export const layer = Layer.effect(
sessionID: input.sessionID, sessionID: input.sessionID,
workspaceType: space.type, workspaceType: space.type,
directory: space.directory, 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, events: rows.length,
batches: total, batches: total,
first: rows[0]?.seq, first: rows[0]?.seq,
@@ -605,10 +611,10 @@ export const layer = Layer.effect(
events: events.length, events: events.length,
first: events[0]?.seq, first: events[0]?.seq,
last: events.at(-1)?.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) yield* sync.replayAll(events)
log.info("session restore batch replayed locally", { log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
@@ -618,10 +624,10 @@ export const layer = Layer.effect(
events: events.length, events: events.length,
}) })
} else { } else {
const url = route(target.url, "/sync/replay") const url = route(nextTarget.url, "/sync/replay")
const res = yield* http.execute( const res = yield* http.execute(
HttpClientRequest.post(url, { HttpClientRequest.post(url, {
headers: new Headers(target.headers), headers: new Headers(nextTarget.headers),
body: HttpBody.jsonUnsafe({ body: HttpBody.jsonUnsafe({
directory: space.directory ?? "", directory: space.directory ?? "",
events, events,
@@ -726,8 +732,7 @@ export const layer = Layer.effect(
const info = fromRow(row) const info = fromRow(row)
yield* Effect.catch( yield* Effect.catch(
Effect.gen(function* () { Effect.gen(function* () {
const adapter = getAdapter(info.projectID, row.type) yield* adapterFor(info).remove(info)
yield* Effect.tryPromise(() => Promise.resolve(adapter.remove(info)))
}), }),
() => () =>
Effect.sync(() => { Effect.sync(() => {
@@ -818,6 +823,7 @@ export const layer = Layer.effect(
get, get,
remove, remove,
status, status,
target,
isSyncing, isSyncing,
waitForSync, waitForSync,
startWorkspaceSyncing, startWorkspaceSyncing,
@@ -829,6 +835,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer), Layer.provide(SyncEvent.defaultLayer),
Layer.provide(Worktree.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
) )
+1 -2
View File
@@ -25,7 +25,6 @@ import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader" import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdapter } from "@/control-plane/adapters" import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types"
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
@@ -139,7 +138,7 @@ export const layer = Layer.effect(
directory: ctx.directory, directory: ctx.directory,
experimental_workspace: { experimental_workspace: {
register(type: string, adapter: PluginWorkspaceAdapter) { register(type: string, adapter: PluginWorkspaceAdapter) {
registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter) registerAdapter(ctx.project.id, type, adapter)
}, },
}, },
get serverUrl(): URL { get serverUrl(): URL {
@@ -1,6 +1,5 @@
import { getAdapter } from "@/control-plane/adapters"
import { WorkspaceID } from "@/control-plane/schema" 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 { Workspace } from "@/control-plane/workspace"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Session } from "@/session/session" import { Session } from "@/session/session"
@@ -8,7 +7,7 @@ import { HttpApiProxy } from "./proxy"
import * as Fence from "@/server/fence" import * as Fence from "@/server/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
import { Flag } from "@opencode-ai/core/flag/flag" 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 { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi" import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket" import * as Socket from "effect/unstable/socket/Socket"
@@ -17,6 +16,7 @@ type RemoteTarget = Extract<Target, { type: "remote" }>
type RequestPlan = Data.TaggedEnum<{ type RequestPlan = Data.TaggedEnum<{
MissingWorkspace: { readonly workspaceID: WorkspaceID } MissingWorkspace: { readonly workspaceID: WorkspaceID }
TargetError: { readonly workspaceID: WorkspaceID; readonly message: string }
Local: { readonly directory: string; readonly workspaceID?: WorkspaceID } Local: { readonly directory: string; readonly workspaceID?: WorkspaceID }
Remote: { Remote: {
readonly request: HttpServerRequest.HttpServerRequest readonly request: HttpServerRequest.HttpServerRequest
@@ -87,13 +87,17 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe
}) })
} }
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> { function targetErrorResponse(id: WorkspaceID, message: string): HttpServerResponse.HttpServerResponse {
return Effect.gen(function* () { return HttpServerResponse.text(`Workspace target unavailable: ${id}\n${message}`, {
const adapter = yield* Effect.sync(() => getAdapter(workspace.projectID, workspace.type)) status: 503,
return yield* Effect.promise(() => Promise.resolve(adapter.target(workspace))) 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( function proxyRemote(
client: HttpClient.HttpClient, client: HttpClient.HttpClient,
request: HttpServerRequest.HttpServerRequest, request: HttpServerRequest.HttpServerRequest,
@@ -135,7 +139,11 @@ function planWorkspaceRequest(
workspace: Workspace.Info, workspace: Workspace.Info,
): Effect.Effect<RequestPlan, never, Workspace.Service> { ): Effect.Effect<RequestPlan, never, Workspace.Service> {
return Effect.gen(function* () { 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 }) if (target.type === "remote") return RequestPlan.Remote({ request, workspace, target, url })
return RequestPlan.Local({ directory: target.directory, workspaceID: workspace.id }) 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> { ): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor | Workspace.Service> {
return RequestPlan.$match(plan, { return RequestPlan.$match(plan, {
MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), 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), Remote: ({ request, workspace, target, url }) => proxyRemote(client, request, workspace, target, url),
Local: ({ directory, workspaceID }) => Local: ({ directory, workspaceID }) =>
effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))), effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))),
+11 -4
View File
@@ -1,6 +1,5 @@
import type { MiddlewareHandler } from "hono" import type { MiddlewareHandler } from "hono"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
import { getAdapter } from "@/control-plane/adapters"
import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
@@ -10,7 +9,7 @@ import { Instance } from "@/project/instance"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { AppRuntime } from "@/effect/app-runtime" 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 * as Log from "@opencode-ai/core/util/log"
import { ServerProxy } from "./proxy" import { ServerProxy } from "./proxy"
@@ -91,8 +90,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next() return next()
} }
const adapter = getAdapter(workspace.projectID, workspace.type) const targetExit = await AppRuntime.runPromiseExit(Workspace.Service.use((svc) => svc.target(workspace)))
const target = await adapter.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") { if (target.type === "local") {
return WorkspaceContext.provide({ return WorkspaceContext.provide({
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { getAdapter, registerAdapter } from "../../src/control-plane/adapters" import { getAdapter, registerAdapter } from "../../src/control-plane/adapters"
import { ProjectID } from "../../src/project/schema" import { ProjectID } from "../../src/project/schema"
import type { WorkspaceInfo } from "../../src/control-plane/types" import type { WorkspaceInfo } from "../../src/control-plane/types"
@@ -41,11 +42,11 @@ describe("control-plane/adapters", () => {
registerAdapter(one, type, adapter("/one")) registerAdapter(one, type, adapter("/one"))
registerAdapter(two, type, adapter("/two")) 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", type: "local",
directory: "/one", 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", type: "local",
directory: "/two", directory: "/two",
}) })
@@ -56,14 +57,14 @@ describe("control-plane/adapters", () => {
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(id, type, adapter("/one")) 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", type: "local",
directory: "/one", directory: "/one",
}) })
registerAdapter(id, type, adapter("/two")) 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", type: "local",
directory: "/two", directory: "/two",
}) })
@@ -4,6 +4,7 @@ import Http from "node:http"
import path from "node:path" import path from "node:path"
import { setTimeout as delay } from "node:timers/promises" import { setTimeout as delay } from "node:timers/promises"
import { NodeHttpServer } from "@effect/platform-node" import { NodeHttpServer } from "@effect/platform-node"
import type { WorkspaceAdapter, WorkspaceInfo as PluginWorkspaceInfo } from "@opencode-ai/plugin"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
@@ -26,7 +27,7 @@ import { testEffect } from "../lib/effect"
import { registerAdapter } from "../../src/control-plane/adapters" import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema" import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql" 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 * as WorkspaceOld from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
@@ -48,18 +49,18 @@ const originalEnv = {
} }
type RecordedCreate = { type RecordedCreate = {
info: WorkspaceInfo info: PluginWorkspaceInfo
env: Record<string, string | undefined> env: Record<string, string | undefined>
from?: WorkspaceInfo from?: PluginWorkspaceInfo
} }
type RecordedAdapter = { type RecordedAdapter = {
adapter: WorkspaceAdapter adapter: WorkspaceAdapter
calls: { calls: {
configure: WorkspaceInfo[] configure: PluginWorkspaceInfo[]
create: RecordedCreate[] create: RecordedCreate[]
remove: WorkspaceInfo[] remove: PluginWorkspaceInfo[]
target: WorkspaceInfo[] target: PluginWorkspaceInfo[]
} }
} }
@@ -166,10 +167,14 @@ function eventuallyEffect(effect: Effect.Effect<void>, timeout = 1500) {
} }
function recordedAdapter(input: { function recordedAdapter(input: {
target: (info: WorkspaceInfo) => Target | Promise<Target> target: (info: PluginWorkspaceInfo) => Target | Promise<Target>
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo> configure?: (info: PluginWorkspaceInfo) => PluginWorkspaceInfo | Promise<PluginWorkspaceInfo>
create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void> create?: (
remove?: (info: WorkspaceInfo) => Promise<void> info: PluginWorkspaceInfo,
env: Record<string, string | undefined>,
from?: PluginWorkspaceInfo,
) => Promise<void>
remove?: (info: PluginWorkspaceInfo) => Promise<void>
}): RecordedAdapter { }): RecordedAdapter {
const calls: RecordedAdapter["calls"] = { const calls: RecordedAdapter["calls"] = {
configure: [], 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({ return recordedAdapter({
configure(info) { configure(info) {
return { ...info, directory: dir } return { ...info, directory: dir }
@@ -1,4 +1,5 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import type { WorkspaceAdapter } from "@opencode-ai/plugin"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { describe, expect } from "bun:test" 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 { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters" import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
@@ -2,9 +2,9 @@ import { afterEach, describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { Effect } from "effect" import { Effect } from "effect"
import type { WorkspaceAdapter } from "@opencode-ai/plugin"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters" import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema" import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
@@ -1,4 +1,5 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import type { WorkspaceAdapter } from "@opencode-ai/plugin"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Queue } from "effect" import { Context, Effect, Layer, Queue } from "effect"
@@ -17,7 +18,6 @@ import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters" import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema" import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { Project } from "../../src/project/project" import { Project } from "../../src/project/project"
@@ -5,7 +5,7 @@ import path from "node:path"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters" 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 { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { Session } from "@/session/session" 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", () => it.live("routes local workspace requests through the workspace target directory", () =>
Effect.gen(function* () { Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true