Browse Source

feat(core): add remote workspace environment seam

Kit Langton 1 tháng trước cách đây
mục cha
commit
fd92aeac66

+ 2 - 2
packages/core/src/control-plane/workspace.sql.ts

@@ -1,10 +1,10 @@
 import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
 import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
+import { Workspace } from "@opencode-ai/schema/workspace"
 import { ProjectTable } from "../project/sql"
 import { ProjectTable } from "../project/sql"
 import { ProjectV2 } from "../project"
 import { ProjectV2 } from "../project"
-import { WorkspaceV2 } from "../workspace"
 
 
 export const WorkspaceTable = sqliteTable("workspace", {
 export const WorkspaceTable = sqliteTable("workspace", {
-  id: text().$type<WorkspaceV2.ID>().primaryKey(),
+  id: text().$type<Workspace.ID>().primaryKey(),
   type: text().notNull(),
   type: text().notNull(),
   name: text().notNull().default(""),
   name: text().notNull().default(""),
   branch: text(),
   branch: text(),

+ 6 - 1
packages/core/src/location-services.ts

@@ -16,6 +16,7 @@ import { Image } from "./image"
 import { LocationWatcher } from "./filesystem/location-watcher"
 import { LocationWatcher } from "./filesystem/location-watcher"
 import { Integration } from "./integration"
 import { Integration } from "./integration"
 import { Location } from "./location"
 import { Location } from "./location"
+import { WorkspaceEnvironment } from "./workspace/environment"
 import { LocationMutation } from "./location-mutation"
 import { LocationMutation } from "./location-mutation"
 import { LocationServiceMap } from "./location-service-map"
 import { LocationServiceMap } from "./location-service-map"
 import { MCP } from "./mcp/index"
 import { MCP } from "./mcp/index"
@@ -50,6 +51,7 @@ export { LocationServiceMap } from "./location-service-map"
 
 
 const locationServiceNodes = [
 const locationServiceNodes = [
   Location.node,
   Location.node,
+  WorkspaceEnvironment.node,
   Config.node,
   Config.node,
   AgentV2.node,
   AgentV2.node,
   CommandV2.node,
   CommandV2.node,
@@ -115,7 +117,10 @@ export function buildLocationServiceMap(
       LayerMap.make(
       LayerMap.make(
         (ref: Location.Ref) => {
         (ref: Location.Ref) => {
           const startedAt = performance.now()
           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
           // Apply replacements during hoist, not afterward: replacements can
           // introduce new tagged dependencies (Location.boundNode depends on
           // introduce new tagged dependencies (Location.boundNode depends on
           // Project), and the hoist walk is the only pass that can still slice
           // Project), and the hoist walk is the only pass that can still slice

+ 35 - 4
packages/core/src/location.ts

@@ -1,8 +1,10 @@
 import { Context, Effect, Layer } from "effect"
 import { Context, Effect, Layer } from "effect"
 import { Info, Ref, response } from "@opencode-ai/schema/location"
 import { Info, Ref, response } from "@opencode-ai/schema/location"
+import path from "path"
 import { Project } from "./project"
 import { Project } from "./project"
 import { LayerNode } from "./effect/layer-node"
 import { LayerNode } from "./effect/layer-node"
 import { makeLocationNode, tags } from "./effect/app-node"
 import { makeLocationNode, tags } from "./effect/app-node"
+import { WorkspaceV2 } from "./workspace"
 
 
 export * as Location from "./location"
 export * as Location from "./location"
 
 
@@ -16,7 +18,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
 
 
 export const node = LayerNode.unbound(Service, tags.values.location)
 export const node = LayerNode.unbound(Service, tags.values.location)
 
 
-const layer = (ref: Ref) =>
+const localLayer = (ref: Ref) =>
   Layer.effect(
   Layer.effect(
     Service,
     Service,
     Effect.gen(function* () {
     Effect.gen(function* () {
@@ -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,
     service: Service,
-    layer: layer(ref),
+    layer: localLayer(ref),
     deps: [Project.node],
     deps: [Project.node],
   })
   })
+}

+ 100 - 0
packages/core/src/workspace.ts

@@ -1,6 +1,106 @@
 export * as WorkspaceV2 from "./workspace"
 export * as WorkspaceV2 from "./workspace"
 
 
+import { Context, Effect, Equal, Exit, Layer, RcMap, Schema, Scope } from "effect"
 import { Workspace } from "@opencode-ai/schema/workspace"
 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 const ID = Workspace.ID
 export type ID = typeof ID.Type
 export type ID = typeof ID.Type
+
+export const Info = Workspace.Info
+export type Info = Workspace.Info
+
+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 get: (id: ID) => Effect.Effect<Info, NotFoundError | InvalidError>
+  readonly borrow: (
+    id: ID,
+  ) => Effect.Effect<
+    WorkspaceEnvironment.Interface,
+    NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError,
+    Scope.Scope
+  >
+}
+
+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 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 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 connections = yield* RcMap.make({
+      idleTimeToLive: "1 minute",
+      lookup: Effect.fn("Workspace.connect")(function* (id: ID) {
+        const placement = yield* row(id)
+        if (!Schema.is(Sandbox.Placement)(placement.extra)) {
+          return yield* new InvalidError({ id, message: "Workspace has no sandbox binding" })
+        }
+        const provider = yield* registry.get(placement.type)
+        const binding = yield* provider.decode(placement.extra.binding)
+        const connection = yield* provider.connect(binding)
+        yield* persistBinding(id, binding, connection.binding)
+        yield* persistBinding(id, connection.binding, yield* provider.reconcile(connection.binding))
+        return connection
+      }),
+    })
+
+    return Service.of({
+      get,
+      borrow: (id) =>
+        RcMap.get(connections, id).pipe(
+          Effect.onExit((exit) => (Exit.isFailure(exit) ? RcMap.invalidate(connections, id) : Effect.void)),
+          Effect.map((connection) => connection.environment),
+        ),
+    })
+  }),
+)
+
+export const node = makeGlobalNode({
+  service: Service,
+  layer,
+  deps: [Database.node, Sandbox.registryNode],
+})

+ 271 - 0
packages/core/src/workspace/environment.ts

@@ -0,0 +1,271 @@
+export * as WorkspaceEnvironment from "./environment"
+
+import { Context, Effect, FileSystem, Layer, Schema } from "effect"
+import { PlatformError, systemError } from "effect/PlatformError"
+import { ChildProcessSpawner, make } from "effect/unstable/process/ChildProcessSpawner"
+import { AppProcess } from "../process"
+import { makeLocationNode, tags } from "../effect/app-node"
+import { LayerNode } from "../effect/layer-node"
+import { FSUtil } from "../fs-util"
+import { KeyedMutex } from "../effect/keyed-mutex"
+import { Location } from "../location"
+import { RipgrepBinary } from "../ripgrep/binary"
+import { ShellSelect } from "../shell/select"
+import { WorkspaceV2 } from "../workspace"
+import path from "path"
+
+export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceEnvironment.Error", {
+  operation: Schema.String,
+  path: Schema.optional(Schema.String),
+  cause: Schema.optional(Schema.Defect()),
+}) {}
+
+export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()(
+  "WorkspaceEnvironment.StaleContentError",
+  { path: Schema.String },
+) {}
+
+export interface FileInfo {
+  readonly type: FileSystem.File.Type
+}
+
+export interface ResolvedPath extends FileInfo {
+  readonly canonical: string
+  readonly directory: string
+}
+
+export interface DirectoryEntry {
+  readonly name: string
+  readonly type: "file" | "directory" | "symlink" | "other"
+}
+
+export interface FileBackend {
+  readonly inspect: (path: string) => Effect.Effect<FileInfo, Error>
+  readonly resolve: (path: string) => Effect.Effect<ResolvedPath, Error>
+  readonly read: (path: string) => Effect.Effect<Uint8Array, Error>
+  readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], Error>
+  readonly ensureDirectory: (path: string) => Effect.Effect<void, Error>
+  readonly createExclusive: (path: string, content: Uint8Array) => Effect.Effect<void, Error>
+  readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, Error>
+  readonly writeIfUnchanged: (
+    path: string,
+    expected: Uint8Array,
+    content: Uint8Array,
+  ) => Effect.Effect<void, Error | StaleContentError>
+  readonly remove: (path: string) => Effect.Effect<void, Error>
+}
+
+export interface Shell {
+  readonly executable: string
+  readonly args: (command: string) => readonly string[]
+  readonly environmentOverrides: Readonly<Record<string, string>>
+  readonly detached: boolean
+}
+
+export interface Interface {
+  readonly platform: NodeJS.Platform
+  readonly directory: string
+  readonly files: FileBackend
+  readonly process: ChildProcessSpawner["Service"]
+  readonly shell: Shell
+  readonly ripgrep: Effect.Effect<string, Error>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/WorkspaceEnvironment") {}
+
+export const node = LayerNode.unbound(Service, tags.values.location)
+
+const wrap = <A>(operation: string, path: string | undefined, effect: Effect.Effect<A, unknown>) =>
+  effect.pipe(Effect.mapError((cause) => new Error({ operation, path, cause })))
+
+const sameBytes = (left: Uint8Array, right: Uint8Array) =>
+  left.length === right.length && left.every((byte, index) => byte === right[index])
+
+const local = Effect.fnUntraced(function* (directory: string) {
+  const fs = yield* FSUtil.Service
+  const proc = yield* AppProcess.Service
+  const ripgrep = yield* RipgrepBinary.Service
+  const locks = KeyedMutex.makeUnsafe<string>()
+  const mutate = (path: string, effect: Effect.Effect<void, unknown>) =>
+    locks.withLock(path)(Effect.uninterruptible(effect))
+  const notFound = <A>(effect: Effect.Effect<A, FSUtil.Error>) =>
+    effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
+  const resolve = Effect.fn("WorkspaceEnvironment.resolve")(function* (absolute: string) {
+    const existing = yield* notFound(fs.realPath(absolute))
+    if (existing) {
+      const info = yield* fs.stat(existing)
+      return {
+        canonical: existing,
+        directory: info.type === "Directory" ? existing : path.dirname(existing),
+        type: info.type,
+      }
+    }
+
+    let anchor = path.dirname(absolute)
+    while (true) {
+      const canonical = yield* notFound(fs.realPath(anchor))
+      if (canonical) {
+        const info = yield* fs.stat(canonical)
+        if (info.type !== "Directory") {
+          return yield* new Error({ operation: "resolve", path: absolute, cause: "Non-directory ancestor" })
+        }
+        return {
+          canonical: path.resolve(canonical, path.relative(anchor, absolute)),
+          directory: canonical,
+          type: "Unknown" as const,
+        }
+      }
+      const parent = path.dirname(anchor)
+      if (parent === anchor) return yield* new Error({ operation: "resolve", path: absolute, cause: "No ancestor" })
+      anchor = parent
+    }
+  })
+  const files: FileBackend = {
+    inspect: (path) => wrap("inspect", path, fs.stat(path)),
+    resolve: (path) =>
+      resolve(path).pipe(
+        Effect.mapError((cause) => (cause instanceof Error ? cause : new Error({ operation: "resolve", path, cause }))),
+      ),
+    read: (path) => wrap("read", path, fs.readFile(path)),
+    list: (path) => wrap("list", path, fs.readDirectoryEntries(path)),
+    ensureDirectory: (path) => wrap("ensureDirectory", path, fs.ensureDir(path)),
+    createExclusive: (path, content) =>
+      wrap("createExclusive", path, mutate(path, fs.writeFile(path, content, { flag: "wx" }))),
+    write: (path, content) => wrap("write", path, mutate(path, fs.writeFile(path, content))),
+    writeIfUnchanged: (path, expected, content) =>
+      mutate(
+        path,
+        Effect.gen(function* () {
+          const current = yield* fs.readFile(path)
+          if (!sameBytes(current, expected)) return yield* new StaleContentError({ path })
+          yield* fs.writeFile(path, content)
+        }),
+      ).pipe(
+        Effect.mapError((cause) =>
+          cause instanceof StaleContentError ? cause : new Error({ operation: "writeIfUnchanged", path, cause }),
+        ),
+      ),
+    remove: (path) => wrap("remove", path, mutate(path, fs.remove(path))),
+  }
+  const executable = ShellSelect.preferred() ?? "/bin/sh"
+  return Service.of({
+    platform: process.platform,
+    directory,
+    files,
+    process: proc,
+    shell: {
+      executable,
+      args: (command) => ShellSelect.args(executable, command),
+      environmentOverrides: {
+        TERM: "xterm-256color",
+        OPENCODE_TERMINAL: "1",
+      },
+      detached: process.platform !== "win32",
+    },
+    ripgrep: ripgrep.filepath.pipe(Effect.mapError((cause) => new Error({ operation: "ripgrep", cause }))),
+  })
+})
+
+const borrow = <A, E>(
+  workspace: WorkspaceV2.Interface,
+  id: WorkspaceV2.ID,
+  use: (environment: Interface) => Effect.Effect<A, E>,
+) =>
+  Effect.scoped(
+    workspace.borrow(id).pipe(
+      Effect.mapError((cause) => new Error({ operation: "connect", cause })),
+      Effect.flatMap(use),
+    ),
+  )
+
+const contains = (root: string, target: string) => {
+  const relative = path.posix.relative(root, target)
+  return relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative)
+}
+
+const localLayer = (ref: Location.Ref) => Layer.effect(Service, local(ref.directory))
+
+const hostedLayer = (ref: Location.Ref & { readonly workspaceID: WorkspaceV2.ID }) =>
+  Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const location = yield* Location.Service
+      const workspace = yield* WorkspaceV2.Service
+      const id = ref.workspaceID
+      const useFile = <A, E>(
+        target: string,
+        use: (files: FileBackend, resolved: ResolvedPath) => Effect.Effect<A, E>,
+      ) =>
+        borrow(workspace, id, (environment) =>
+          Effect.gen(function* () {
+            const absolute = path.posix.resolve(location.directory, target)
+            const [root, resolved] = yield* Effect.all([
+              environment.files.resolve(location.project.directory),
+              environment.files.resolve(absolute),
+            ])
+            if (!contains(root.canonical, resolved.canonical)) {
+              return yield* new Error({ operation: "containment", path: target })
+            }
+            return yield* use(environment.files, resolved)
+          }),
+        )
+      const files: FileBackend = {
+        inspect: (path) => useFile(path, (files, resolved) => files.inspect(resolved.canonical)),
+        resolve: (path) => useFile(path, (_files, resolved) => Effect.succeed(resolved)),
+        read: (path) => useFile(path, (files, resolved) => files.read(resolved.canonical)),
+        list: (path) => useFile(path, (files, resolved) => files.list(resolved.canonical)),
+        ensureDirectory: (path) => useFile(path, (files, resolved) => files.ensureDirectory(resolved.canonical)),
+        createExclusive: (path, content) =>
+          useFile(path, (files, resolved) => files.createExclusive(resolved.canonical, content)),
+        write: (path, content) => useFile(path, (files, resolved) => files.write(resolved.canonical, content)),
+        writeIfUnchanged: (path, expected, content) =>
+          useFile(path, (files, resolved) => files.writeIfUnchanged(resolved.canonical, expected, content)),
+        remove: (path) => useFile(path, (files, resolved) => files.remove(resolved.canonical)),
+      }
+      return Service.of({
+        platform: "linux",
+        directory: location.directory,
+        files,
+        process: make((command) =>
+          workspace.borrow(id).pipe(
+            Effect.flatMap((environment) => environment.process.spawn(command)),
+            Effect.mapError((cause) =>
+              cause instanceof PlatformError
+                ? cause
+                : systemError({
+                    _tag: "Unknown",
+                    module: "WorkspaceEnvironment",
+                    method: "spawn",
+                    cause,
+                  }),
+            ),
+          ),
+        ),
+        shell: {
+          executable: "/bin/sh",
+          args: (command) => ["-c", command],
+          environmentOverrides: {
+            TERM: "xterm-256color",
+            OPENCODE_TERMINAL: "1",
+          },
+          detached: false,
+        },
+        ripgrep: borrow(workspace, id, (environment) => environment.ripgrep),
+      })
+    }),
+  )
+
+export const boundNode = (ref: Location.Ref) => {
+  if (ref.workspaceID) {
+    return makeLocationNode({
+      service: Service,
+      layer: hostedLayer({ ...ref, workspaceID: ref.workspaceID }),
+      deps: [Location.node, WorkspaceV2.node],
+    })
+  }
+  return makeLocationNode({
+    service: Service,
+    layer: localLayer(ref),
+    deps: [FSUtil.node, AppProcess.node, RipgrepBinary.node],
+  })
+}

+ 70 - 0
packages/core/src/workspace/sandbox.ts

@@ -0,0 +1,70 @@
+export * as Sandbox from "./sandbox"
+
+import { Context, Effect, Layer, Schema, Scope } from "effect"
+import { makeGlobalNode } from "../effect/app-node"
+import type { WorkspaceEnvironment } from "./environment"
+
+export const Binding = Schema.Record(Schema.String, Schema.Json).annotate({ identifier: "Sandbox.Binding" })
+export type Binding = typeof Binding.Type
+
+export const Placement = Schema.Struct({
+  kind: Schema.Literal("sandbox"),
+  version: Schema.Literal(1),
+  binding: Binding,
+}).annotate({ identifier: "Sandbox.Placement" })
+export type Placement = typeof Placement.Type
+
+export class Error extends Schema.TaggedErrorClass<Error>()("Sandbox.Error", {
+  provider: Schema.String,
+  operation: Schema.String,
+  cause: Schema.optional(Schema.Defect()),
+}) {}
+
+export interface Connection {
+  readonly binding: Binding
+  readonly environment: WorkspaceEnvironment.Interface
+}
+
+export interface Provider {
+  readonly key: string
+  readonly decode: (binding: Binding) => Effect.Effect<Binding, Error>
+  readonly connect: (binding: Binding) => Effect.Effect<Connection, Error, Scope.Scope>
+  readonly reconcile: (binding: Binding) => Effect.Effect<Binding, Error>
+}
+
+export class DuplicateProviderError extends Schema.TaggedErrorClass<DuplicateProviderError>()(
+  "Sandbox.DuplicateProviderError",
+  { provider: Schema.String },
+) {}
+
+export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
+  "Sandbox.ProviderNotFoundError",
+  { provider: Schema.String },
+) {}
+
+export interface Registry {
+  readonly register: (provider: Provider) => Effect.Effect<void, DuplicateProviderError, Scope.Scope>
+  readonly get: (key: string) => Effect.Effect<Provider, ProviderNotFoundError>
+}
+
+export class RegistryService extends Context.Service<RegistryService, Registry>()("@opencode/SandboxRegistry") {}
+
+export const registryLayer = Layer.sync(RegistryService, () => {
+  const providers = new Map<string, Provider>()
+  return RegistryService.of({
+    register: (provider) =>
+      Effect.acquireRelease(
+        Effect.sync(() => {
+          if (providers.has(provider.key)) return new DuplicateProviderError({ provider: provider.key })
+          providers.set(provider.key, provider)
+        }).pipe(Effect.flatMap((error) => (error ? Effect.fail(error) : Effect.void))),
+        () => Effect.sync(() => providers.delete(provider.key)),
+      ),
+    get: (key) => {
+      const provider = providers.get(key)
+      return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ provider: key }))
+    },
+  })
+})
+
+export const registryNode = makeGlobalNode({ service: RegistryService, layer: registryLayer, deps: [] })

