feat(core): expose workspace adaptors to plugins (#21927)

This commit is contained in:
James Long
2026-04-13 13:33:13 -04:00
committed by GitHub
parent b8801dbd22
commit bf50d1c028
17 changed files with 1745 additions and 43 deletions

View File

@@ -1,20 +1,52 @@
import { lazy } from "@/util/lazy"
import type { Adaptor } from "../types"
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor } from "../types"
const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
export type WorkspaceAdaptorEntry = {
type: string
name: string
description: string
}
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
}
export function getAdaptor(type: string): Promise<Adaptor> {
return ADAPTORS[type]()
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin()
throw new Error(`Unknown workspace adaptor: ${type}`)
}
export function installAdaptor(type: string, adaptor: Adaptor) {
// This is experimental: mostly used for testing right now, but we
// will likely allow this in the future. Need to figure out the
// TypeScript story
// @ts-expect-error we force the builtin types right now, but we
// will implement a way to extend the types for custom adaptors
ADAPTORS[type] = () => adaptor
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, init]) => {
const adaptor = await init()
return {
type,
name: adaptor.name,
description: adaptor.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
type,
name: adaptor.name,
description: adaptor.description,
}))
return [...builtin, ...custom]
}
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
adaptors.set(type, adaptor)
state.set(projectID, adaptors)
}

View File

@@ -1,18 +1,18 @@
import z from "zod"
import { Worktree } from "@/worktree"
import { type Adaptor, WorkspaceInfo } from "../types"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
const Config = WorkspaceInfo.extend({
name: WorkspaceInfo.shape.name.unwrap(),
const WorktreeConfig = z.object({
name: WorkspaceInfo.shape.name,
branch: WorkspaceInfo.shape.branch.unwrap(),
directory: WorkspaceInfo.shape.directory.unwrap(),
})
type Config = z.infer<typeof Config>
export const WorktreeAdaptor: Adaptor = {
export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
const worktree = await Worktree.makeWorktreeInfo(undefined)
return {
...info,
name: worktree.name,
@@ -21,7 +21,7 @@ export const WorktreeAdaptor: Adaptor = {
}
},
async create(info) {
const config = Config.parse(info)
const config = WorktreeConfig.parse(info)
await Worktree.createFromInfo({
name: config.name,
directory: config.directory,
@@ -29,11 +29,11 @@ export const WorktreeAdaptor: Adaptor = {
})
},
async remove(info) {
const config = Config.parse(info)
const config = WorktreeConfig.parse(info)
await Worktree.remove({ directory: config.directory })
},
target(info) {
const config = Config.parse(info)
const config = WorktreeConfig.parse(info)
return {
type: "local",
directory: config.directory,

View File

@@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema"
export const WorkspaceInfo = z.object({
id: WorkspaceID.zod,
type: z.string(),
name: z.string(),
branch: z.string().nullable(),
name: z.string().nullable(),
directory: z.string().nullable(),
extra: z.unknown().nullable(),
projectID: ProjectID.zod,
@@ -24,9 +24,11 @@ export type Target =
headers?: HeadersInit
}
export type Adaptor = {
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(config: WorkspaceInfo): Promise<void>
target(config: WorkspaceInfo): Target | Promise<Target>
export type WorkspaceAdaptor = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
}

View File

@@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
name: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()

View File

@@ -9,6 +9,7 @@ import { SyncEvent } from "@/sync"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
@@ -66,9 +67,9 @@ export namespace Workspace {
export const create = fn(CreateInput, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.type)
const adaptor = await getAdaptor(input.projectID, input.type)
const config = await adaptor.configure({ ...input, id, name: null, directory: null })
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
const info: Info = {
id,
@@ -124,7 +125,7 @@ export namespace Workspace {
stopSync(id)
const info = fromRow(row)
const adaptor = await getAdaptor(row.type)
const adaptor = await getAdaptor(info.projectID, row.type)
adaptor.remove(info)
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
@@ -162,7 +163,7 @@ export namespace Workspace {
log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.type)
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return