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

View File

@@ -107,6 +107,7 @@ export function DialogSessionList() {
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warp(selection)

View File

@@ -36,21 +36,14 @@ export type WorkspaceSelection =
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
type ExistingWorkspaceSelectValue = { workspace: Workspace }
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(input: {
sessions: readonly { workspaceID?: string; time: { updated: number } }[]
get: (workspaceID: string) => WorkspaceInfo | undefined
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
workspaces: readonly WorkspaceInfo[]
status: (workspaceID: string) => string | undefined
limit?: number
omitWorkspaceID?: string
}) {
const workspaces = input.sessions
.toSorted((a, b) => b.time.updated - a.time.updated)
.flatMap((session) => {
const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined
return workspace && input.status(workspace.id) === "connected" ? [workspace] : []
})
.filter((workspace) => workspace.id !== input.omitWorkspaceID)
.filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index)
const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
const recent = workspaces.slice(0, input.limit ?? 3)
return { recent, hasMore: recent.length < workspaces.length }
@@ -83,10 +76,13 @@ export async function openWorkspaceSelect(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
input.dialog.clear()
await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
await input.project.workspace.sync().catch(() => undefined)
const adapters = await loadWorkspaceAdapters(input)
if (!adapters) return
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
@@ -200,8 +196,7 @@ export function DialogWorkspaceSelect(props: {
const list = adapters()
if (!list) return []
const { recent, hasMore } = recentConnectedWorkspaces({
sessions: sync.data.session,
get: project.workspace.get,
workspaces: project.workspace.list(),
status: project.workspace.status,
omitWorkspaceID: omittedWorkspaceID(),
})

View File

@@ -610,6 +610,7 @@ export function Prompt(props: PromptProps) {
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warpSession(selection)
@@ -1036,6 +1037,7 @@ export function Prompt(props: PromptProps) {
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warpSession(selection)

View File

@@ -18,22 +18,18 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter
throw new Error(`Unknown workspace adapter: ${type}`)
}
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, adapter]) => {
return {
type,
name: adapter.name,
description: adapter.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] {
return registeredAdapters(projectID).map(([type, adapter]) => ({
type,
name: adapter.name,
description: adapter.description,
}))
return [...builtin, ...custom]
}
export function registeredAdapters(projectID: ProjectID): [string, WorkspaceAdapter][] {
const adapters = new Map(Object.entries(BUILTIN))
for (const [type, adapter] of state.get(projectID)?.entries() ?? []) adapters.set(type, adapter)
return [...adapters.entries()]
}
// Plugins can be loaded per-project so we need to scope them. If you

View File

@@ -3,14 +3,18 @@ import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name,
branch: Schema.String,
branch: Schema.optional(Schema.NullOr(Schema.String)),
directory: Schema.String,
})
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
async function loadWorktree() {
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
return { AppRuntime, Worktree }
const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([
import("@/effect/app-runtime"),
import("@/project/instance"),
import("@/worktree"),
])
return { AppRuntime, Instance, Worktree }
}
export const WorktreeAdapter: WorkspaceAdapter = {
@@ -34,11 +38,22 @@ export const WorktreeAdapter: WorkspaceAdapter = {
svc.createFromInfo({
name: config.name,
directory: config.directory,
branch: config.branch,
branch: config.branch ?? config.name,
}),
),
)
},
async list() {
const { AppRuntime, Instance, Worktree } = await loadWorktree()
return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({
type: "worktree",
name: info.name,
branch: info.branch ?? null,
directory: info.directory,
extra: null,
projectID: Instance.project.id,
}))
},
async remove(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)

View File

