Compare commits
4 commits
dev
...
jlongster/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5668fe61f8 | ||
|
|
ed6140af9e | ||
|
|
8d63dc34c0 | ||
|
|
771170fb03 |
22 changed files with 2070 additions and 145 deletions
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -107,6 +107,7 @@ export function DialogSessionList() {
|
||||||
dialog,
|
dialog,
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
|
project,
|
||||||
toast,
|
toast,
|
||||||
onSelect: (selection) => {
|
onSelect: (selection) => {
|
||||||
void warp(selection)
|
void warp(selection)
|
||||||
|
|
|
||||||
|
|
@ -36,21 +36,14 @@ export type WorkspaceSelection =
|
||||||
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
|
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
|
||||||
type ExistingWorkspaceSelectValue = { workspace: Workspace }
|
type ExistingWorkspaceSelectValue = { workspace: Workspace }
|
||||||
|
|
||||||
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(input: {
|
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
|
||||||
sessions: readonly { workspaceID?: string; time: { updated: number } }[]
|
workspaces: readonly WorkspaceInfo[]
|
||||||
get: (workspaceID: string) => WorkspaceInfo | undefined
|
|
||||||
status: (workspaceID: string) => string | undefined
|
status: (workspaceID: string) => string | undefined
|
||||||
limit?: number
|
limit?: number
|
||||||
omitWorkspaceID?: string
|
omitWorkspaceID?: string
|
||||||
}) {
|
}) {
|
||||||
const workspaces = input.sessions
|
const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
|
||||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
|
||||||
.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 recent = workspaces.slice(0, input.limit ?? 3)
|
const recent = workspaces.slice(0, input.limit ?? 3)
|
||||||
|
|
||||||
return { recent, hasMore: recent.length < workspaces.length }
|
return { recent, hasMore: recent.length < workspaces.length }
|
||||||
|
|
@ -83,10 +76,13 @@ export async function openWorkspaceSelect(input: {
|
||||||
dialog: ReturnType<typeof useDialog>
|
dialog: ReturnType<typeof useDialog>
|
||||||
sdk: ReturnType<typeof useSDK>
|
sdk: ReturnType<typeof useSDK>
|
||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
|
project: ReturnType<typeof useProject>
|
||||||
toast: ReturnType<typeof useToast>
|
toast: ReturnType<typeof useToast>
|
||||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||||
}) {
|
}) {
|
||||||
input.dialog.clear()
|
input.dialog.clear()
|
||||||
|
await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||||
|
await input.project.workspace.sync().catch(() => undefined)
|
||||||
const adapters = await loadWorkspaceAdapters(input)
|
const adapters = await loadWorkspaceAdapters(input)
|
||||||
if (!adapters) return
|
if (!adapters) return
|
||||||
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
|
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
|
||||||
|
|
@ -200,8 +196,7 @@ export function DialogWorkspaceSelect(props: {
|
||||||
const list = adapters()
|
const list = adapters()
|
||||||
if (!list) return []
|
if (!list) return []
|
||||||
const { recent, hasMore } = recentConnectedWorkspaces({
|
const { recent, hasMore } = recentConnectedWorkspaces({
|
||||||
sessions: sync.data.session,
|
workspaces: project.workspace.list(),
|
||||||
get: project.workspace.get,
|
|
||||||
status: project.workspace.status,
|
status: project.workspace.status,
|
||||||
omitWorkspaceID: omittedWorkspaceID(),
|
omitWorkspaceID: omittedWorkspaceID(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -600,6 +600,7 @@ export function Prompt(props: PromptProps) {
|
||||||
dialog,
|
dialog,
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
|
project,
|
||||||
toast,
|
toast,
|
||||||
onSelect: (selection) => {
|
onSelect: (selection) => {
|
||||||
void warpSession(selection)
|
void warpSession(selection)
|
||||||
|
|
@ -854,6 +855,7 @@ export function Prompt(props: PromptProps) {
|
||||||
dialog,
|
dialog,
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
|
project,
|
||||||
toast,
|
toast,
|
||||||
onSelect: (selection) => {
|
onSelect: (selection) => {
|
||||||
void warpSession(selection)
|
void warpSession(selection)
|
||||||
|
|
|
||||||
|
|
@ -18,22 +18,18 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter
|
||||||
throw new Error(`Unknown workspace adapter: ${type}`)
|
throw new Error(`Unknown workspace adapter: ${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
|
export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] {
|
||||||
const builtin = await Promise.all(
|
return registeredAdapters(projectID).map(([type, adapter]) => ({
|
||||||
Object.entries(BUILTIN).map(async ([type, adapter]) => {
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
name: adapter.name,
|
|
||||||
description: adapter.description,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
|
|
||||||
type,
|
type,
|
||||||
name: adapter.name,
|
name: adapter.name,
|
||||||
description: adapter.description,
|
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
|
// 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({
|
const WorktreeConfig = Schema.Struct({
|
||||||
name: WorkspaceInfo.fields.name,
|
name: WorkspaceInfo.fields.name,
|
||||||
branch: Schema.String,
|
branch: Schema.optional(Schema.NullOr(Schema.String)),
|
||||||
directory: Schema.String,
|
directory: Schema.String,
|
||||||
})
|
})
|
||||||
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
|
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
|
||||||
|
|
||||||
async function loadWorktree() {
|
async function loadWorktree() {
|
||||||
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
|
const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([
|
||||||
return { AppRuntime, Worktree }
|
import("@/effect/app-runtime"),
|
||||||
|
import("@/project/instance"),
|
||||||
|
import("@/worktree"),
|
||||||
|
])
|
||||||
|
return { AppRuntime, Instance, Worktree }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorktreeAdapter: WorkspaceAdapter = {
|
export const WorktreeAdapter: WorkspaceAdapter = {
|
||||||
|
|
@ -34,11 +38,22 @@ export const WorktreeAdapter: WorkspaceAdapter = {
|
||||||
svc.createFromInfo({
|
svc.createFromInfo({
|
||||||
name: config.name,
|
name: config.name,
|
||||||
directory: config.directory,
|
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) {
|
async remove(info) {
|
||||||
const { AppRuntime, Worktree } = await loadWorktree()
|
const { AppRuntime, Worktree } = await loadWorktree()
|
||||||
const config = decodeWorktreeConfig(info)
|
const config = decodeWorktreeConfig(info)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Schema } from "effect"
|
import { Schema, Struct } from "effect"
|
||||||
import { ProjectID } from "@/project/schema"
|
import { ProjectID } from "@/project/schema"
|
||||||
import { WorkspaceID } from "./schema"
|
import { WorkspaceID } from "./schema"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
|
|
@ -17,6 +17,11 @@ export const WorkspaceInfo = Schema.Struct({
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
|
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({
|
export const WorkspaceAdapterEntry = Schema.Struct({
|
||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
|
@ -40,6 +45,7 @@ export type WorkspaceAdapter = {
|
||||||
description: string
|
description: string
|
||||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||||
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
|
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
|
||||||
|
list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
|
||||||
remove(info: WorkspaceInfo): Promise<void>
|
remove(info: WorkspaceInfo): Promise<void>
|
||||||
target(info: WorkspaceInfo): Target | Promise<Target>
|
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 { ProjectTable } from "../project/project.sql"
|
||||||
import type { ProjectID } from "../project/schema"
|
import type { ProjectID } from "../project/schema"
|
||||||
import type { WorkspaceID } from "./schema"
|
import type { WorkspaceID } from "./schema"
|
||||||
|
|
@ -14,4 +14,7 @@ export const WorkspaceTable = sqliteTable("workspace", {
|
||||||
.$type<ProjectID>()
|
.$type<ProjectID>()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
.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 { ProjectID } from "@/project/schema"
|
||||||
import { Slug } from "@opencode-ai/core/util/slug"
|
import { Slug } from "@opencode-ai/core/util/slug"
|
||||||
import { WorkspaceTable } from "./workspace.sql"
|
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 { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
|
||||||
import { WorkspaceID } from "./schema"
|
import { WorkspaceID } from "./schema"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
|
|
@ -35,8 +35,13 @@ import { Vcs } from "@/project/vcs"
|
||||||
import { InstanceStore } from "@/project/instance-store"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
|
|
||||||
export const Info = WorkspaceInfoSchema
|
export const Info = Schema.Struct({
|
||||||
export type Info = WorkspaceInfo
|
...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({
|
export const ConnectionStatus = Schema.Struct({
|
||||||
workspaceID: WorkspaceID,
|
workspaceID: WorkspaceID,
|
||||||
|
|
@ -69,6 +74,7 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
|
||||||
directory: row.directory,
|
directory: row.directory,
|
||||||
extra: row.extra,
|
extra: row.extra,
|
||||||
projectID: row.project_id,
|
projectID: row.project_id,
|
||||||
|
timeUsed: row.time_used,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,6 +156,7 @@ export interface Interface {
|
||||||
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
|
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
|
||||||
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
|
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
|
||||||
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
|
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 get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
||||||
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
|
||||||
readonly status: () => Effect.Effect<ConnectionStatus[]>
|
readonly status: () => Effect.Effect<ConnectionStatus[]>
|
||||||
|
|
@ -483,7 +490,19 @@ export const layer = Layer.effect(
|
||||||
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
|
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
|
||||||
|
|
||||||
const adapter = getAdapter(space.projectID, space.type)
|
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") {
|
if (target.type === "local") {
|
||||||
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
|
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 id = WorkspaceID.ascending(input.id)
|
||||||
const adapter = getAdapter(input.projectID, input.type)
|
const adapter = getAdapter(input.projectID, input.type)
|
||||||
const config = yield* EffectBridge.fromPromise(() =>
|
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 = {
|
const info: Info = {
|
||||||
|
|
@ -534,6 +559,7 @@ export const layer = Layer.effect(
|
||||||
directory: config.directory ?? null,
|
directory: config.directory ?? null,
|
||||||
extra: config.extra ?? null,
|
extra: config.extra ?? null,
|
||||||
projectID: input.projectID,
|
projectID: input.projectID,
|
||||||
|
timeUsed: Date.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* db((db) => {
|
yield* db((db) => {
|
||||||
|
|
@ -546,6 +572,7 @@ export const layer = Layer.effect(
|
||||||
directory: info.directory,
|
directory: info.directory,
|
||||||
extra: info.extra,
|
extra: info.extra,
|
||||||
project_id: info.projectID,
|
project_id: info.projectID,
|
||||||
|
time_used: info.timeUsed,
|
||||||
})
|
})
|
||||||
.run()
|
.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 get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) {
|
||||||
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||||
if (!row) return
|
if (!row) return
|
||||||
|
|
@ -916,13 +1000,10 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) {
|
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) =>
|
const rows = yield* db((db) =>
|
||||||
db
|
db
|
||||||
.selectDistinct({ workspace: WorkspaceTable })
|
.selectDistinct({ workspace: WorkspaceTable })
|
||||||
.from(WorkspaceTable)
|
.from(WorkspaceTable)
|
||||||
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
|
|
||||||
.where(eq(WorkspaceTable.project_id, projectID))
|
.where(eq(WorkspaceTable.project_id, projectID))
|
||||||
.all(),
|
.all(),
|
||||||
)
|
)
|
||||||
|
|
@ -947,6 +1028,7 @@ export const layer = Layer.effect(
|
||||||
create,
|
create,
|
||||||
sessionWarp,
|
sessionWarp,
|
||||||
list,
|
list,
|
||||||
|
syncList,
|
||||||
get,
|
get,
|
||||||
remove,
|
remove,
|
||||||
status,
|
status,
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,23 @@ export const WorkspaceRoutes = lazy(() =>
|
||||||
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
|
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(
|
.get(
|
||||||
"/status",
|
"/status",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export class ApiWorkspaceWarpError extends Schema.ErrorClass<ApiWorkspaceWarpErr
|
||||||
export const WorkspacePaths = {
|
export const WorkspacePaths = {
|
||||||
adapters: `${root}/adapter`,
|
adapters: `${root}/adapter`,
|
||||||
list: root,
|
list: root,
|
||||||
|
syncList: `${root}/sync-list`,
|
||||||
status: `${root}/status`,
|
status: `${root}/status`,
|
||||||
remove: `${root}/:id`,
|
remove: `${root}/:id`,
|
||||||
warp: `${root}/warp`,
|
warp: `${root}/warp`,
|
||||||
|
|
@ -67,6 +68,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||||
description: "Create a workspace for the current project.",
|
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, {
|
HttpApiEndpoint.get("status", WorkspacePaths.status, {
|
||||||
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
|
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||||
|
|
||||||
const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () {
|
const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () {
|
||||||
const instance = yield* InstanceState.context
|
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* () {
|
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
|
||||||
|
|
@ -32,6 +32,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
.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 status = Effect.fn("WorkspaceHttpApi.status")(function* () {
|
||||||
const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id))
|
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))
|
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("adapters", adapters)
|
||||||
.handle("list", list)
|
.handle("list", list)
|
||||||
.handle("create", create)
|
.handle("create", create)
|
||||||
|
.handle("syncList", syncList)
|
||||||
.handle("status", status)
|
.handle("status", status)
|
||||||
.handle("remove", remove)
|
.handle("remove", remove)
|
||||||
.handle("warp", warp)
|
.handle("warp", warp)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { SyncEvent } from "@/sync"
|
||||||
import * as Session from "./session"
|
import * as Session from "./session"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||||
|
import { WorkspaceTable } from "@/control-plane/workspace.sql"
|
||||||
import { Log } from "@opencode-ai/core/util/log"
|
import { Log } from "@opencode-ai/core/util/log"
|
||||||
import nextProjectors from "./projectors-next"
|
import nextProjectors from "./projectors-next"
|
||||||
|
|
||||||
|
|
@ -69,6 +70,10 @@ export default [
|
||||||
db.insert(SessionTable)
|
db.insert(SessionTable)
|
||||||
.values(Session.toRow(data.info as Session.Info))
|
.values(Session.toRow(data.info as Session.Info))
|
||||||
.run()
|
.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) => {
|
SyncEvent.project(Session.Event.Updated, (db, data) => {
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,13 @@ export const ResetFailedError = NamedError.create(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const ListFailedError = NamedError.create(
|
||||||
|
"WorktreeListFailedError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
function slugify(input: string) {
|
function slugify(input: string) {
|
||||||
return input
|
return input
|
||||||
.trim()
|
.trim()
|
||||||
|
|
@ -149,6 +156,7 @@ export interface Interface {
|
||||||
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
|
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
|
||||||
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
|
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
|
||||||
readonly create: (input?: CreateInput) => Effect.Effect<Info>
|
readonly create: (input?: CreateInput) => Effect.Effect<Info>
|
||||||
|
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[]>
|
||||||
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
|
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
|
||||||
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
|
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
|
||||||
}
|
}
|
||||||
|
|
@ -341,6 +349,34 @@ export const layer: Layer.Layer<
|
||||||
return undefined
|
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)
|
||||||
|
const primaryName = pathSvc.basename(primary)
|
||||||
|
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
|
||||||
|
const name = pathSvc.basename(directory)
|
||||||
|
return {
|
||||||
|
name: name === primaryName ? pathSvc.basename(pathSvc.dirname(directory)) : name,
|
||||||
|
directory,
|
||||||
|
...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).pipe(Effect.map((items) => items.filter((item) => item !== undefined)))
|
||||||
|
})
|
||||||
|
|
||||||
function stopFsmonitor(target: string) {
|
function stopFsmonitor(target: string) {
|
||||||
return fs.exists(target).pipe(
|
return fs.exists(target).pipe(
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
|
|
@ -579,7 +615,7 @@ export const layer: Layer.Layer<
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
|
return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
|
||||||
import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create"
|
import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create"
|
||||||
|
|
||||||
describe("recentConnectedWorkspaces", () => {
|
describe("recentConnectedWorkspaces", () => {
|
||||||
test("returns unique connected workspaces after filtering missing and inactive entries", () => {
|
test("returns connected workspaces sorted by time used", () => {
|
||||||
const workspaces = [
|
const workspaces = [
|
||||||
{ id: "wrk_a", name: "alpha" },
|
{ id: "wrk_a", name: "alpha", timeUsed: 700 },
|
||||||
{ id: "wrk_b", name: "beta" },
|
{ id: "wrk_b", name: "beta", timeUsed: 800 },
|
||||||
{ id: "wrk_c", name: "gamma" },
|
{ id: "wrk_c", name: "gamma", timeUsed: 400 },
|
||||||
{ id: "wrk_d", name: "delta" },
|
{ id: "wrk_d", name: "delta", timeUsed: 300 },
|
||||||
{ id: "wrk_e", name: "epsilon" },
|
{ id: "wrk_e", name: "epsilon", timeUsed: 200 },
|
||||||
]
|
]
|
||||||
const status = {
|
const status = {
|
||||||
wrk_a: "connected",
|
wrk_a: "connected",
|
||||||
|
|
@ -19,45 +19,10 @@ describe("recentConnectedWorkspaces", () => {
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const { recent } = recentConnectedWorkspaces({
|
const { recent } = recentConnectedWorkspaces({
|
||||||
sessions: [
|
workspaces,
|
||||||
{ 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),
|
|
||||||
status: (workspaceID) => status[workspaceID as keyof typeof status],
|
status: (workspaceID) => status[workspaceID as keyof typeof status],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
|
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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { registerAdapter } from "../../src/control-plane/adapters"
|
||||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||||
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
|
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 { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { InstanceStore } from "@/project/instance-store"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
|
|
@ -37,7 +37,7 @@ void Log.init({ print: false })
|
||||||
|
|
||||||
const testServerLayer = Layer.mergeAll(
|
const testServerLayer = Layer.mergeAll(
|
||||||
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
|
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
|
||||||
WorkspaceOld.defaultLayer.pipe(
|
Workspace.defaultLayer.pipe(
|
||||||
Layer.provide(InstanceStore.defaultLayer),
|
Layer.provide(InstanceStore.defaultLayer),
|
||||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||||
),
|
),
|
||||||
|
|
@ -64,6 +64,7 @@ type RecordedAdapter = {
|
||||||
calls: {
|
calls: {
|
||||||
configure: WorkspaceInfo[]
|
configure: WorkspaceInfo[]
|
||||||
create: RecordedCreate[]
|
create: RecordedCreate[]
|
||||||
|
list: number
|
||||||
remove: WorkspaceInfo[]
|
remove: WorkspaceInfo[]
|
||||||
target: WorkspaceInfo[]
|
target: WorkspaceInfo[]
|
||||||
}
|
}
|
||||||
|
|
@ -125,23 +126,25 @@ async function initGitRepo(dir: string) {
|
||||||
await $`git commit -m "base"`.cwd(dir).quiet()
|
await $`git commit -m "base"`.cwd(dir).quiet()
|
||||||
}
|
}
|
||||||
|
|
||||||
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
|
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => AppRuntime.runPromise(effect)
|
||||||
const createWorkspace = (input: WorkspaceOld.CreateInput) =>
|
const createWorkspace = (input: Workspace.CreateInput) =>
|
||||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
|
runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
|
||||||
const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
|
const warpWorkspaceSession = (input: Workspace.SessionWarpInput) =>
|
||||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
|
runWorkspace(Workspace.Service.use((workspace) => workspace.sessionWarp(input)))
|
||||||
const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
|
const listWorkspaces = (project: Parameters<Workspace.Interface["list"]>[0]) =>
|
||||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
|
runWorkspace(Workspace.Service.use((workspace) => workspace.list(project)))
|
||||||
const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
|
const syncListWorkspaces = (project: Parameters<Workspace.Interface["syncList"]>[0]) =>
|
||||||
const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id)))
|
runWorkspace(Workspace.Service.use((workspace) => workspace.syncList(project)))
|
||||||
const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status()))
|
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) =>
|
const isWorkspaceSyncing = (id: WorkspaceID) =>
|
||||||
runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id)))
|
runWorkspace(Workspace.Service.use((workspace) => workspace.isSyncing(id)))
|
||||||
const startWorkspaceSyncing = (projectID: ProjectID) => {
|
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) =>
|
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() {
|
function captureGlobalEvents() {
|
||||||
const events: GlobalEvent[] = []
|
const events: GlobalEvent[] = []
|
||||||
|
|
@ -187,11 +190,13 @@ function recordedAdapter(input: {
|
||||||
target: (info: WorkspaceInfo) => Target | Promise<Target>
|
target: (info: WorkspaceInfo) => Target | Promise<Target>
|
||||||
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
|
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
|
||||||
create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void>
|
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>
|
remove?: (info: WorkspaceInfo) => Promise<void>
|
||||||
}): RecordedAdapter {
|
}): RecordedAdapter {
|
||||||
const calls: RecordedAdapter["calls"] = {
|
const calls: RecordedAdapter["calls"] = {
|
||||||
configure: [],
|
configure: [],
|
||||||
create: [],
|
create: [],
|
||||||
|
list: 0,
|
||||||
remove: [],
|
remove: [],
|
||||||
target: [],
|
target: [],
|
||||||
}
|
}
|
||||||
|
|
@ -213,6 +218,14 @@ function recordedAdapter(input: {
|
||||||
})
|
})
|
||||||
await input.create?.(info, env, from)
|
await input.create?.(info, env, from)
|
||||||
},
|
},
|
||||||
|
...(input.list
|
||||||
|
? {
|
||||||
|
async list() {
|
||||||
|
calls.list += 1
|
||||||
|
return input.list?.() ?? []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
async remove(info) {
|
async remove(info) {
|
||||||
calls.remove.push(structuredClone(info))
|
calls.remove.push(structuredClone(info))
|
||||||
await input.remove?.(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 {
|
return {
|
||||||
id: input?.id ?? WorkspaceID.ascending(),
|
id: input?.id ?? WorkspaceID.ascending(),
|
||||||
type,
|
type,
|
||||||
|
|
@ -281,10 +294,11 @@ function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Works
|
||||||
directory: input?.directory ?? null,
|
directory: input?.directory ?? null,
|
||||||
extra: input?.extra ?? null,
|
extra: input?.extra ?? null,
|
||||||
projectID,
|
projectID,
|
||||||
|
timeUsed: input?.timeUsed ?? Date.now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertWorkspace(info: WorkspaceInfo) {
|
function insertWorkspace(info: Workspace.Info) {
|
||||||
Database.use((db) =>
|
Database.use((db) =>
|
||||||
db
|
db
|
||||||
.insert(WorkspaceTable)
|
.insert(WorkspaceTable)
|
||||||
|
|
@ -296,6 +310,7 @@ function insertWorkspace(info: WorkspaceInfo) {
|
||||||
directory: info.directory,
|
directory: info.directory,
|
||||||
extra: info.extra,
|
extra: info.extra,
|
||||||
project_id: info.projectID,
|
project_id: info.projectID,
|
||||||
|
time_used: info.timeUsed,
|
||||||
})
|
})
|
||||||
.run(),
|
.run(),
|
||||||
)
|
)
|
||||||
|
|
@ -348,11 +363,11 @@ function sessionUpdatedType() {
|
||||||
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
|
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", () => {
|
test("keeps the historical event type names", () => {
|
||||||
expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
|
expect(Workspace.Event.Ready.type).toBe("workspace.ready")
|
||||||
expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
|
expect(Workspace.Event.Failed.type).toBe("workspace.failed")
|
||||||
expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
|
expect(Workspace.Event.Status.type).toBe("workspace.status")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("validates create input with workspace id, project id, branch, type, and extra", () => {
|
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 },
|
extra: { nested: true },
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input)
|
expect(Workspace.CreateInput.zod.parse(input)).toEqual(input)
|
||||||
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
|
expect(() => Workspace.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
|
||||||
expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).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 () => {
|
test("get returns undefined for a missing workspace", async () => {
|
||||||
await withInstance(async () => {
|
await withInstance(async () => {
|
||||||
expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined()
|
expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined()
|
||||||
|
|
@ -447,13 +462,22 @@ describe("workspace-old CRUD", () => {
|
||||||
directory: targetDir,
|
directory: targetDir,
|
||||||
extra: { configured: true },
|
extra: { configured: true },
|
||||||
projectID: Instance.project.id,
|
projectID: Instance.project.id,
|
||||||
|
timeUsed: info.timeUsed,
|
||||||
})
|
})
|
||||||
expect(await getWorkspace(workspaceID)).toEqual(info)
|
expect(await getWorkspace(workspaceID)).toEqual(info)
|
||||||
expect(await listWorkspaces(Instance.project)).toEqual([info])
|
expect(await listWorkspaces(Instance.project)).toEqual([info])
|
||||||
expect(recorded.calls.configure).toHaveLength(1)
|
expect(recorded.calls.configure).toHaveLength(1)
|
||||||
expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null })
|
expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null })
|
||||||
expect(recorded.calls.create).toHaveLength(1)
|
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({
|
expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({
|
||||||
test: { type: "api", key: "secret" },
|
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", () => {
|
it.live("remote create connects to routed event and history endpoints", () => {
|
||||||
const calls: FetchCall[] = []
|
const calls: FetchCall[] = []
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|
@ -557,7 +695,7 @@ describe("workspace-old CRUD", () => {
|
||||||
yield* provideTmpdirInstance(
|
yield* provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const workspace = yield* WorkspaceOld.Service
|
const workspace = yield* Workspace.Service
|
||||||
const type = unique("remote-create")
|
const type = unique("remote-create")
|
||||||
const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir })
|
const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir })
|
||||||
registerAdapter(Instance.project.id, type, recorded.adapter)
|
registerAdapter(Instance.project.id, type, recorded.adapter)
|
||||||
|
|
@ -754,7 +892,7 @@ describe("workspace-old CRUD", () => {
|
||||||
yield* provideTmpdirInstance(
|
yield* provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const workspace = yield* WorkspaceOld.Service
|
const workspace = yield* Workspace.Service
|
||||||
const sessionSvc = yield* SessionNs.Service
|
const sessionSvc = yield* SessionNs.Service
|
||||||
const previousType = unique("warp-remote-source")
|
const previousType = unique("warp-remote-source")
|
||||||
const targetType = unique("warp-remote-target")
|
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 () => {
|
test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => {
|
||||||
await withInstance(async (dir) => {
|
await withInstance(async (dir) => {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
|
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) => {
|
await withInstance(async (dir) => {
|
||||||
const withSessionType = unique("with-session")
|
const firstType = unique("first")
|
||||||
const withoutSessionType = unique("without-session")
|
const secondType = unique("second")
|
||||||
const withSession = workspaceInfo(Instance.project.id, withSessionType)
|
const first = workspaceInfo(Instance.project.id, firstType)
|
||||||
const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType)
|
const second = workspaceInfo(Instance.project.id, secondType)
|
||||||
const withSessionDir = path.join(dir, "with-session")
|
await fs.mkdir(path.join(dir, "first"), { recursive: true })
|
||||||
const withoutSessionDir = path.join(dir, "without-session")
|
await fs.mkdir(path.join(dir, "second"), { recursive: true })
|
||||||
await fs.mkdir(withSessionDir, { recursive: true })
|
insertWorkspace(first)
|
||||||
await fs.mkdir(withoutSessionDir, { recursive: true })
|
insertWorkspace(second)
|
||||||
insertWorkspace(withSession)
|
registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter)
|
||||||
insertWorkspace(withoutSession)
|
registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
startWorkspaceSyncing(Instance.project.id)
|
startWorkspaceSyncing(Instance.project.id)
|
||||||
|
|
||||||
await eventually(() =>
|
await eventually(() =>
|
||||||
workspaceStatus().then((status) =>
|
workspaceStatus().then((status) => {
|
||||||
expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"),
|
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(first.id)
|
||||||
await removeWorkspace(withSession.id)
|
await removeWorkspace(second.id)
|
||||||
await removeWorkspace(withoutSession.id)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -907,7 +1039,7 @@ describe("workspace-old sync state", () => {
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
captured.events.filter(
|
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)
|
).toHaveLength(1)
|
||||||
await removeWorkspace(info.id)
|
await removeWorkspace(info.id)
|
||||||
|
|
@ -941,7 +1073,7 @@ describe("workspace-old sync state", () => {
|
||||||
yield* provideTmpdirInstance(
|
yield* provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const workspace = yield* WorkspaceOld.Service
|
const workspace = yield* Workspace.Service
|
||||||
const sessionSvc = yield* SessionNs.Service
|
const sessionSvc = yield* SessionNs.Service
|
||||||
const captured = captureGlobalEvents()
|
const captured = captureGlobalEvents()
|
||||||
try {
|
try {
|
||||||
|
|
@ -965,7 +1097,7 @@ describe("workspace-old sync state", () => {
|
||||||
expect(
|
expect(
|
||||||
captured.events
|
captured.events
|
||||||
.filter(
|
.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),
|
.map((event) => event.payload.properties.status),
|
||||||
).toEqual(["disconnected", "connecting", "connected"])
|
).toEqual(["disconnected", "connecting", "connected"])
|
||||||
|
|
@ -998,7 +1130,7 @@ describe("workspace-old sync state", () => {
|
||||||
yield* provideTmpdirInstance(
|
yield* provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const workspace = yield* WorkspaceOld.Service
|
const workspace = yield* Workspace.Service
|
||||||
const sessionSvc = yield* SessionNs.Service
|
const sessionSvc = yield* SessionNs.Service
|
||||||
const type = unique("remote-connect-fail")
|
const type = unique("remote-connect-fail")
|
||||||
const info = workspaceInfo(Instance.project.id, type)
|
const info = workspaceInfo(Instance.project.id, type)
|
||||||
|
|
@ -1038,7 +1170,7 @@ describe("workspace-old sync state", () => {
|
||||||
yield* provideTmpdirInstance(
|
yield* provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const workspace = yield* WorkspaceOld.Service
|
const workspace = yield* Workspace.Service
|
||||||
const sessionSvc = yield* SessionNs.Service
|
const sessionSvc = yield* SessionNs.Service
|
||||||
const type = unique("remote-history-fail")
|
const type = unique("remote-history-fail")
|
||||||
const info = workspaceInfo(Instance.project.id, type)
|
const info = workspaceInfo(Instance.project.id, type)
|
||||||
|
|
@ -1093,7 +1225,7 @@ describe("workspace-old sync state", () => {
|
||||||
yield* provideTmpdirInstance(
|
yield* provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const workspace = yield* WorkspaceOld.Service
|
const workspace = yield* Workspace.Service
|
||||||
const sessionSvc = yield* SessionNs.Service
|
const sessionSvc = yield* SessionNs.Service
|
||||||
const captured = captureGlobalEvents()
|
const captured = captureGlobalEvents()
|
||||||
try {
|
try {
|
||||||
|
|
@ -1160,7 +1292,7 @@ describe("workspace-old sync state", () => {
|
||||||
yield* provideTmpdirInstance(
|
yield* provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const workspace = yield* WorkspaceOld.Service
|
const workspace = yield* Workspace.Service
|
||||||
const sessionSvc = yield* SessionNs.Service
|
const sessionSvc = yield* SessionNs.Service
|
||||||
const captured = captureGlobalEvents()
|
const captured = captureGlobalEvents()
|
||||||
try {
|
try {
|
||||||
|
|
@ -1241,7 +1373,7 @@ describe("workspace-old sync state", () => {
|
||||||
yield* provideTmpdirInstance(
|
yield* provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const workspace = yield* WorkspaceOld.Service
|
const workspace = yield* Workspace.Service
|
||||||
const sessionSvc = yield* SessionNs.Service
|
const sessionSvc = yield* SessionNs.Service
|
||||||
const captured = captureGlobalEvents()
|
const captured = captureGlobalEvents()
|
||||||
try {
|
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 () => {
|
test("returns immediately for an empty fence", async () => {
|
||||||
await withInstance(async () => {
|
await withInstance(async () => {
|
||||||
await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined()
|
await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined()
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,35 @@ describe("Worktree", () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("list", () => {
|
||||||
|
it.live("uses parent folder name when worktree basename matches the primary worktree", () =>
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Worktree.Service
|
||||||
|
const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`)
|
||||||
|
const target = path.join(parent, path.basename(dir))
|
||||||
|
const branch = `same-basename-list-${Date.now()}`
|
||||||
|
|
||||||
|
yield* Effect.promise(() => fs.mkdir(parent, { recursive: true }))
|
||||||
|
yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet())
|
||||||
|
|
||||||
|
const list = yield* svc.list()
|
||||||
|
const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target))
|
||||||
|
|
||||||
|
expect(list).toContainEqual({
|
||||||
|
name: path.basename(parent),
|
||||||
|
branch,
|
||||||
|
directory,
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* svc.remove({ directory: target })
|
||||||
|
}),
|
||||||
|
{ git: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
describe("remove edge cases", () => {
|
describe("remove edge cases", () => {
|
||||||
it.live("remove non-existent directory succeeds silently", () =>
|
it.live("remove non-existent directory succeeds silently", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
|
||||||
return {
|
return {
|
||||||
name: "Remote Test",
|
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", () =>
|
it.live("creates workspace with the TUI payload shape", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import type {
|
||||||
ExperimentalWorkspaceRemoveErrors,
|
ExperimentalWorkspaceRemoveErrors,
|
||||||
ExperimentalWorkspaceRemoveResponses,
|
ExperimentalWorkspaceRemoveResponses,
|
||||||
ExperimentalWorkspaceStatusResponses,
|
ExperimentalWorkspaceStatusResponses,
|
||||||
|
ExperimentalWorkspaceSyncListResponses,
|
||||||
ExperimentalWorkspaceWarpErrors,
|
ExperimentalWorkspaceWarpErrors,
|
||||||
ExperimentalWorkspaceWarpResponses,
|
ExperimentalWorkspaceWarpResponses,
|
||||||
FileListResponses,
|
FileListResponses,
|
||||||
|
|
@ -949,6 +950,36 @@ export class Workspace extends HeyApiClient {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync workspace list
|
||||||
|
*
|
||||||
|
* Register missing workspaces returned by workspace adapters.
|
||||||
|
*/
|
||||||
|
public syncList<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "directory" },
|
||||||
|
{ in: "query", key: "workspace" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<ExperimentalWorkspaceSyncListResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/experimental/workspace/sync-list",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workspace status
|
* Workspace status
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1749,6 +1749,7 @@ export type Workspace = {
|
||||||
directory: string | null
|
directory: string | null
|
||||||
extra: unknown | null
|
extra: unknown | null
|
||||||
projectID: string
|
projectID: string
|
||||||
|
timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceWarpError = {
|
export type WorkspaceWarpError = {
|
||||||
|
|
@ -6700,6 +6701,26 @@ export type ExperimentalWorkspaceCreateResponses = {
|
||||||
export type ExperimentalWorkspaceCreateResponse =
|
export type ExperimentalWorkspaceCreateResponse =
|
||||||
ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
|
ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
|
||||||
|
|
||||||
|
export type ExperimentalWorkspaceSyncListData = {
|
||||||
|
body?: never
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
url: "/experimental/workspace/sync-list"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExperimentalWorkspaceSyncListResponses = {
|
||||||
|
/**
|
||||||
|
* Workspace list synced
|
||||||
|
*/
|
||||||
|
204: void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExperimentalWorkspaceSyncListResponse =
|
||||||
|
ExperimentalWorkspaceSyncListResponses[keyof ExperimentalWorkspaceSyncListResponses]
|
||||||
|
|
||||||
export type ExperimentalWorkspaceStatusData = {
|
export type ExperimentalWorkspaceStatusData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|
|
||||||
|
|
@ -8417,6 +8417,43 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/experimental/workspace/sync-list": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["workspace"],
|
||||||
|
"operationId": "experimental.workspace.syncList",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "directory",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "workspace",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Workspace list synced"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Register missing workspaces returned by workspace adapters.",
|
||||||
|
"summary": "Sync workspace list",
|
||||||
|
"x-codeSamples": [
|
||||||
|
{
|
||||||
|
"lang": "js",
|
||||||
|
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.syncList({\n ...\n})"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/experimental/workspace/status": {
|
"/experimental/workspace/status": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["workspace"],
|
"tags": ["workspace"],
|
||||||
|
|
@ -13653,9 +13690,32 @@
|
||||||
},
|
},
|
||||||
"projectID": {
|
"projectID": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timeUsed": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["NaN"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["Infinity"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["-Infinity"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["Infinity", "-Infinity", "NaN"]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID"],
|
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID", "timeUsed"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"WorkspaceWarpError": {
|
"WorkspaceWarpError": {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue