Refactor workspace service boundaries (#25152)
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user