Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/control-plane/workspace.sql.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Workspace } from "@opencode-ai/schema/workspace"
import { ProjectTable } from "../project/sql"
import { ProjectV2 } from "../project"
import { WorkspaceV2 } from "../workspace"

export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceV2.ID>().primaryKey(),
id: text().$type<Workspace.ID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Image } from "./image"
import { LocationWatcher } from "./filesystem/location-watcher"
import { Integration } from "./integration"
import { Location } from "./location"
import { WorkspaceEnvironment } from "./workspace/environment"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { MCP } from "./mcp/index"
Expand Down Expand Up @@ -50,6 +51,7 @@ export { LocationServiceMap } from "./location-service-map"

const locationServiceNodes = [
Location.node,
WorkspaceEnvironment.node,
Config.node,
AgentV2.node,
CommandV2.node,
Expand Down Expand Up @@ -115,7 +117,10 @@ export function buildLocationServiceMap(
LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
const allReplacements = replacements.concat([
[Location.node, Location.boundNode(ref)],
[WorkspaceEnvironment.node, WorkspaceEnvironment.boundNode(ref)],
])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
Expand Down
39 changes: 35 additions & 4 deletions packages/core/src/location.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location"
import path from "path"
import { Project } from "./project"
import { LayerNode } from "./effect/layer-node"
import { makeLocationNode, tags } from "./effect/app-node"
import { WorkspaceV2 } from "./workspace"

export * as Location from "./location"

Expand All @@ -16,7 +18,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo

export const node = LayerNode.unbound(Service, tags.values.location)

const layer = (ref: Ref) =>
const localLayer = (ref: Ref) =>
Layer.effect(
Service,
Effect.gen(function* () {
Expand All @@ -31,9 +33,38 @@ const layer = (ref: Ref) =>
}),
)

export const boundNode = (ref: Ref) =>
makeLocationNode({
const hostedLayer = (ref: Ref & { readonly workspaceID: WorkspaceV2.ID }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const workspace = yield* WorkspaceV2.Service
const info = yield* workspace.get(ref.workspaceID)
const relative = path.posix.relative(info.directory, ref.directory)
if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) {
return yield* new WorkspaceV2.InvalidError({
id: ref.workspaceID,
message: `Location directory is outside Workspace root: ${ref.directory}`,
})
}
return Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: info.project,
})
}),
).pipe(Layer.orDie)

export const boundNode = (ref: Ref) => {
if (ref.workspaceID) {
return makeLocationNode({
service: Service,
layer: hostedLayer({ ...ref, workspaceID: ref.workspaceID }),
deps: [WorkspaceV2.node],
})
}
return makeLocationNode({
service: Service,
layer: layer(ref),
layer: localLayer(ref),
deps: [Project.node],
})
}
269 changes: 269 additions & 0 deletions packages/core/src/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,275 @@
export * as WorkspaceV2 from "./workspace"

import { Context, Effect, Equal, Exit, Latch, Layer, RcMap, Schema, Scope, Semaphore } from "effect"
import { Workspace } from "@opencode-ai/schema/workspace"
import { eq } from "drizzle-orm"
import { Database } from "./database/database"
import { makeGlobalNode } from "./effect/app-node"
import { AbsolutePath } from "./schema"
import { WorkspaceTable } from "./control-plane/workspace.sql"
import { Sandbox } from "./workspace/sandbox"
import type { WorkspaceEnvironment } from "./workspace/environment"

export const ID = Workspace.ID
export type ID = typeof ID.Type

export const Info = Workspace.Info
export type Info = Workspace.Info

export interface CreateInput {
readonly provider: string
readonly name: string
readonly directory: AbsolutePath
readonly projectID: Info["project"]["id"]
}

export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Workspace.NotFoundError", {
id: ID,
}) {}

export class InvalidError extends Schema.TaggedErrorClass<InvalidError>()("Workspace.InvalidError", {
id: ID,
message: Schema.String,
}) {}

export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, Sandbox.Error | Sandbox.ProviderNotFoundError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError | InvalidError>
readonly connect: (
id: ID,
) => Effect.Effect<void, NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError>
readonly borrow: (
id: ID,
) => Effect.Effect<
WorkspaceEnvironment.Interface,
NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError,
Scope.Scope
>
readonly suspend: (
id: ID,
) => Effect.Effect<void, NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError>
readonly remove: (
id: ID,
) => Effect.Effect<void, NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}

const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const registry = yield* Sandbox.RegistryService
const lifecycle = yield* RcMap.make({
idleTimeToLive: 0,
lookup: () => Effect.succeed(makeLifecycleGate()),
})

const row = Effect.fn("Workspace.row")(function* (id: ID) {
const value = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
if (!value) return yield* new NotFoundError({ id })
if (!value.directory) return yield* new InvalidError({ id, message: "Workspace has no directory" })
return { ...value, directory: value.directory }
})

const get = Effect.fn("Workspace.get")(function* (id: ID) {
const value = yield* row(id)
const directory = AbsolutePath.make(value.directory)
return Info.make({
id,
name: value.name,
directory,
project: { id: value.project_id, directory },
})
})

