feat(core): allow external workspace creation (#26212)

This commit is contained in:
James Long
2026-05-08 11:09:12 -04:00
committed by GitHub
parent c36ab3f935
commit c818c9dcb6
21 changed files with 2039 additions and 145 deletions
@@ -17,7 +17,7 @@ import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdapter } from "./adapters"
import { getAdapter, registeredAdapters } from "./adapters"
import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { Session } from "@/session/session"
@@ -35,8 +35,13 @@ import { Vcs } from "@/project/vcs"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
export const Info = WorkspaceInfoSchema
export type Info = WorkspaceInfo
export const Info = Schema.Struct({
...WorkspaceInfoSchema.fields,
timeUsed: Schema.Number,
})
.annotate({ identifier: "Workspace" })
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
export type Info = WorkspaceInfo & { timeUsed: number }
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
@@ -69,6 +74,7 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
directory: row.directory,
extra: row.extra,
projectID: row.project_id,
timeUsed: row.time_used,
}
}
@@ -150,6 +156,7 @@ export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
readonly syncList: (project: Project.Info) => Effect.Effect<void>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly status: () => Effect.Effect<ConnectionStatus[]>
@@ -483,7 +490,19 @@ export const layer = Layer.effect(
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(space.id, "error")
log.warn("workspace target failed", {
workspaceID: space.id,
error: errorData(error),
})
return null
}),
),
)
if (!target) return
if (target.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
@@ -523,7 +542,13 @@ export const layer = Layer.effect(
const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* EffectBridge.fromPromise(() =>
adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }),
adapter.configure({
...input,
id,
name: Slug.create(),
directory: null,
extra: input.extra ?? null,
}),
)
const info: Info = {
@@ -534,6 +559,7 @@ export const layer = Layer.effect(
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
timeUsed: Date.now(),
}
yield* db((db) => {
@@ -546,6 +572,7 @@ export const layer = Layer.effect(
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run()
})
@@ -828,6 +855,63 @@ export const layer = Layer.effect(
)
})
const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) {
const names = new Set((yield* list(project)).map((workspace) => workspace.name))
const discovered = yield* Effect.forEach(
registeredAdapters(project.id),
([type, adapter]) =>
adapter.list
? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe(
Effect.catchCause((error) =>
Effect.sync(() => {
log.warn("workspace adapter list failed", { type, error })
return []
}),
),
)
: Effect.succeed([]),
{ concurrency: "unbounded" },
).pipe(Effect.map((items) => items.flat()))
yield* Effect.forEach(
discovered,
(item) =>
Effect.gen(function* () {
if (names.has(item.name)) return
names.add(item.name)
const info: Info = {
id: WorkspaceID.ascending(),
type: item.type,
branch: item.branch,
name: item.name,
directory: item.directory,
extra: item.extra,
projectID: item.projectID,
timeUsed: Date.now(),
}
yield* db((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run()
})
yield* startSync(info)
}),
{ concurrency: 1 },
)
})
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) {
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
@@ -916,13 +1000,10 @@ export const layer = Layer.effect(
})
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) {
// This session table join makes this query only return
// workspaces that have sessions
const rows = yield* db((db) =>
db
.selectDistinct({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
.where(eq(WorkspaceTable.project_id, projectID))
.all(),
)
@@ -947,6 +1028,7 @@ export const layer = Layer.effect(
create,
sessionWarp,
list,
syncList,
get,
remove,
status,