mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-02 15:00:39 +08:00
Compare commits
3 Commits
dev
...
kit/effect
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a44903c5 | ||
|
|
aaeff9b3ef | ||
|
|
34e332c139 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }))),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user