mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 09:39:46 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38a44903c5 | |||
| aaeff9b3ef | |||
| 34e332c139 |
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+17
-8
@@ -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 }))),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user