feat(core): add remote workspace environment seam

This commit is contained in:
Kit Langton 2026-07-17 00:04:13 -04:00
commit fd92aeac66
10 changed files with 805 additions and 24 deletions

View file

@ -1,14 +1,20 @@
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 { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
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"
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(
Project.Service,
Project.Service.of({
@ -31,7 +37,7 @@ describe("Location", () => {
const location = yield* Location.Service
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.directory).toBe(AbsolutePath.make("/repo"))
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))
}),
),
)
})

View file

@ -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)
}),
)
})