Refactor workspace service boundaries (#25152)

This commit is contained in:
Kit Langton
2026-04-30 15:34:37 -04:00
committed by GitHub
parent b315a70773
commit 0e9d9282c6
14 changed files with 433 additions and 417 deletions

View File

@@ -5,6 +5,8 @@ import { EventSequenceTable } from "@/sync/event.sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
import * as Log from "@opencode-ai/core/util/log"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
const HEADER = "x-opencode-sync"
type State = Record<string, number>
@@ -54,16 +56,22 @@ export function parse(headers: Headers) {
) as State
}
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
return Effect.gen(function* () {
log.info("waiting for state", {
workspaceID,
state,
})
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
log.info("state fully synced", {
workspaceID,
state,
})
})
}
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
log.info("waiting for state", {
workspaceID,
state,
})
await Workspace.waitForSync(workspaceID, state, signal)
log.info("state fully synced", {
workspaceID,
state,
})
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
}
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {

View File

@@ -4,6 +4,7 @@ import * as Log from "@opencode-ai/core/util/log"
import * as Fence from "./fence"
import type { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { ProxyUtil } from "./proxy-util"
import { Effect, Stream } from "effect"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
@@ -69,18 +70,17 @@ function statusText(response: unknown) {
}
export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
if (!Workspace.isSyncing(workspaceID)) {
return Effect.succeed(
new Response(`broken sync connection for workspace: ${workspaceID}`, {
return Effect.gen(function* () {
const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID))
if (!syncing) {
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
},
}),
)
}
})
}
return Effect.gen(function* () {
const response = yield* HttpClient.execute(
HttpClientRequest.make(req.method as never)(url, {
headers: ProxyUtil.headers(req, extra),
@@ -100,7 +100,7 @@ export function httpEffect(url: string | URL, extra: HeadersInit | undefined, re
next.delete("content-encoding")
next.delete("content-length")
if (sync) yield* Effect.promise(() => Fence.wait(workspaceID, sync, req.signal))
if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal)
const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty)))
return new Response(body, {
status: response.status,
@@ -114,7 +114,7 @@ export function httpEffect(url: string | URL, extra: HeadersInit | undefined, re
}
export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
return Effect.runPromise(httpEffect(url, extra, req, workspaceID))
return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID))
}
export function websocket(

View File

@@ -1,8 +1,10 @@
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { Effect } from "effect"
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
@@ -62,10 +64,14 @@ export const WorkspaceRoutes = lazy(() =>
),
async (c) => {
const body = c.req.valid("json") as Omit<Workspace.CreateInput, "projectID">
const workspace = await Workspace.create({
projectID: Instance.project.id,
...body,
})
const workspace = await AppRuntime.runPromise(
Workspace.Service.use((svc) =>
svc.create({
projectID: Instance.project.id,
...body,
}),
),
)
return c.json(workspace)
},
)
@@ -87,7 +93,7 @@ export const WorkspaceRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(Workspace.list(Instance.project))
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
},
)
.get(
@@ -108,8 +114,11 @@ export const WorkspaceRoutes = lazy(() =>
},
}),
async (c) => {
const ids = new Set(Workspace.list(Instance.project).map((item) => item.id))
return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID)))
const result = await AppRuntime.runPromise(
Workspace.Service.use((svc) => Effect.all([svc.list(Instance.project), svc.status()])),
)
const ids = new Set(result[0].map((item) => item.id))
return c.json(result[1].filter((item) => ids.has(item.workspaceID)))
},
)
.delete(
@@ -138,7 +147,7 @@ export const WorkspaceRoutes = lazy(() =>
),
async (c) => {
const { id } = c.req.valid("param")
return c.json(await Workspace.remove(id))
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.remove(id))))
},
)
.post(
@@ -174,10 +183,14 @@ export const WorkspaceRoutes = lazy(() =>
directory: Instance.directory,
})
try {
const result = await Workspace.sessionRestore({
workspaceID: id,
...body,
})
const result = await AppRuntime.runPromise(
Workspace.Service.use((svc) =>
svc.sessionRestore({
workspaceID: id,
...body,
}),
),
)
log.info("session restore route complete", {
workspaceID: id,
sessionID: body.sessionID,

View File

@@ -1,4 +1,4 @@
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
@@ -9,15 +9,20 @@ import { eq } from "drizzle-orm"
import { lte } from "drizzle-orm"
import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { Effect } from "effect"
import { Effect, Scope } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload } from "../groups/sync"
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const scope = yield* Scope.Scope
const start = Effect.fn("SyncHttpApi.start")(function* () {
startWorkspaceSyncing((yield* InstanceState.context).project.id)
yield* workspace
.startWorkspaceSyncing((yield* InstanceState.context).project.id)
.pipe(Effect.ignore, Effect.forkIn(scope))
return true
})

View File

@@ -75,9 +75,9 @@ function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest,
function resolveWorkspace(
id: WorkspaceID | undefined,
envWorkspaceID: WorkspaceID | undefined,
): Effect.Effect<Workspace.Info | void> {
): Effect.Effect<Workspace.Info | void, never, Workspace.Service> {
if (!id || envWorkspaceID) return Effect.void
return Effect.promise(() => Workspace.get(id))
return Workspace.Service.use((workspace) => workspace.get(id))
}
function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse {
@@ -99,9 +99,9 @@ function proxyRemote(
workspace: Workspace.Info,
target: RemoteTarget,
url: URL,
): Effect.Effect<HttpServerResponse.HttpServerResponse, never, Socket.WebSocketConstructor> {
): Effect.Effect<HttpServerResponse.HttpServerResponse, never, Socket.WebSocketConstructor | Workspace.Service> {
return Effect.gen(function* () {
const syncing = yield* Effect.sync(() => Workspace.isSyncing(workspace.id))
const syncing = yield* Workspace.Service.use((svc) => svc.isSyncing(workspace.id))
if (!syncing) {
return HttpServerResponse.text(`broken sync connection for workspace: ${workspace.id}`, {
status: 503,
@@ -113,10 +113,17 @@ function proxyRemote(
if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL)
const response = yield* HttpApiProxy.http(proxyURL, target.headers, request)
const sync = Fence.parse(new Headers(response.headers))
if (sync)
yield* Effect.promise(() =>
Fence.wait(workspace.id, sync, request.source instanceof Request ? request.source.signal : undefined),
if (sync) {
const syncFailure = yield* Fence.waitEffect(
workspace.id,
sync,
request.source instanceof Request ? request.source.signal : undefined,
).pipe(
Effect.as(undefined),
Effect.catch((error) => Effect.succeed(HttpServerResponse.text(error.message, { status: 503 }))),
)
if (syncFailure) return syncFailure
}
return response
})
}
@@ -125,7 +132,7 @@ function planWorkspaceRequest(
request: HttpServerRequest.HttpServerRequest,
url: URL,
workspace: Workspace.Info,
): Effect.Effect<RequestPlan> {
): Effect.Effect<RequestPlan, never, Workspace.Service> {
return Effect.gen(function* () {
const target = yield* resolveTarget(workspace)
if (target.type === "remote") return RequestPlan.Remote({ request, workspace, target, url })
@@ -136,7 +143,7 @@ function planWorkspaceRequest(
function planRequest(
request: HttpServerRequest.HttpServerRequest,
sessionWorkspaceID?: WorkspaceID,
): Effect.Effect<RequestPlan> {
): Effect.Effect<RequestPlan, never, Workspace.Service> {
return Effect.gen(function* () {
const url = requestURL(request)
const envWorkspaceID = configuredWorkspaceID()
@@ -158,7 +165,7 @@ function planRequest(
function routeWorkspace<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
plan: RequestPlan,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor> {
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor | Workspace.Service> {
return RequestPlan.$match(plan, {
MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)),
Remote: ({ request, workspace, target, url }) => proxyRemote(request, workspace, target, url),
@@ -167,20 +174,12 @@ function routeWorkspace<E>(
})
}
function routeWorkspaceRequest<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
request: HttpServerRequest.HttpServerRequest,
sessionWorkspaceID?: WorkspaceID,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor> {
return Effect.flatMap(planRequest(request, sessionWorkspaceID), (plan) => routeWorkspace(effect, plan))
}
function routeHttpApiWorkspace<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
): Effect.Effect<
HttpServerResponse.HttpServerResponse,
E,
Session.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor
Session.Service | Workspace.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor
> {
return Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
@@ -188,7 +187,8 @@ function routeHttpApiWorkspace<E>(
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void))
: undefined
return yield* routeWorkspaceRequest(effect, request, session?.workspaceID)
const plan = yield* planRequest(request, session?.workspaceID)
return yield* routeWorkspace(effect, plan)
})
}
@@ -196,8 +196,12 @@ export const workspaceRoutingLayer = Layer.effect(
WorkspaceRoutingMiddleware,
Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service
return WorkspaceRoutingMiddleware.of((effect) =>
routeHttpApiWorkspace(effect).pipe(Effect.provideService(Socket.WebSocketConstructor, makeWebSocket)),
routeHttpApiWorkspace(effect).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace),
),
)
}),
)
@@ -205,12 +209,15 @@ export const workspaceRoutingLayer = Layer.effect(
export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()(
Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* routeWorkspaceRequest(effect, request).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
)
})
const plan = yield* planRequest(request)
return yield* routeWorkspace(effect, plan)
}).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace),
)
}),
)

View File

@@ -12,7 +12,8 @@ import { eq } from "drizzle-orm"
import { EventTable } from "@/sync/event.sql"
import { lazy } from "@/util/lazy"
import * as Log from "@opencode-ai/core/util/log"
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
@@ -46,7 +47,9 @@ export const SyncRoutes = lazy(() =>
},
}),
async (c) => {
startWorkspaceSyncing(Instance.project.id)
void AppRuntime.runPromise(
Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(Instance.project.id)),
)
return c.json(true)
},
)

View File

@@ -72,7 +72,9 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next()
}
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
const workspace = await AppRuntime.runPromise(
Workspace.Service.use((svc) => svc.get(WorkspaceID.make(workspaceID))),
)
if (!workspace) {
return new Response(`Workspace not found: ${workspaceID}`, {
@@ -89,7 +91,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next()
}
const adaptor = await getAdaptor(workspace.projectID, workspace.type)
const adaptor = getAdaptor(workspace.projectID, workspace.type)
const target = await adaptor.target(workspace)
if (target.type === "local") {