Merge branch 'dev' into security/effect-httpapi-root-auth

This commit is contained in:
Rajvardhan Patil
2026-05-08 21:20:11 +05:30
committed by GitHub
28 changed files with 2146 additions and 215 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -93,9 +93,29 @@ import { useBindings, useCommandShortcut } from "../../keymap"
addDefaultParsers(parsers.parsers)
const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at"
const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show"
const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at"
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
function goUpsellKeys(action: SessionRetry.Retryable["action"]) {
if (!action) return
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
if (action.reason === "free_tier_limit") {
return {
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
}
}
if (action.reason === "account_rate_limit") {
return {
lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
}
}
}
const context = createContext<{
width: number
@@ -263,14 +283,17 @@ export function Session() {
if (!evt.properties.status.action) return
if (dialog.stack.length > 0) return
const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
const keys = goUpsellKeys(evt.properties.status.action)
if (!keys) return
const seen = kv.get(keys.lastSeenAt)
if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
if (kv.get(GO_UPSELL_DONT_SHOW)) return
if (kv.get(keys.dontShow)) return
void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => {
if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
if (dontShowAgain) kv.set(keys.dontShow, true)
kv.set(keys.lastSeenAt, Date.now())
})
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdapter } from "./adapters"
import { getAdapter, registeredAdapters } from "./adapters"
import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { Session } from "@/session/session"
@@ -35,8 +35,13 @@ import { Vcs } from "@/project/vcs"
import { InstanceStore } from "@/project/instance-store"
import { InstanceBootstrap } from "@/project/bootstrap"
export const Info = WorkspaceInfoSchema
export type Info = WorkspaceInfo
export const Info = Schema.Struct({
...WorkspaceInfoSchema.fields,
timeUsed: Schema.Number,
})
.annotate({ identifier: "Workspace" })
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
export type Info = WorkspaceInfo & { timeUsed: number }
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
@@ -69,6 +74,7 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
directory: row.directory,
extra: row.extra,
projectID: row.project_id,
timeUsed: row.time_used,
}
}
@@ -150,6 +156,7 @@ export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
readonly syncList: (project: Project.Info) => Effect.Effect<void>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly status: () => Effect.Effect<ConnectionStatus[]>
@@ -483,7 +490,19 @@ export const layer = Layer.effect(
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adapter = getAdapter(space.projectID, space.type)
const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(space.id, "error")
log.warn("workspace target failed", {
workspaceID: space.id,
error: errorData(error),
})
return null
}),
),
)
if (!target) return
if (target.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
@@ -523,7 +542,13 @@ export const layer = Layer.effect(
const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* EffectBridge.fromPromise(() =>
adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }),
adapter.configure({
...input,
id,
name: Slug.create(),
directory: null,
extra: input.extra ?? null,
}),
)
const info: Info = {
@@ -534,6 +559,7 @@ export const layer = Layer.effect(
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
timeUsed: Date.now(),
}
yield* db((db) => {
@@ -546,6 +572,7 @@ export const layer = Layer.effect(
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run()
})
@@ -828,6 +855,63 @@ export const layer = Layer.effect(
)
})
const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) {
const names = new Set((yield* list(project)).map((workspace) => workspace.name))
const discovered = yield* Effect.forEach(
registeredAdapters(project.id),
([type, adapter]) =>
adapter.list
? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe(
Effect.catchCause((error) =>
Effect.sync(() => {
log.warn("workspace adapter list failed", { type, error })
return []
}),
),
)
: Effect.succeed([]),
{ concurrency: "unbounded" },
).pipe(Effect.map((items) => items.flat()))
yield* Effect.forEach(
discovered,
(item) =>
Effect.gen(function* () {
if (names.has(item.name)) return
names.add(item.name)
const info: Info = {
id: WorkspaceID.ascending(),
type: item.type,
branch: item.branch,
name: item.name,
directory: item.directory,
extra: item.extra,
projectID: item.projectID,
timeUsed: Date.now(),
}
yield* db((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run()
})
yield* startSync(info)
}),
{ concurrency: 1 },
)
})
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) {
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
@@ -916,13 +1000,10 @@ export const layer = Layer.effect(
})
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) {
// This session table join makes this query only return
// workspaces that have sessions
const rows = yield* db((db) =>
db
.selectDistinct({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
.where(eq(WorkspaceTable.project_id, projectID))
.all(),
)
@@ -947,6 +1028,7 @@ export const layer = Layer.effect(
create,
sessionWarp,
list,
syncList,
get,
remove,
status,

View File

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

View File

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

View File

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

View File

@@ -701,6 +701,7 @@ export const layer: Layer.Layer<
),
Effect.retry(
SessionRetry.policy({
provider: input.model.providerID,
parse,
set: (info) => {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.

View File

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

View File

@@ -7,10 +7,13 @@ export type Err = ReturnType<NamedError["toObject"]>
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
export const GO_UPSELL_URL = "https://opencode.ai/go"
export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {})
export type Retryable = {
message: string
action?: {
reason: RetryReason
provider: string
title: string
message: string
label: string
@@ -60,7 +63,7 @@ export function delay(attempt: number, error?: MessageV2.APIError) {
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
}
export function retryable(error: Err) {
export function retryable(error: Err, provider: string) {
// context overflow errors should not be retried
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
@@ -72,6 +75,8 @@ export function retryable(error: Err) {
return {
message: GO_UPSELL_MESSAGE,
action: {
reason: "free_tier_limit",
provider,
title: "Free limit reached",
message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
label: "subscribe",
@@ -97,12 +102,14 @@ export function retryable(error: Err) {
return minutes > 0 ? unit(minutes, "minute") : "less than a minute"
})
const message = `${limitName} usage limit reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance`
const message = `${limitName ? `${limitName} usage limit` : "Usage limit"} reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance`
const link = `https://opencode.ai/workspace/${workspace}/go`
return {
message: `${message} - ${link}`,
action: {
reason: "account_rate_limit",
provider,
title: "Go limit reached",
message,
label: "open settings",
@@ -165,13 +172,14 @@ function parseJSON(value: unknown) {
}
export function policy(opts: {
provider: string
parse: (error: unknown) => Err
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
}) {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const retry = retryable(error)
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)

View File

@@ -17,6 +17,8 @@ export const Info = Schema.Union([
message: Schema.String,
action: Schema.optional(
Schema.Struct({
reason: Schema.String,
provider: Schema.String,
title: Schema.String,
message: Schema.String,
label: Schema.String,

View File

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

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,10 +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(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
),
Workspace.defaultLayer.pipe(Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer)),
SessionNs.defaultLayer,
)
const it = testEffect(testServerLayer)
@@ -64,6 +61,7 @@ type RecordedAdapter = {
calls: {
configure: WorkspaceInfo[]
create: RecordedCreate[]
list: number
remove: WorkspaceInfo[]
target: WorkspaceInfo[]
}
@@ -125,23 +123,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 +187,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 +215,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 +282,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 +291,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 +307,7 @@ function insertWorkspace(info: WorkspaceInfo) {
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run(),
)
@@ -348,11 +360,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 +376,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 +459,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 +553,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 +692,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 +889,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 +940,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 +958,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 +1036,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 +1070,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 {
@@ -964,9 +1093,7 @@ describe("workspace-old sync state", () => {
expect(
captured.events
.filter(
(event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
)
.filter((event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type)
.map((event) => event.payload.properties.status),
).toEqual(["disconnected", "connecting", "connected"])
expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1)
@@ -998,7 +1125,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 +1165,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 +1220,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 +1287,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 +1368,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 +1407,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

View File

@@ -13,6 +13,7 @@ import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const providerID = ProviderID.make("test")
const retryProvider = "test"
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
function apiError(headers?: Record<string, string>): MessageV2.APIError {
@@ -92,6 +93,7 @@ describe("session.retry.delay", () => {
const step = yield* Schedule.toStepWithMetadata(
SessionRetry.policy({
provider: "test",
parse: (err) => MessageV2.APIError.Schema.parse(err),
set: (info) =>
status.set(sessionID, {
@@ -118,47 +120,47 @@ describe("session.retry.delay", () => {
describe("session.retry.retryable", () => {
test("maps too_many_requests json messages", () => {
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
expect(SessionRetry.retryable(error)).toEqual({ message: "Too Many Requests" })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
})
test("maps overloaded provider codes", () => {
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
expect(SessionRetry.retryable(error)).toEqual({ message: "Provider is overloaded" })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
})
test("does not retry unknown json messages", () => {
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
expect(SessionRetry.retryable(error)).toBeUndefined()
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("does not throw on numeric error codes", () => {
const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
const result = SessionRetry.retryable(error)
const result = SessionRetry.retryable(error, retryProvider)
expect(result).toBeUndefined()
})
test("returns undefined for non-json message", () => {
const error = wrap("not-json")
expect(SessionRetry.retryable(error)).toBeUndefined()
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("retries plain text rate limit errors from Alibaba", () => {
const msg =
"Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
const error = wrap(msg)
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test("retries plain text rate limit errors", () => {
const msg = "Rate limit exceeded, please try again later"
const error = wrap(msg)
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test("retries too many requests in plain text", () => {
const msg = "Too many requests, please slow down"
const error = wrap(msg)
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test("does not retry context overflow errors", () => {
@@ -167,7 +169,7 @@ describe("session.retry.retryable", () => {
responseBody: '{"error":{"code":"context_length_exceeded"}}',
}).toObject()
expect(SessionRetry.retryable(error)).toBeUndefined()
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("retries 500 errors even when isRetryable is false", () => {
@@ -180,7 +182,7 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toEqual({ message: "Internal server error" })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Internal server error" })
})
test("retries 502 bad gateway errors", () => {
@@ -192,7 +194,7 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toEqual({ message: "Bad gateway" })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Bad gateway" })
})
test("retries 503 service unavailable errors", () => {
@@ -204,7 +206,7 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toEqual({ message: "Service unavailable" })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Service unavailable" })
})
test("does not retry 4xx errors when isRetryable is false", () => {
@@ -216,7 +218,7 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toBeUndefined()
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("retries ZlibError decompression failures", () => {
@@ -228,7 +230,7 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
const retryable = SessionRetry.retryable(error)
const retryable = SessionRetry.retryable(error, retryProvider)
expect(retryable).toBeDefined()
expect(retryable).toEqual({ message: "Response decompression failed" })
})
@@ -246,9 +248,11 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toEqual({
expect(SessionRetry.retryable(error, "opencode")).toEqual({
message: SessionRetry.GO_UPSELL_MESSAGE,
action: {
reason: "free_tier_limit",
provider: "opencode",
title: "Free limit reached",
message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
label: "subscribe",
@@ -280,10 +284,12 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toEqual({
expect(SessionRetry.retryable(error, "opencode-go")).toEqual({
message:
"5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go",
action: {
reason: "account_rate_limit",
provider: "opencode-go",
title: "Go limit reached",
message:
"5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance",
@@ -292,6 +298,33 @@ describe("session.retry.retryable", () => {
},
})
})
test("maps Go subscription limits without limit metadata", () => {
const error = MessageV2.APIError.Schema.parse(
new MessageV2.APIError({
message: "Subscription quota exceeded. You can continue using free models.",
isRetryable: true,
statusCode: 429,
responseHeaders: {
"retry-after": "900",
},
responseBody: JSON.stringify({
type: "error",
error: {
type: "GoUsageLimitError",
message: "Subscription quota exceeded. You can continue using free models.",
},
metadata: {
workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH",
},
}),
}).toObject(),
)
expect(SessionRetry.retryable(error, "opencode-go")?.action?.message).toBe(
"Usage limit reached. It will reset in 15 minutes. To continue using this model now, enable usage from your available balance",
)
})
})
describe("session.message-v2.fromError", () => {
@@ -341,7 +374,7 @@ describe("session.message-v2.fromError", () => {
}).toObject(),
)
const retryable = SessionRetry.retryable(error)
const retryable = SessionRetry.retryable(error, retryProvider)
expect(retryable).toBeDefined()
expect(retryable).toEqual({ message: "Connection reset by server" })
})
@@ -381,6 +414,8 @@ describe("session.message-v2.fromError", () => {
expect(MessageV2.APIError.isInstance(result)).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
expect(SessionRetry.retryable(result)).toEqual({ message: "An error occurred while processing your request." })
expect(SessionRetry.retryable(result, retryProvider)).toEqual({
message: "An error occurred while processing your request.",
})
})
})

View File

@@ -236,6 +236,8 @@ describe("SessionStatus.Info", () => {
attempt: 1,
message: "transient",
action: {
reason: "free_tier_limit",
provider: "opencode",
title: "Free limit reached",
message: "Subscribe to OpenCode Go.",
label: "subscribe",

View File

@@ -36,6 +36,7 @@ import type {
ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses,
ExperimentalWorkspaceStatusResponses,
ExperimentalWorkspaceSyncListResponses,
ExperimentalWorkspaceWarpErrors,
ExperimentalWorkspaceWarpResponses,
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
*

View File

@@ -267,6 +267,8 @@ export type SessionStatus =
attempt: number
message: string
action?: {
reason: string
provider: string
title: string
message: string
label: string
@@ -1755,6 +1757,7 @@ export type Workspace = {
directory: string | null
extra: unknown | null
projectID: string
timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
export type WorkspaceWarpError = {
@@ -6706,6 +6709,26 @@ export type ExperimentalWorkspaceCreateResponses = {
export type ExperimentalWorkspaceCreateResponse =
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 = {
body?: never
path?: never

View File

@@ -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": {
"get": {
"tags": ["workspace"],
@@ -9404,6 +9441,12 @@
"action": {
"type": "object",
"properties": {
"reason": {
"type": "string"
},
"provider": {
"type": "string"
},
"title": {
"type": "string"
},
@@ -9417,7 +9460,7 @@
"type": "string"
}
},
"required": ["title", "message", "label"],
"required": ["reason", "provider", "title", "message", "label"],
"additionalProperties": false
},
"next": {
@@ -13672,9 +13715,32 @@
},
"projectID": {
"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
},
"WorkspaceWarpError": {

View File

@@ -1,37 +0,0 @@
const retryAfterSeconds = 15 * 60
// const response = {
// type: "error",
// error: {
// type: "FreeUsageLimitError",
// message: "Free usage exceeded, subscribe to Go https://opencode.ai/go",
// },
// metadata: {},
// }
const response = {
type: "error",
error: {
type: "GoUsageLimitError",
message: "Subscription quota exceeded. You can continue using free models.",
},
metadata: {
workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH",
limit: "5 hour",
resetAt: retryAfterSeconds,
},
}
Bun.serve({
port: 4141,
fetch() {
return Response.json(response, {
status: 429,
headers: {
"retry-after": String(retryAfterSeconds),
},
})
},
})
console.log("Zen limit repro server listening on http://localhost:4141")