feat(core): basic implementation of remote workspace support (#15120)

This commit is contained in:
James Long
2026-02-27 15:36:39 -05:00
committed by GitHub
parent a94f564ff0
commit c12ce2ffff
26 changed files with 2153 additions and 65 deletions
@@ -0,0 +1,160 @@
import z from "zod"
import { Identifier } from "@/id/id"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Log } from "@/util/log"
import { WorkspaceTable } from "./workspace.sql"
import { Config } from "./config"
import { getAdaptor } from "./adaptors"
import { parseSSE } from "./sse"
export namespace Workspace {
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
z.object({
name: z.string(),
}),
),
Failed: BusEvent.define(
"workspace.failed",
z.object({
message: z.string(),
}),
),
}
export const Info = z
.object({
id: Identifier.schema("workspace"),
branch: z.string().nullable(),
projectID: z.string(),
config: Config,
})
.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return {
id: row.id,
branch: row.branch,
projectID: row.project_id,
config: row.config,
}
}
export const create = fn(
z.object({
id: Identifier.schema("workspace").optional(),
projectID: Info.shape.projectID,
branch: Info.shape.branch,
config: Info.shape.config,
}),
async (input) => {
const id = Identifier.ascending("workspace", input.id)
const { config, init } = await getAdaptor(input.config).create(input.config, input.branch)
const info: Info = {
id,
projectID: input.projectID,
branch: input.branch,
config,
}
setTimeout(async () => {
await init()
Database.use((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
branch: info.branch,
project_id: info.projectID,
config: info.config,
})
.run()
})
GlobalBus.emit("event", {
directory: id,
payload: {
type: Event.Ready.type,
properties: {},
},
})
}, 0)
return info
},
)
export function list(project: Project.Info) {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
}
export const get = fn(Identifier.schema("workspace"), async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
})
export const remove = fn(Identifier.schema("workspace"), async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
const info = fromRow(row)
await getAdaptor(info.config).remove(info.config)
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
}
})
const log = Log.create({ service: "workspace-sync" })
async function workspaceEventLoop(space: Info, stop: AbortSignal) {
while (!stop.aborted) {
const res = await getAdaptor(space.config)
.request(space.config, "GET", "/event", undefined, stop)
.catch(() => undefined)
if (!res || !res.ok || !res.body) {
await Bun.sleep(1000)
continue
}
await parseSSE(res.body, stop, (event) => {
GlobalBus.emit("event", {
directory: space.id,
payload: event,
})
})
// Wait 250ms and retry if SSE connection fails
await Bun.sleep(250)
}
}
export function startSyncing(project: Project.Info) {
const stop = new AbortController()
const spaces = list(project).filter((space) => space.config.type !== "worktree")
spaces.forEach((space) => {
void workspaceEventLoop(space, stop.signal).catch((error) => {
log.warn("workspace sync listener failed", {
workspaceID: space.id,
error,
})
})
})
return {
async stop() {
stop.abort()
},
}
}
}