+ 122 - 3
packages/core/test/location.test.ts

@@ -1,14 +1,20 @@
 import { describe, expect } from "bun:test"
 import { describe, expect } from "bun:test"
-import { Effect, Layer } from "effect"
+import fs from "fs/promises"
+import path from "path"
+import { Effect, Exit, Layer } from "effect"
+import { make } from "effect/unstable/process/ChildProcessSpawner"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Location } from "@opencode-ai/core/location"
 import { Location } from "@opencode-ai/core/location"
 import { Project } from "@opencode-ai/core/project"
 import { Project } from "@opencode-ai/core/project"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { WorkspaceV2 } from "@opencode-ai/core/workspace"
 import { WorkspaceV2 } from "@opencode-ai/core/workspace"
+import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
+import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
 import { testEffect } from "./lib/effect"
 
 
 const workspaceID = WorkspaceV2.ID.make("wrk_test")
 const workspaceID = WorkspaceV2.ID.make("wrk_test")
-const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID }
+const ref = { directory: AbsolutePath.make("/repo/packages/app") }
 const projectLayer = Layer.succeed(
 const projectLayer = Layer.succeed(
   Project.Service,
   Project.Service,
   Project.Service.of({
   Project.Service.of({
@@ -31,7 +37,7 @@ describe("Location", () => {
       const location = yield* Location.Service
       const location = yield* Location.Service
 
 
       expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
       expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
-      expect(location.workspaceID).toBe(workspaceID)
+      expect(location.workspaceID).toBeUndefined()
       expect(location.project.id).toBe(Project.ID.make("project"))
       expect(location.project.id).toBe(Project.ID.make("project"))
       expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
       expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
       expect(location.vcs).toEqual({
       expect(location.vcs).toEqual({
@@ -40,4 +46,117 @@ describe("Location", () => {
       })
       })
     }),
     }),
   )
   )
+
+  it.live("resolves hosted metadata without reading the host path", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) => {
+        const directory = AbsolutePath.make(path.join(tmp.path, "hosted-checkout"))
+        const connections = { count: 0 }
+        const reads = { count: 0 }
+        const unsupported = () => Effect.die("Unsupported fake environment operation")
+        const providerEnvironment = WorkspaceEnvironment.Service.of({
+          platform: "linux",
+          directory,
+          process: make(() => unsupported()),
+          shell: {
+            executable: "/bin/sh",
+            args: (command) => ["-c", command],
+            environmentOverrides: {},
+            detached: false,
+          },
+          ripgrep: Effect.succeed("/usr/bin/rg"),
+          files: {
+            resolve: (target) =>
+              Effect.succeed({
+                canonical: target.includes("symlink") ? "/outside/secret" : target,
+                directory: path.posix.dirname(target),
+                type: "File",
+              }),
+            inspect: unsupported,
+            read: () =>
+              Effect.sync(() => {
+                reads.count++
+                return new Uint8Array([1])
+              }),
+            list: unsupported,
+            ensureDirectory: unsupported,
+            createExclusive: unsupported,
+            write: unsupported,
+            writeIfUnchanged: unsupported,
+            remove: unsupported,
+          },
+        })
+        const workspaceLayer = Layer.succeed(
+          WorkspaceV2.Service,
+          WorkspaceV2.Service.of({
+            get: () =>
+              Effect.succeed(
+                WorkspaceV2.Info.make({
+                  id: workspaceID,
+                  name: "Hosted",
+                  directory,
+                  project: {
+                    id: Project.ID.make("hosted-project"),
+                    directory,
+                  },
+                }),
+              ),
+            borrow: () =>
+              Effect.sync(() => {
+                connections.count++
+                return providerEnvironment
+              }),
+          }),
+        )
+        const hostedRef = { directory, workspaceID }
+        const layer = AppNodeBuilder.build(LayerNode.group([Location.node, WorkspaceEnvironment.node]), [
+          [Location.node, Location.boundNode(hostedRef)],
+          [WorkspaceEnvironment.node, WorkspaceEnvironment.boundNode(hostedRef)],
+          [WorkspaceV2.node, workspaceLayer],
+        ])
+        const invalidLayer = AppNodeBuilder.build(
+          Location.boundNode({ directory: AbsolutePath.make(path.join(tmp.path, "outside")), workspaceID }),
+          [[WorkspaceV2.node, workspaceLayer]],
+        )
+        return Effect.gen(function* () {
+          expect(
+            yield* Effect.promise(() =>
+              fs.stat(directory).then(
+                () => true,
+                () => false,
+              ),
+            ),
+          ).toBe(false)
+
+          const location = yield* Location.Service
+          const environment = yield* WorkspaceEnvironment.Service
+          expect(location.directory).toBe(directory)
+          expect(location.workspaceID).toBe(workspaceID)
+          expect(location.project).toEqual({
+            id: Project.ID.make("hosted-project"),
+            directory,
+          })
+          expect(environment.directory).toBe(directory)
+          expect(environment.platform).toBe("linux")
+          expect(connections.count).toBe(0)
+
+          expect(yield* environment.files.read(path.posix.join(directory, "file.txt"))).toEqual(new Uint8Array([1]))
+          expect(connections.count).toBe(1)
+          expect(reads.count).toBe(1)
+
+          const outsideFile = yield* environment.files.read("/outside/secret").pipe(Effect.flip)
+          expect(outsideFile.operation).toBe("containment")
+          const symlink = yield* environment.files.read(path.posix.join(directory, "symlink")).pipe(Effect.flip)
+          expect(symlink.operation).toBe("containment")
+          expect(reads.count).toBe(1)
+
+          const invalid = yield* Location.Service.pipe(Effect.provide(invalidLayer), Effect.exit)
+          expect(Exit.isFailure(invalid)).toBe(true)
+        }).pipe(Effect.provide(layer))
+      }),
+    ),
+  )
 })
 })