@@ -1,4 +1,4 @@
import { Schema } from "effect"
import { Schema, Struct } from "effect"
import { ProjectID } from "@/project/schema"
import { WorkspaceID } from "./schema"
import { zod } from "@/util/effect-zod"
@@ -17,6 +17,11 @@ export const WorkspaceInfo = Schema.Struct({
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"]))
.annotate({ identifier: "WorkspaceListedInfo" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceListedInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceListedInfo>>
export const WorkspaceAdapterEntry = Schema.Struct({
type: Schema.String,
name: Schema.String,
@@ -40,6 +45,7 @@ export type WorkspaceAdapter = {
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
}

View File

@@ -1,4 +1,4 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
import type { WorkspaceID } from "./schema"
@@ -14,4 +14,7 @@ export const WorkspaceTable = sqliteTable("workspace", {
.$type<ProjectID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
time_used: integer()
.notNull()
.$default(() => Date.now()),
})

View File

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

View File

@@ -93,6 +93,23 @@ export const WorkspaceRoutes = lazy(() =>
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
},
)
.post(
"/sync-list",
describeRoute({
summary: "Sync workspace list",
description: "Register missing workspaces returned by workspace adapters.",
operationId: "experimental.workspace.syncList",
responses: {
204: {
description: "Workspace list synced",
},
},
}),
async (c) => {
await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.syncList(Instance.project)))
return c.body(null, 204)
},
)
.get(
"/status",
describeRoute({

View File

@@ -29,6 +29,7 @@ export class ApiWorkspaceWarpError extends Schema.ErrorClass<ApiWorkspaceWarpErr
export const WorkspacePaths = {
adapters: `${root}/adapter`,
list: root,
syncList: `${root}/sync-list`,
status: `${root}/status`,
remove: `${root}/:id`,
warp: `${root}/warp`,
@@ -67,6 +68,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "Create a workspace for the current project.",
}),
),
HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, {
success: described(HttpApiSchema.NoContent, "Workspace list synced"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.syncList",
summary: "Sync workspace list",
description: "Register missing workspaces returned by workspace adapters.",
}),
),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
}).annotateMerge(

View File

@@ -14,7 +14,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => listAdapters(instance.project.id))
return yield* Effect.sync(() => listAdapters(instance.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
@@ -32,6 +32,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
})
const syncList = Effect.fn("WorkspaceHttpApi.syncList")(function* () {
yield* workspace.syncList((yield* InstanceState.context).project)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id))
return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID))
@@ -73,6 +77,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
.handle("adapters", adapters)
.handle("list", list)
.handle("create", create)
.handle("syncList", syncList)
.handle("status", status)
.handle("remove", remove)
.handle("warp", warp)

View File

@@ -5,6 +5,7 @@ import { SyncEvent } from "@/sync"
import * as Session from "./session"
import { MessageV2 } from "./message-v2"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { WorkspaceTable } from "@/control-plane/workspace.sql"
import { Log } from "@opencode-ai/core/util/log"
import nextProjectors from "./projectors-next"
@@ -69,6 +70,10 @@ export default [
db.insert(SessionTable)
.values(Session.toRow(data.info as Session.Info))
.run()
if (data.info.workspaceID) {
db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run()
}
}),
SyncEvent.project(Session.Event.Updated, (db, data) => {

View File

@@ -117,6 +117,13 @@ export const ResetFailedError = NamedError.create(
}),
)
export const ListFailedError = NamedError.create(
"WorktreeListFailedError",
z.object({
message: z.string(),
}),
)
function slugify(input: string) {
return input
.trim()
@@ -149,6 +156,7 @@ export interface Interface {
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[]>
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
}
@@ -341,6 +349,32 @@ export const layer: Layer.Layer<
return undefined
})
const list = Effect.fn("Worktree.list")(function* () {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") {
return []
}
const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
if (result.code !== 0) {
throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
}
const primary = yield* canonical(ctx.worktree)
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
Effect.gen(function* () {
if (!entry.path) return undefined
const directory = yield* canonical(entry.path)
if (directory === primary) return undefined
return {
name: pathSvc.basename(directory),
directory,
...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}),
}
}),
).pipe(Effect.map((items) => items.filter((item) => item !== undefined)))
})
function stopFsmonitor(target: string) {
return fs.exists(target).pipe(
Effect.orDie,
@@ -579,7 +613,7 @@ export const layer: Layer.Layer<
return true
})
return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset })
}),
)