feat(core): allow external workspace creation (#26212)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user