const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = ID.create()
const provider = yield* registry.get(input.provider)
return yield* Effect.acquireUseRelease(
provider.create({ identity: id }),
(binding) =>
db
.insert(WorkspaceTable)
.values({
id,
type: provider.key,
name: input.name,
directory: input.directory,
extra: Sandbox.Placement.make({ kind: "sandbox", version: 1, binding }),
project_id: input.projectID,
})
.run()
.pipe(
Effect.orDie,
Effect.as(
Info.make({
id,
name: input.name,
directory: input.directory,
project: { id: input.projectID, directory: input.directory },
}),
),
),
(binding, exit) => (Exit.isFailure(exit) ? Effect.uninterruptible(provider.delete(binding)) : Effect.void),
)
})

const persistBinding = Effect.fnUntraced(function* (id: ID, previous: Sandbox.Binding, next: Sandbox.Binding) {
if (Equal.equals(previous, next)) return
yield* db
.update(WorkspaceTable)
.set({ extra: Sandbox.Placement.make({ kind: "sandbox", version: 1, binding: next }) })
.where(eq(WorkspaceTable.id, id))
.run()
.pipe(Effect.orDie)
})

const placement = Effect.fnUntraced(function* (id: ID) {
const value = yield* row(id)
if (!Schema.is(Sandbox.Placement)(value.extra)) {
return yield* new InvalidError({ id, message: "Workspace has no sandbox binding" })
}
const provider = yield* registry.get(value.type)
return { provider, binding: yield* provider.decode(value.extra.binding) }
})

const reconcile = Effect.fnUntraced(function* (id: ID, provider: Sandbox.Provider, binding: Sandbox.Binding) {
const next = yield* provider.reconcile(binding)
yield* persistBinding(id, binding, next)
return next
})

const connections = yield* RcMap.make({
idleTimeToLive: "1 minute",
lookup: Effect.fn("Workspace.connect")(function* (id: ID) {
const current = yield* placement(id)
const provider = current.provider
const binding = current.binding
const connection = yield* provider.connect(binding)
yield* persistBinding(id, binding, connection.binding)
yield* reconcile(id, provider, connection.binding)
return { provider, connection }
}),
})

const lease = (id: ID) => RcMap.get(lifecycle, id).pipe(Effect.flatMap((gate) => gate.read))

const borrow = (id: ID) =>
lease(id).pipe(
Effect.andThen(RcMap.get(connections, id)),
Effect.onExit((exit) => (Exit.isFailure(exit) ? RcMap.invalidate(connections, id) : Effect.void)),
Effect.map((value) => value.connection.environment),
)

const transition = <A, E, R>(id: ID, effect: Effect.Effect<A, E, R>) =>
Effect.scoped(RcMap.get(lifecycle, id).pipe(Effect.flatMap((gate) => gate.write(effect))))

const suspend = Effect.fn("Workspace.suspend")((id: ID) =>
transition(
id,
Effect.scoped(
RcMap.get(connections, id).pipe(
Effect.flatMap((value) =>
Effect.gen(function* () {
const binding = yield* Effect.uninterruptible(
value.provider
.suspend(value.connection)
.pipe(Effect.tap((binding) => persistBinding(id, value.connection.binding, binding))),
)
yield* reconcile(id, value.provider, binding)
}),
),
),
).pipe(Effect.ensuring(RcMap.invalidate(connections, id))),
),
)

const remove = Effect.fn("Workspace.remove")((id: ID) =>
transition(
id,
Effect.gen(function* () {
yield* RcMap.invalidate(connections, id)
const current = yield* placement(id)
const binding = yield* reconcile(id, current.provider, current.binding)
yield* Effect.uninterruptible(
current.provider
.delete(binding)
.pipe(
Effect.andThen(db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run().pipe(Effect.orDie)),
),
)
}),
),
)

return Service.of({
create,
get,
connect: (id) => Effect.scoped(borrow(id)).pipe(Effect.asVoid),
borrow,
suspend,
remove,
})
}),
)

export const node = makeGlobalNode({
service: Service,
layer,
deps: [Database.node, Sandbox.registryNode],
})

function makeLifecycleGate() {
const admission = Semaphore.makeUnsafe(1)
const transition = Semaphore.makeUnsafe(1)
const open = Latch.makeUnsafe(true)
const drained = Latch.makeUnsafe(true)
let active = 0
let blocked = false

const read: Effect.Effect<void, never, Scope.Scope> = Effect.suspend(() =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
yield* restore(open.await)
yield* restore(admission.take(1))
if (blocked) {
yield* admission.release(1)
return yield* restore(read)
}
active++
if (active === 1) drained.closeUnsafe()
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
active--
if (active === 0) drained.openUnsafe()
}),
)
yield* admission.release(1)
}),
),
)

const write = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
transition.withPermit(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
yield* restore(admission.take(1))
blocked = true
open.closeUnsafe()
yield* admission.release(1)
return yield* restore(drained.await.pipe(Effect.andThen(effect))).pipe(
Effect.ensuring(
Effect.sync(() => {
blocked = false
}).pipe(Effect.andThen(open.open)),
),
)
}),
),
)

return { read, write }
}
Loading
Loading