Skip to content

Commit a02b0a4

Browse files
authored
fix(core): make workspace destroy idempotent (#44769)
1 parent d8ce27f commit a02b0a4

16 files changed

Lines changed: 180 additions & 21 deletions

File tree

packages/client/src/effect/api/api.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,6 +1695,16 @@ export interface WorktreeApi<E = never> {
16951695
readonly refresh: WorktreeRefreshOperation<E>
16961696
}
16971697

1698+
export type WorkspaceDestroyInput = { readonly workspaceID: Workspace.ID }
1699+
export type WorkspaceDestroyOutput = Workspace.DestroyResult
1700+
export type WorkspaceDestroyOperation<E = never> = (
1701+
input: WorkspaceDestroyInput,
1702+
) => Effect.Effect<WorkspaceDestroyOutput, E>
1703+
1704+
export interface WorkspaceApi<E = never> {
1705+
readonly destroy: WorkspaceDestroyOperation<E>
1706+
}
1707+
16981708
export type VcsGetInput = {
16991709
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
17001710
}
@@ -1812,6 +1822,7 @@ export interface AppApi<E = never> {
18121822
readonly shell: ShellApi<E>
18131823
readonly reference: ReferenceApi<E>
18141824
readonly worktree: WorktreeApi<E>
1825+
readonly workspace: WorkspaceApi<E>
18151826
readonly vcs: VcsApi<E>
18161827
readonly debug: DebugApi<E>
18171828
readonly migration: MigrationApi<E>

packages/client/src/effect/generated/client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ import type {
214214
WorktreeRemoveOutput,
215215
WorktreeRefreshInput,
216216
WorktreeRefreshOutput,
217+
WorkspaceDestroyInput,
218+
WorkspaceDestroyOutput,
217219
VcsGetInput,
218220
VcsGetOutput,
219221
VcsStatusInput,
@@ -1271,6 +1273,13 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
12711273
refresh: EndpointWorktreeRefresh(raw),
12721274
})
12731275

1276+
const EndpointWorkspaceDestroy = (raw: RawClient["server.workspace"]) => (input: WorkspaceDestroyInput) =>
1277+
preserveEffect<WorkspaceDestroyOutput>()(
1278+
raw["workspace.destroy"]({ params: { workspaceID: input["workspaceID"] } }).pipe(Effect.mapError(mapClientError)),
1279+
)
1280+
1281+
const adaptGroupWorkspace = (raw: RawClient["server.workspace"]) => ({ destroy: EndpointWorkspaceDestroy(raw) })
1282+
12741283
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
12751284
preserveEffect<VcsGetOutput>()(
12761285
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1361,6 +1370,7 @@ const adaptClient = (raw: RawClient) => ({
13611370
shell: adaptGroupShell(raw["server.shell"]),
13621371
reference: adaptGroupReference(raw["server.reference"]),
13631372
worktree: adaptGroupWorktree(raw["server.worktree"]),
1373+
workspace: adaptGroupWorkspace(raw["server.workspace"]),
13641374
vcs: adaptGroupVcs(raw["server.vcs"]),
13651375
debug: adaptGroupDebug(raw["server.debug"]),
13661376
migration: adaptGroupMigration(raw["server.migration"]),

packages/client/src/promise/generated/client.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ import type {
210210
WorktreeRemoveOutput,
211211
WorktreeRefreshInput,
212212
WorktreeRefreshOutput,
213+
WorkspaceDestroyInput,
214+
WorkspaceDestroyOutput,
213215
VcsGetInput,
214216
VcsGetOutput,
215217
VcsStatusInput,
@@ -1766,6 +1768,19 @@ export function make(options: ClientOptions) {
17661768
requestOptions,
17671769
),
17681770
},
1771+
workspace: {
1772+
destroy: (input: WorkspaceDestroyInput, requestOptions?: RequestOptions) =>
1773+
request<WorkspaceDestroyOutput>(
1774+
{
1775+
method: "DELETE",
1776+
path: `/api/workspace/${encodeURIComponent(input.workspaceID)}`,
1777+
successStatus: 200,
1778+
declaredStatuses: [500, 401, 400],
1779+
empty: false,
1780+
},
1781+
requestOptions,
1782+
),
1783+
},
17691784
vcs: {
17701785
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
17711786
request<VcsGetOutput>(

packages/client/src/promise/generated/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ export type WorktreeDirectory = { directory: string; strategy?: string }
187187

188188
export type WorktreeInfo = { directory: string }
189189

190+
export type WorkspaceDestroyResult = { destroyed: boolean }
191+
190192
export type VcsBranch = { current?: string; default?: string }
191193

192194
export type VcsFileStatus = {
@@ -5653,6 +5655,10 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
56535655

56545656
export type WorktreeRefreshOutput = void
56555657

5658+
export type WorkspaceDestroyInput = { readonly workspaceID: { readonly workspaceID: string }["workspaceID"] }
5659+
5660+
export type WorkspaceDestroyOutput = WorkspaceDestroyResult
5661+
56565662
export type VcsGetInput = {
56575663
readonly location?: {
56585664
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined

packages/client/test/promise.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ test("exposes every standard HTTP API group", () => {
3030
"question",
3131
"reference",
3232
"worktree",
33+
"workspace",
3334
"vcs",
3435
"debug",
3536
"migration",
@@ -280,6 +281,21 @@ test("worktree methods use the global project contract", async () => {
280281
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
281282
})
282283

284+
test("workspace.destroy returns the transition result", async () => {
285+
let request: Request | undefined
286+
const client = OpenCode.make({
287+
baseUrl: "http://localhost:3000",
288+
fetch: async (input, init) => {
289+
request = input instanceof Request ? input : new Request(input, init)
290+
return Response.json({ destroyed: false })
291+
},
292+
})
293+
294+
expect(await client.workspace.destroy({ workspaceID: "wrk_missing" })).toEqual({ destroyed: false })
295+
expect(request?.method).toBe("DELETE")
296+
expect(request?.url).toBe("http://localhost:3000/api/workspace/wrk_missing")
297+
})
298+
283299
test("shell list and remove use the public HTTP contract", async () => {
284300
const requests: Array<{ method: string; url: string }> = []
285301
const shell = {

packages/core/src/workspace.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ export interface Interface {
3535
readonly connect: (
3636
workspaceID: ID,
3737
) => Effect.Effect<EnvironmentDriver, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
38-
readonly destroy: (
39-
workspaceID: ID,
40-
) => Effect.Effect<void, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
38+
/** Makes the workspace absent; reports whether this call destroyed an existing workspace. */
39+
readonly destroy: (workspaceID: ID) => Effect.Effect<
40+
Workspace.DestroyResult,
41+
WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound
42+
>
4143
}
4244

4345
export interface Options {
@@ -79,13 +81,16 @@ const layer = (options: Options) =>
7981
const fork = yield* FiberSet.makeRuntime<never, void, never>()
8082
const idleThreshold = Duration.toMillis(options.idleThreshold ?? Duration.minutes(20))
8183

82-
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
83-
const row = yield* db
84+
const find = (workspaceID: ID) =>
85+
db
8486
.select()
8587
.from(WorkspaceTable)
8688
.where(eq(WorkspaceTable.id, workspaceID))
8789
.get()
8890
.pipe(Effect.orDie)
91+
92+
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
93+
const row = yield* find(workspaceID)
8994
if (!row) return yield* new NotFound({ workspaceID })
9095
return row
9196
})
@@ -267,9 +272,10 @@ const layer = (options: Options) =>
267272
attempts.delete(workspaceID)
268273
Deferred.doneUnsafe(attempt, Exit.fail(new NotFound({ workspaceID })))
269274
}
270-
yield* locks.withLock(workspaceID)(
275+
return yield* locks.withLock(workspaceID)(
271276
Effect.gen(function* () {
272-
const row = yield* load(workspaceID)
277+
const row = yield* find(workspaceID)
278+
if (!row) return { destroyed: false }
273279
const connection = connections.get(workspaceID)
274280
connections.delete(workspaceID)
275281
if (connection) yield* Scope.close(connection.scope, Exit.void)
@@ -284,6 +290,7 @@ const layer = (options: Options) =>
284290
),
285291
)
286292
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
293+
return { destroyed: true }
287294
}),
288295
)
289296
}),

packages/core/test/workspace.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ it.effect("destroys an unprovisioned workspace through the driver with a null bi
9494
const workspace = yield* Workspace.Service
9595
const workspaceID = yield* workspace.create("fake")
9696

97-
yield* workspace.destroy(workspaceID)
97+
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: true })
9898
expect(calls).toEqual([{ operation: "destroy", binding: null }])
9999
expect(
100100
yield* Database.Service.use(({ db }) =>
@@ -104,6 +104,27 @@ it.effect("destroys an unprovisioned workspace through the driver with a null bi
104104
}),
105105
)
106106

107+
it.effect("succeeds without calling the driver when the workspace does not exist", () =>
108+
Effect.gen(function* () {
109+
const workspace = yield* Workspace.Service
110+
const workspaceID = Workspace.ID.create()
111+
112+
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: false })
113+
expect(calls).toEqual([])
114+
}),
115+
)
116+
117+
it.effect("reports whether destroy removed an existing workspace", () =>
118+
Effect.gen(function* () {
119+
const workspace = yield* Workspace.Service
120+
const workspaceID = yield* workspace.create("fake")
121+
122+
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: true })
123+
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: false })
124+
expect(calls).toEqual([{ operation: "destroy", binding: null }])
125+
}),
126+
)
127+
107128
it.effect("starts eager provisioning in the background and lets callers join it", () =>
108129
Effect.gen(function* () {
109130
const workspace = yield* Workspace.Service

packages/protocol/src/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { WorktreeGroup } from "./groups/worktree.js"
3232
import { VcsGroup } from "./groups/vcs.js"
3333
import { MigrationGroup } from "./groups/migration.js"
3434
import { ConfigGroup } from "./groups/config.js"
35+
import { WorkspaceGroup } from "./groups/workspace.js"
3536

3637
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
3738
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
@@ -86,6 +87,7 @@ type ApiGroups<
8687
| typeof DebugGroup
8788
| typeof MigrationGroup
8889
| typeof WorktreeGroup
90+
| typeof WorkspaceGroup
8991
| LocationGroups<LocationId>
9092
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
9193
| SessionGroups<SessionLocationId, SessionLocationService>
@@ -169,6 +171,7 @@ const makeApiFromGroup = <
169171
.add(ShellGroup.middleware(locationMiddleware))
170172
.add(ReferenceGroup.middleware(locationMiddleware))
171173
.add(WorktreeGroup)
174+
.add(WorkspaceGroup)
172175
.add(VcsGroup.middleware(locationMiddleware))
173176
.add(DebugGroup)
174177
.add(MigrationGroup)

packages/protocol/src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const groupNames = {
6060
"server.reference": "reference",
6161
"server.project": "project",
6262
"server.worktree": "worktree",
63+
"server.workspace": "workspace",
6364
"server.vcs": "vcs",
6465
"server.config": "config",
6566
} as const
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Workspace } from "@opencode-ai/schema/workspace"
2+
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
3+
import { UnknownError } from "../errors.js"
4+
5+
export const WorkspaceGroup = HttpApiGroup.make("server.workspace")
6+
.add(
7+
HttpApiEndpoint.delete("workspace.destroy", "/api/workspace/:workspaceID", {
8+
params: { workspaceID: Workspace.ID },
9+
success: Workspace.DestroyResult,
10+
error: UnknownError,
11+
}).annotateMerge(
12+
OpenApi.annotations({
13+
identifier: "v2.workspace.destroy",
14+
summary: "Destroy workspace",
15+
description:
16+
"Make a workspace not exist. This operation is idempotent: an already-missing workspace succeeds with `destroyed: false`, while a workspace removed by this request returns `destroyed: true`.",
17+
}),
18+
),
19+
)
20+
.annotateMerge(OpenApi.annotations({ title: "workspace", description: "Workspace lifecycle routes." }))

0 commit comments

Comments
 (0)