+ 171 - 0
packages/core/test/workspace.test.ts

@@ -0,0 +1,171 @@
+import { describe, expect } from "bun:test"
+import { Effect, Exit } from "effect"
+import { adjust } from "effect/testing/TestClock"
+import { eq } from "drizzle-orm"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { Database } from "@opencode-ai/core/database/database"
+import { AppProcess } from "@opencode-ai/core/process"
+import { Project } from "@opencode-ai/core/project"
+import { ProjectTable } from "@opencode-ai/core/project/sql"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { WorkspaceV2 } from "@opencode-ai/core/workspace"
+import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
+import { Sandbox } from "@opencode-ai/core/workspace/sandbox"
+import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
+import { testEffect } from "./lib/effect"
+
+const it = testEffect(
+  AppNodeBuilder.build(LayerNode.group([Database.node, Sandbox.registryNode, WorkspaceV2.node, AppProcess.node])),
+)
+
+describe("WorkspaceV2", () => {
+  it.effect("loads metadata without connecting and shares a scoped connection", () =>
+    Effect.gen(function* () {
+      const db = (yield* Database.Service).db
+      const process = yield* AppProcess.Service
+      const registry = yield* Sandbox.RegistryService
+      const workspace = yield* WorkspaceV2.Service
+      const id = WorkspaceV2.ID.make("wrk_hosted")
+      const projectID = Project.ID.make("hosted-project")
+      const directory = AbsolutePath.make("/workspace/repo")
+      const lifecycle = { connected: 0, reconciled: 0, released: 0 }
+      const unsupported = (operation: string) => Effect.fail(new WorkspaceEnvironment.Error({ operation }))
+      const environment = WorkspaceEnvironment.Service.of({
+        platform: "linux",
+        directory,
+        process,
+        shell: {
+          executable: "/bin/sh",
+          args: (command) => ["-c", command],
+          environmentOverrides: {},
+          detached: false,
+        },
+        ripgrep: Effect.succeed("/usr/bin/rg"),
+        files: {
+          inspect: () => unsupported("inspect"),
+          resolve: () => unsupported("resolve"),
+          read: () => unsupported("read"),
+          list: () => unsupported("list"),
+          ensureDirectory: () => unsupported("ensureDirectory"),
+          createExclusive: () => unsupported("createExclusive"),
+          write: () => unsupported("write"),
+          writeIfUnchanged: () => unsupported("writeIfUnchanged"),
+          remove: () => unsupported("remove"),
+        },
+      })
+
+      yield* db
+        .insert(ProjectTable)
+        .values({
+          id: projectID,
+          worktree: directory,
+          sandboxes: [],
+          time_created: 1,
+          time_updated: 1,
+        })
+        .run()
+      yield* db
+        .insert(WorkspaceTable)
+        .values({
+          id,
+          type: "fake",
+          name: "Hosted",
+          directory,
+          extra: { kind: "sandbox", version: 1, binding: { sandbox: "one" } },
+          project_id: projectID,
+          time_used: 1,
+        })
+        .run()
+      yield* registry.register({
+        key: "fake",
+        decode: Effect.succeed,
+        connect: () =>
+          Effect.acquireRelease(
+            Effect.sync(() => {
+              lifecycle.connected++
+              return { binding: { sandbox: "live", retired: "one" }, environment }
+            }),
+            () => Effect.sync(() => lifecycle.released++),
+          ),
+        reconcile: () =>
+          Effect.sync(() => {
+            lifecycle.reconciled++
+            return { sandbox: "live" }
+          }),
+      })
+
+      expect(yield* workspace.get(id)).toEqual({
+        id,
+        name: "Hosted",
+        directory,
+        project: { id: projectID, directory },
+      })
+      expect(lifecycle.connected).toBe(0)
+
+      const borrowed = yield* Effect.all([workspace.borrow(id), workspace.borrow(id)]).pipe(Effect.scoped)
+      expect(borrowed[0]).toBe(environment)
+      expect(borrowed[1]).toBe(environment)
+      expect(lifecycle.connected).toBe(1)
+      expect(lifecycle.reconciled).toBe(1)
+      expect(lifecycle.released).toBe(0)
+      const placement = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()
+      expect(placement?.extra).toEqual({
+        kind: "sandbox",
+        version: 1,
+        binding: { sandbox: "live" },
+      })
+
+      yield* adjust("1 minute")
+      yield* Effect.yieldNow
+      expect(lifecycle.released).toBe(1)
+
+      const invalidID = WorkspaceV2.ID.make("wrk_invalid")
+      yield* db
+        .insert(WorkspaceTable)
+        .values({
+          id: invalidID,
+          type: "fake",
+          name: "Legacy",
+          directory,
+          extra: { sandbox: "legacy-adapter-state" },
+          project_id: projectID,
+          time_used: 1,
+        })
+        .run()
+      const invalid = yield* workspace.borrow(invalidID).pipe(Effect.scoped, Effect.flip)
+      expect(invalid._tag).toBe("Workspace.InvalidError")
+      expect(lifecycle.connected).toBe(1)
+
+      const retryID = WorkspaceV2.ID.make("wrk_retry")
+      const retry = { attempts: 0 }
+      yield* db
+        .insert(WorkspaceTable)
+        .values({
+          id: retryID,
+          type: "flaky",
+          name: "Retry",
+          directory,
+          extra: { kind: "sandbox", version: 1, binding: { sandbox: "retry" } },
+          project_id: projectID,
+          time_used: 1,
+        })
+        .run()
+      yield* registry.register({
+        key: "flaky",
+        decode: Effect.succeed,
+        connect: (binding) =>
+          Effect.sync(() => ++retry.attempts).pipe(
+            Effect.flatMap((attempt) =>
+              attempt === 1 ? Effect.die("Transient provider defect") : Effect.succeed({ binding, environment }),
+            ),
+          ),
+        reconcile: Effect.succeed,
+      })
+
+      expect(Exit.isFailure(yield* workspace.borrow(retryID).pipe(Effect.scoped, Effect.exit))).toBe(true)
+      expect(yield* workspace.borrow(retryID).pipe(Effect.scoped)).toBe(environment)
+      expect(retry.attempts).toBe(2)
+    }),
+  )
+})

