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

@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create"
describe("recentConnectedWorkspaces", () => {
test("returns unique connected workspaces after filtering missing and inactive entries", () => {
test("returns connected workspaces sorted by time used", () => {
const workspaces = [
{ id: "wrk_a", name: "alpha" },
{ id: "wrk_b", name: "beta" },
{ id: "wrk_c", name: "gamma" },
{ id: "wrk_d", name: "delta" },
{ id: "wrk_e", name: "epsilon" },
{ id: "wrk_a", name: "alpha", timeUsed: 700 },
{ id: "wrk_b", name: "beta", timeUsed: 800 },
{ id: "wrk_c", name: "gamma", timeUsed: 400 },
{ id: "wrk_d", name: "delta", timeUsed: 300 },
{ id: "wrk_e", name: "epsilon", timeUsed: 200 },
]
const status = {
wrk_a: "connected",
@@ -19,45 +19,10 @@ describe("recentConnectedWorkspaces", () => {
} as const
const { recent } = recentConnectedWorkspaces({
sessions: [
{ time: { updated: 900 } },
{ workspaceID: "wrk_b", time: { updated: 800 } },
{ workspaceID: "wrk_a", time: { updated: 700 } },
{ workspaceID: "wrk_a", time: { updated: 600 } },
{ workspaceID: "wrk_missing", time: { updated: 500 } },
{ workspaceID: "wrk_c", time: { updated: 400 } },
{ workspaceID: "wrk_d", time: { updated: 300 } },
{ workspaceID: "wrk_e", time: { updated: 200 } },
],
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
workspaces,
status: (workspaceID) => status[workspaceID as keyof typeof status],
})
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
})
test("omits the active workspace before limiting recent workspaces", () => {
const workspaces = [
{ id: "wrk_a", name: "alpha" },
{ id: "wrk_b", name: "beta" },
{ id: "wrk_c", name: "gamma" },
{ id: "wrk_d", name: "delta" },
]
const { recent, hasMore } = recentConnectedWorkspaces({
sessions: [
{ workspaceID: "wrk_a", time: { updated: 400 } },
{ workspaceID: "wrk_b", time: { updated: 300 } },
{ workspaceID: "wrk_c", time: { updated: 200 } },
{ workspaceID: "wrk_d", time: { updated: 100 } },
],
get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
status: () => "connected",
limit: 3,
omitWorkspaceID: "wrk_a",
})
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"])
expect(hasMore).toBe(false)
})
})

View File

@@ -28,7 +28,7 @@ import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
import * as WorkspaceOld from "../../src/control-plane/workspace"
import * as Workspace from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
@@ -37,7 +37,7 @@ void Log.init({ print: false })
const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
WorkspaceOld.defaultLayer.pipe(
Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
),
@@ -64,6 +64,7 @@ type RecordedAdapter = {
calls: {
configure: WorkspaceInfo[]
create: RecordedCreate[]
list: number
remove: WorkspaceInfo[]
target: WorkspaceInfo[]
}
@@ -125,23 +126,25 @@ async function initGitRepo(dir: string) {
await $`git commit -m "base"`.cwd(dir).quiet()
}
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id)))
const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status()))
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => AppRuntime.runPromise(effect)
const createWorkspace = (input: Workspace.CreateInput) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
const warpWorkspaceSession = (input: Workspace.SessionWarpInput) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.sessionWarp(input)))
const listWorkspaces = (project: Parameters<Workspace.Interface["list"]>[0]) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.list(project)))
const syncListWorkspaces = (project: Parameters<Workspace.Interface["syncList"]>[0]) =>
runWorkspace(Workspace.Service.use((workspace) => workspace.syncList(project)))
const getWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.get(id)))
const removeWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.remove(id)))
const workspaceStatus = () => runWorkspace(Workspace.Service.use((workspace) => workspace.status()))
const isWorkspaceSyncing = (id: WorkspaceID) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id)))
runWorkspace(Workspace.Service.use((workspace) => workspace.isSyncing(id)))
const startWorkspaceSyncing = (projectID: ProjectID) => {
void runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
}
const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) =>
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
function captureGlobalEvents() {
const events: GlobalEvent[] = []
@@ -187,11 +190,13 @@ function recordedAdapter(input: {
target: (info: WorkspaceInfo) => Target | Promise<Target>
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void>
list?: () => Omit<WorkspaceInfo, "id">[] | Promise<Omit<WorkspaceInfo, "id">[]>
remove?: (info: WorkspaceInfo) => Promise<void>
}): RecordedAdapter {
const calls: RecordedAdapter["calls"] = {
configure: [],
create: [],
list: 0,
remove: [],
target: [],
}
@@ -213,6 +218,14 @@ function recordedAdapter(input: {
})
await input.create?.(info, env, from)
},
...(input.list
? {
async list() {
calls.list += 1
return input.list?.() ?? []
},
}
: {}),
async remove(info) {
calls.remove.push(structuredClone(info))
await input.remove?.(info)
@@ -272,7 +285,7 @@ function serverUrl() {
})
}
function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<WorkspaceInfo>): WorkspaceInfo {
function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Workspace.Info>): Workspace.Info {
return {
id: input?.id ?? WorkspaceID.ascending(),
type,
@@ -281,10 +294,11 @@ function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Works
directory: input?.directory ?? null,
extra: input?.extra ?? null,
projectID,
timeUsed: input?.timeUsed ?? Date.now(),
}
}
function insertWorkspace(info: WorkspaceInfo) {
function insertWorkspace(info: Workspace.Info) {
Database.use((db) =>
db
.insert(WorkspaceTable)
@@ -296,6 +310,7 @@ function insertWorkspace(info: WorkspaceInfo) {
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run(),
)
@@ -348,11 +363,11 @@ function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
}
describe("workspace-old schemas and exports", () => {
describe("workspace schemas and exports", () => {
test("keeps the historical event type names", () => {
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
expect(Workspace.Event.Ready.type).toBe("workspace.ready")
expect(Workspace.Event.Failed.type).toBe("workspace.failed")
expect(Workspace.Event.Status.type).toBe("workspace.status")
})
test("validates create input with workspace id, project id, branch, type, and extra", () => {
@@ -364,13 +379,13 @@ describe("workspace-old schemas and exports", () => {
extra: { nested: true },
}
expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input)
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
expect(Workspace.CreateInput.zod.parse(input)).toEqual(input)
expect(() => Workspace.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
expect(() => Workspace.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
})
})
describe("workspace-old CRUD", () => {
describe("workspace CRUD", () => {
test("get returns undefined for a missing workspace", async () => {
await withInstance(async () => {
expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined()
@@ -447,13 +462,22 @@ describe("workspace-old CRUD", () => {
directory: targetDir,
extra: { configured: true },
projectID: Instance.project.id,
timeUsed: info.timeUsed,
})
expect(await getWorkspace(workspaceID)).toEqual(info)
expect(await listWorkspaces(Instance.project)).toEqual([info])
expect(recorded.calls.configure).toHaveLength(1)
expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null })
expect(recorded.calls.create).toHaveLength(1)
expect(recorded.calls.create[0].info).toEqual(info)
expect(recorded.calls.create[0].info).toEqual({
id: workspaceID,
type,
branch: "configured-branch",
name: "Configured Name",
directory: targetDir,
extra: { configured: true },
projectID: Instance.project.id,
})
expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({
test: { type: "api", key: "secret" },
})
@@ -532,6 +556,120 @@ describe("workspace-old CRUD", () => {
})
})
test("syncList registers adapter-listed workspaces that are missing by name", async () => {
await withInstance(async (dir) => {
const type = unique("list-sync")
const existing = workspaceInfo(Instance.project.id, type, {
id: WorkspaceID.ascending("wrk_list_sync_existing"),
name: "existing",
directory: path.join(dir, "existing"),
})
insertWorkspace(existing)
const discovered = {
type,
name: "discovered",
branch: "feature/discovered",
directory: path.join(dir, "discovered"),
extra: { source: "adapter" },
projectID: Instance.project.id,
}
const recorded = recordedAdapter({
list() {
return [
{
type,
name: existing.name,
branch: "ignored",
directory: path.join(dir, "ignored"),
extra: null,
projectID: Instance.project.id,
},
discovered,
]
},
target(info) {
return { type: "local", directory: info.directory ?? dir }
},
})
registerAdapter(Instance.project.id, type, recorded.adapter)
await syncListWorkspaces(Instance.project)
const synced = (await listWorkspaces(Instance.project)).filter((item) => item.name === discovered.name)
expect(synced).toHaveLength(1)
expect(synced[0]).toMatchObject(discovered)
expect(synced[0]?.id).toStartWith("wrk_")
expect(await listWorkspaces(Instance.project)).toEqual(expect.arrayContaining([existing, synced[0]]))
expect(recorded.calls.list).toBe(1)
expect(recorded.calls.configure).toHaveLength(0)
expect(recorded.calls.create).toHaveLength(0)
expect(recorded.calls.target).toHaveLength(1)
})
})
test("syncList calls every registered adapter with a list method", async () => {
await withInstance(async (dir) => {
const typeA = unique("list-sync-a")
const typeB = unique("list-sync-b")
const adapterA = recordedAdapter({
list() {
return [
{
type: typeA,
name: "adapter-a",
branch: null,
directory: path.join(dir, "adapter-a"),
extra: null,
projectID: Instance.project.id,
},
]
},
target(info) {
return { type: "local", directory: info.directory ?? dir }
},
})
const adapterB = recordedAdapter({
list() {
return [
{
type: typeB,
name: "adapter-b",
branch: null,
directory: path.join(dir, "adapter-b"),
extra: null,
projectID: Instance.project.id,
},
]
},
target(info) {
return { type: "local", directory: info.directory ?? dir }
},
})
const noList = recordedAdapter({
target() {
return { type: "local", directory: dir }
},
})
registerAdapter(Instance.project.id, typeA, adapterA.adapter)
registerAdapter(Instance.project.id, typeB, adapterB.adapter)
registerAdapter(Instance.project.id, unique("list-sync-none"), noList.adapter)
await syncListWorkspaces(Instance.project)
const synced = await listWorkspaces(Instance.project)
expect(
synced
.filter((item) => item.type === typeA || item.type === typeB)
.map((item) => item.name)
.toSorted(),
).toEqual(["adapter-a", "adapter-b"])
expect(adapterA.calls.list).toBe(1)
expect(adapterB.calls.list).toBe(1)
expect(noList.calls.list).toBe(0)
})
})
it.live("remote create connects to routed event and history endpoints", () => {
const calls: FetchCall[] = []
return Effect.gen(function* () {
@@ -557,7 +695,7 @@ describe("workspace-old CRUD", () => {
yield* provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const type = unique("remote-create")
const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir })
registerAdapter(Instance.project.id, type, recorded.adapter)
@@ -754,7 +892,7 @@ describe("workspace-old CRUD", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-remote-source")
const targetType = unique("warp-remote-target")
@@ -805,7 +943,7 @@ describe("workspace-old CRUD", () => {
})
})
describe("workspace-old sync state", () => {
describe("workspace sync state", () => {
test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => {
await withInstance(async (dir) => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
@@ -823,35 +961,29 @@ describe("workspace-old sync state", () => {
})
})
test("startWorkspaceSyncing starts only workspaces with sessions", async () => {
test("startWorkspaceSyncing starts all workspaces", async () => {
await withInstance(async (dir) => {
const withSessionType = unique("with-session")
const withoutSessionType = unique("without-session")
const withSession = workspaceInfo(Instance.project.id, withSessionType)
const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType)
const withSessionDir = path.join(dir, "with-session")
const withoutSessionDir = path.join(dir, "without-session")
await fs.mkdir(withSessionDir, { recursive: true })
await fs.mkdir(withoutSessionDir, { recursive: true })
insertWorkspace(withSession)
insertWorkspace(withoutSession)
registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter)
registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter)
attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
withSession.id,
)
const firstType = unique("first")
const secondType = unique("second")
const first = workspaceInfo(Instance.project.id, firstType)
const second = workspaceInfo(Instance.project.id, secondType)
await fs.mkdir(path.join(dir, "first"), { recursive: true })
await fs.mkdir(path.join(dir, "second"), { recursive: true })
insertWorkspace(first)
insertWorkspace(second)
registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter)
registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter)
startWorkspaceSyncing(Instance.project.id)
await eventually(() =>
workspaceStatus().then((status) =>
expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"),
),
workspaceStatus().then((status) => {
expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected")
expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected")
}),
)
expect((await workspaceStatus()).find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined()
await removeWorkspace(withSession.id)
await removeWorkspace(withoutSession.id)
await removeWorkspace(first.id)
await removeWorkspace(second.id)
})
})
@@ -907,7 +1039,7 @@ describe("workspace-old sync state", () => {
)
expect(
captured.events.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
(event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type,
),
).toHaveLength(1)
await removeWorkspace(info.id)
@@ -941,7 +1073,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
@@ -965,7 +1097,7 @@ describe("workspace-old sync state", () => {
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
(event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type,
)
.map((event) => event.payload.properties.status),
).toEqual(["disconnected", "connecting", "connected"])
@@ -998,7 +1130,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("remote-connect-fail")
const info = workspaceInfo(Instance.project.id, type)
@@ -1038,7 +1170,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("remote-history-fail")
const info = workspaceInfo(Instance.project.id, type)
@@ -1093,7 +1225,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
@@ -1160,7 +1292,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
@@ -1241,7 +1373,7 @@ describe("workspace-old sync state", () => {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const captured = captureGlobalEvents()
try {
@@ -1280,7 +1412,7 @@ describe("workspace-old sync state", () => {
})
})
describe("workspace-old waitForSync", () => {
describe("workspace waitForSync", () => {
test("returns immediately for an empty fence", async () => {
await withInstance(async () => {
await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined()

View File

@@ -64,6 +64,36 @@ function localAdapter(directory: string): WorkspaceAdapter {
}
}
function listedAdapter(directory: string, type: string): WorkspaceAdapter {
return {
name: "Listed Test",
description: "List a local test workspace",
configure(info) {
return { ...info, name: "unused", directory }
},
async create() {},
async remove() {},
list() {
return [
{
type,
name: "listed-test",
branch: "listed/main",
directory,
extra: { listed: true },
projectID: Instance.project.id,
},
]
},
target() {
return {
type: "local" as const,
directory,
}
},
}
}
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
return {
name: "Remote Test",
@@ -196,6 +226,30 @@ describe("workspace HttpApi", () => {
}),
)
it.live("serves list sync endpoint", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const type = `listed-${Math.random().toString(36).slice(2)}`
registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type))
const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" })
expect(response.status).toBe(204)
const listed = yield* request(WorkspacePaths.list, dir)
expect(yield* Effect.promise(() => listed.json())).toMatchObject([
{
type,
name: "listed-test",
branch: "listed/main",
directory: path.join(dir, ".listed"),
extra: { listed: true },
},
])
}),
)
it.live("creates workspace with the TUI payload shape", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true