+ 11 - 0
packages/schema/src/workspace.ts

@@ -2,8 +2,19 @@ export * as Workspace from "./workspace.js"
 
 
 import { WorkspaceEvent } from "./workspace-event.js"
 import { WorkspaceEvent } from "./workspace-event.js"
 import { WorkspaceID } from "./workspace-id.js"
 import { WorkspaceID } from "./workspace-id.js"
+import { Project } from "./project.js"
+import { AbsolutePath } from "./schema.js"
+import { Schema } from "effect"
 
 
 export const ID = WorkspaceID
 export const ID = WorkspaceID
 export type ID = WorkspaceID
 export type ID = WorkspaceID
 
 
+export const Info = Schema.Struct({
+  id: ID,
+  name: Schema.String,
+  directory: AbsolutePath,
+  project: Project.Current,
+}).annotate({ identifier: "Workspace.Info" })
+export interface Info extends Schema.Schema.Type<typeof Info> {}
+
 export const Event = WorkspaceEvent
 export const Event = WorkspaceEvent

+ 17 - 14
specs/v2/remote-workspace-execution.md

@@ -149,7 +149,7 @@ existing Core policy services:
 ```ts
 ```ts
 interface WorkspaceFileBackend {
 interface WorkspaceFileBackend {
   readonly inspect: (path: string) => Effect.Effect<FileInfo, FileError>
   readonly inspect: (path: string) => Effect.Effect<FileInfo, FileError>
-  readonly realPath: (path: string) => Effect.Effect<string, FileError>
+  readonly resolve: (path: string) => Effect.Effect<ResolvedPath, FileError>
   readonly read: (path: string) => Effect.Effect<Uint8Array, FileError>
   readonly read: (path: string) => Effect.Effect<Uint8Array, FileError>
   readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], FileError>
   readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], FileError>
   readonly ensureDirectory: (path: string) => Effect.Effect<void, FileError>
   readonly ensureDirectory: (path: string) => Effect.Effect<void, FileError>
@@ -168,9 +168,9 @@ interface WorkspaceFileBackend {
 not emulate it with an unlocked client-side read followed by write.
 not emulate it with an unlocked client-side read followed by write.
 `FileMutation` remains the owner of create/write/remove semantics, stale-edit
 `FileMutation` remains the owner of create/write/remove semantics, stale-edit
 errors, parent-directory creation, result metadata, BOM handling, and
 errors, parent-directory creation, result metadata, BOM handling, and
-OpenCode-side mutation ordering. `LocationMutation` retains symlink-safe path
-containment by composing `realPath` and `inspect`, including its nearest
-existing ancestor resolution for new targets.
+OpenCode-side mutation ordering. `resolve` returns a provider-canonical path
+for an existing target or through the nearest existing ancestor for a new
+target. `LocationMutation` uses it to retain symlink-safe containment.
 
 
 Provider process transports adapt native command handles to Effect's scoped
 Provider process transports adapt native command handles to Effect's scoped
 `ChildProcessSpawner` contract. Scope finalization interrupts an unfinished
 `ChildProcessSpawner` contract. Scope finalization interrupts an unfinished
@@ -265,28 +265,31 @@ or when detached process reconnection and richer runtime semantics justify it.
 
 
 ## Implementation Sequence
 ## Implementation Sequence
 
 
-The first integration PR is a narrow vertical slice across stages 1 through 4:
+The first integration PR establishes the narrow seam needed by stages 1
+through 4:
 
 
-- add the provider-neutral file, process, environment, and lifecycle contracts;
+- add provider-neutral file, process, environment, connection, and binding
+  reconciliation contracts;
 - add the local environment and a test-only fake hosted provider;
 - add the local environment and a test-only fake hosted provider;
-- replace the experimental Workspace storage shape with public metadata plus
-  private placement state;
-- add the Workspace connection manager and Location-scoped
+- add browser-safe Workspace metadata while keeping provider placement private
+  in the existing experimental row;
+- add a scoped, idle-expiring Workspace connection cache and Location-scoped
   `WorkspaceEnvironment.Service`;
   `WorkspaceEnvironment.Service`;
 - make `Location.boundNode` resolve hosted project/root metadata without
 - make `Location.boundNode` resolve hosted project/root metadata without
   `Project.resolve` or any host-path access; and
   `Project.resolve` or any host-path access; and
 - prove lazy connection and scoped release using a hosted directory that does
 - prove lazy connection and scoped release using a hosted directory that does
   not exist on the test host.
   not exist on the test host.
 
 
-It does not add Vercel or migrate every tool. Its purpose is to establish the
-placement seam and prevent cloud-provider details from shaping later Core
-changes.
+It does not add Vercel, lifecycle mutation, or migrate existing tools. Its
+purpose is to establish the placement seam and prevent cloud-provider details
+from shaping later Core changes. The lifecycle gate and create, suspend, and
+remove orchestration land with stage 3 rather than as unused first-PR methods.
 
 
 ### 1. Define Behavioral Contracts
 ### 1. Define Behavioral Contracts
 
 
 - Add internal Sandbox provider, binding, file-backend, process-transport, and
 - Add internal Sandbox provider, binding, file-backend, process-transport, and
   environment services in Core.
   environment services in Core.
-- Include provider-side real-path and directory primitives plus explicit
+- Include provider-side canonical-path and directory primitives plus explicit
   Workspace shell/environment policy in the contract.
   Workspace shell/environment policy in the contract.
 - Keep provider registration process-global and provider implementations out
 - Keep provider registration process-global and provider implementations out
   of tool modules.
   of tool modules.
@@ -364,7 +367,7 @@ requires a host checkout.
   Node filesystem use.
   Node filesystem use.
 - Adapt `FileMutation` to the Workspace file backend while preserving its
 - Adapt `FileMutation` to the Workspace file backend while preserving its
   current policy and result surface.
   current policy and result surface.
-- Adapt `LocationMutation` to provider-side `realPath` and `inspect` so existing
+- Adapt `LocationMutation` to provider-side `resolve` and `inspect` so existing
   targets, missing-target ancestors, and symlink escapes retain current
   targets, missing-target ancestors, and symlink escapes retain current
   containment behavior.
   containment behavior.
 - Adapt hosted process transport once to `ChildProcessSpawner`, then reuse
 - Adapt hosted process transport once to `ChildProcessSpawner`, then reuse