refactor(core): move database schema ownership (#29068)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Dax
2026-05-30 21:08:38 -04:00
committed by GitHub
parent 6bcb9cb9bb
commit 7f571d36ea
390 changed files with 11127 additions and 9164 deletions
+3 -4
View File
@@ -1,7 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Schema } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
export const Event = {
Connected: BusEvent.define("server.connected", Schema.Struct({})),
Disposed: BusEvent.define("global.disposed", Schema.Struct({})),
Connected: EventV2.define({ type: "server.connected", schema: {} }),
Disposed: EventV2.define({ type: "global.disposed", schema: {} }),
}
@@ -1,26 +1,2 @@
import sessionProjectors from "../session/projectors"
import { SyncEvent } from "@/sync"
import { Session } from "@/session/session"
import { SessionTable } from "@/session/session.sql"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
export function initProjectors() {
SyncEvent.init({
projectors: sessionProjectors,
convertEvent: (type, data) => {
if (type === "session.updated") {
const id = (data as SyncEvent.Event<typeof Session.Event.Updated>["data"]).sessionID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) return data
return {
sessionID: id,
info: Session.fromRow(row),
}
}
return data
},
})
}
@@ -1,7 +1,6 @@
import { Schema } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { EventV2 } from "@opencode-ai/core/event"
import { ConfigApi } from "./groups/config"
import { ControlApi } from "./groups/control"
import { EventApi } from "./groups/event"
@@ -23,9 +22,18 @@ import { V2Api } from "./groups/v2"
import { Authorization } from "./middleware/authorization"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
// SSE event schemas built from the BusEvent/SyncEvent registries.
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
const SyncEventSchemas = SyncEvent.effectPayloads()
const EventSchema = Schema.Union(
EventV2.registry
.values()
.map((definition) =>
Schema.Struct({
id: Schema.String,
type: Schema.Literal(definition.type),
properties: definition.data,
}).annotate({ identifier: `Event.${definition.type}` }),
)
.toArray(),
).annotate({ identifier: "Event" })
export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi)
@@ -56,7 +64,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas])
.annotate(HttpApi.AdditionalSchemas, [EventSchema])
export type RootHttpApiType = typeof RootHttpApi
export type InstanceHttpApiType = typeof InstanceHttpApi
@@ -1,11 +1,12 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
import { ProviderV2 } from "@opencode-ai/core/provider"
const AuthParams = Schema.Struct({
providerID: ProviderID,
providerID: ProviderV2.ID,
})
const LogQuery = Schema.Struct({
@@ -1,6 +1,6 @@
import { AccountID, OrgID } from "@/account/schema"
import { MCP } from "@/mcp"
import { ProviderID, ModelID } from "@/provider/schema"
import { Session } from "@/session/session"
import { Worktree } from "@/worktree"
import { NonNegativeInt } from "@opencode-ai/core/schema"
@@ -15,6 +15,7 @@ import {
} from "../middleware/workspace-routing"
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
@@ -49,8 +50,8 @@ const ToolListItem = Schema.Struct({
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
export const ToolListQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
provider: ProviderID,
model: ModelID,
provider: ProviderV2.ID,
model: ProviderV2.ModelID,
})
const WorktreeList = Schema.Array(Schema.String)
@@ -1,6 +1,5 @@
import { Config } from "@/config/config"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { EventV2 } from "@opencode-ai/core/event"
import "@/server/event"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
@@ -15,7 +14,14 @@ const GlobalEventSchema = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Union([...BusEvent.effectPayloads(), ...SyncEvent.effectPayloads()]),
payload: Schema.Union(
EventV2.registry
.values()
.map((definition) =>
Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }),
)
.toArray(),
),
}).annotate({ identifier: "GlobalEvent" })
export const GlobalUpgradeInput = Schema.Struct({
@@ -1,5 +1,5 @@
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ProjectNotFoundError } from "../errors"
@@ -50,7 +50,7 @@ export const ProjectApi = HttpApi.make("project")
}),
),
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
params: { projectID: ProjectID },
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: UpdatePayload,
success: described(Project.Info, "Updated project information"),
@@ -1,12 +1,13 @@
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
import { ProviderV2 } from "@opencode-ai/core/provider"
const root = "/provider"
@@ -21,7 +22,7 @@ export class ProviderAuthApiError extends Schema.ErrorClass<ProviderAuthApiError
{
name: ProviderAuthErrorName,
data: Schema.Struct({
providerID: Schema.optional(ProviderID),
providerID: Schema.optional(ProviderV2.ID),
field: Schema.optional(Schema.String),
message: Schema.optional(Schema.String),
kind: Schema.optional(Schema.String),
@@ -55,7 +56,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
params: { providerID: ProviderV2.ID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.AuthorizeInput,
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
@@ -68,7 +69,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
params: { providerID: ProviderV2.ID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.CallbackInput,
success: described(Schema.Boolean, "OAuth callback processed successfully"),
@@ -1,6 +1,7 @@
import { Permission } from "@/permission"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import { SessionPrompt } from "@/session/prompt"
@@ -22,6 +23,7 @@ import {
import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors"
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
const root = "/session"
export const ListQuery = Schema.Struct({
@@ -55,13 +57,13 @@ export const UpdatePayload = Schema.Struct({
})
export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"]))
export const InitPayload = Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
modelID: ProviderV2.ModelID,
providerID: ProviderV2.ID,
messageID: MessageID,
})
export const SummarizePayload = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
auto: Schema.optional(Schema.Boolean),
})
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
@@ -176,7 +178,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("messages", SessionPaths.messages, {
params: { sessionID: SessionID },
query: MessagesQuery,
success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
success: described(Schema.Array(SessionLegacy.WithParts), "List of messages"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
@@ -188,7 +190,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
query: WorkspaceRoutingQuery,
success: described(MessageV2.WithParts, "Message"),
success: described(SessionLegacy.WithParts, "Message"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
@@ -314,7 +316,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: PromptPayload,
success: described(MessageV2.WithParts, "Created message"),
success: described(SessionLegacy.WithParts, "Created message"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
@@ -341,7 +343,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: CommandPayload,
success: described(MessageV2.WithParts, "Created message"),
success: described(SessionLegacy.WithParts, "Created message"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
@@ -354,7 +356,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: ShellPayload,
success: described(MessageV2.WithParts, "Created message"),
success: described(SessionLegacy.WithParts, "Created message"),
error: [HttpApiError.BadRequest, ApiNotFoundError, SessionBusyError],
}).annotateMerge(
OpenApi.annotations({
@@ -430,8 +432,8 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
query: WorkspaceRoutingQuery,
payload: MessageV2.Part,
success: described(MessageV2.Part, "Successfully updated part"),
payload: SessionLegacy.Part,
success: described(SessionLegacy.Part, "Successfully updated part"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
@@ -12,19 +12,19 @@ const root = "/tui"
export const CommandPayload = Schema.Struct({ command: Schema.String })
const EventTuiPromptAppend = Schema.Struct({
type: Schema.Literal(TuiEvent.PromptAppend.type),
properties: TuiEvent.PromptAppend.properties,
properties: TuiEvent.PromptAppend.data,
}).annotate({ identifier: "EventTuiPromptAppend" })
const EventTuiCommandExecute = Schema.Struct({
type: Schema.Literal(TuiEvent.CommandExecute.type),
properties: TuiEvent.CommandExecute.properties,
properties: TuiEvent.CommandExecute.data,
}).annotate({ identifier: "EventTuiCommandExecute" })
const EventTuiToastShow = Schema.Struct({
type: Schema.Literal(TuiEvent.ToastShow.type),
properties: TuiEvent.ToastShow.properties,
properties: TuiEvent.ToastShow.data,
}).annotate({ identifier: "EventTuiToastShow" })
const EventTuiSessionSelect = Schema.Struct({
type: Schema.Literal(TuiEvent.SessionSelect.type),
properties: TuiEvent.SessionSelect.properties,
properties: TuiEvent.SessionSelect.data,
}).annotate({ identifier: "EventTuiSessionSelect" })
export const TuiPublishPayload = Schema.Union([
EventTuiPromptAppend,
@@ -55,7 +55,7 @@ export const TuiApi = HttpApi.make("tui")
.add(
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.PromptAppend.properties,
payload: TuiEvent.PromptAppend.data,
success: described(Schema.Boolean, "Prompt processed successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@@ -139,7 +139,7 @@ export const TuiApi = HttpApi.make("tui")
),
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.ToastShow.properties,
payload: TuiEvent.ToastShow.data,
success: described(Schema.Boolean, "Toast notification shown successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -162,7 +162,7 @@ export const TuiApi = HttpApi.make("tui")
),
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.SessionSelect.properties,
payload: TuiEvent.SessionSelect.data,
success: described(Schema.Boolean, "Session selected successfully"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
@@ -1,5 +1,5 @@
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
@@ -1,7 +1,7 @@
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { Prompt } from "@opencode-ai/core/session-prompt"
import { SessionV2 } from "@/v2/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
@@ -1,24 +1,25 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api"
import { LogInput } from "../groups/control"
import { ProviderV2 } from "@opencode-ai/core/provider"
export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) =>
Effect.gen(function* () {
const auth = yield* Auth.Service
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
payload: Auth.Info
}) {
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
return true
})
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderV2.ID } }) {
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
return true
})
@@ -1,6 +1,9 @@
import { Bus } from "@/bus"
import { EventV2Bridge } from "@/event-v2-bridge"
import { InstanceState } from "@/effect/instance-state"
import { GlobalBus } from "@/bus/global"
import { EventV2 } from "@opencode-ai/core/event"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { Effect, Queue } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -18,24 +21,51 @@ function eventData(data: unknown): Sse.Event {
}
}
function eventResponse(bus: Bus.Interface) {
function eventID() {
return EventV2.ID.create()
}
function eventResponse(events: EventV2.Interface) {
return Effect.gen(function* () {
// Subscribe eagerly: the bus subscription is acquired in the request scope
// at this yield, so any publish from now on is queued for the body-pump
// fiber to drain — closing the race where Stream.concat(server.connected,
// lazy-subscribe) used to drop publishes in the prefix-consume window.
const events = (yield* bus.subscribeAll()).pipe(
Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type),
const instance = yield* InstanceState.context
const workspaceID = yield* InstanceState.workspaceID
// Listener registration is eager, so events published after this point cannot
// be lost while the HTTP body fiber is starting or emitting server.connected.
const queue = yield* Queue.unbounded<EventV2.Payload>()
const unsubscribe = yield* events.listen((event) => Effect.sync(() => Queue.offerUnsafe(queue, event)))
yield* Effect.addFinalizer(() => unsubscribe)
const stream = Stream.fromQueue(queue).pipe(
Stream.filter(
(event) =>
event.location?.directory === instance.directory &&
(event.location.workspaceID === undefined || event.location.workspaceID === workspaceID),
),
Stream.map((event) => ({ id: event.id, type: event.type, properties: event.data })),
)
const disposed = Stream.callback<{ id: string; type: string; properties: unknown }>((queue) => {
const listener = (event: { directory?: string; payload: { id?: string; type?: string; properties?: unknown } }) => {
if (event.directory !== instance.directory || event.payload.type !== "server.instance.disposed") return
Queue.offerUnsafe(queue, {
id: event.payload.id ?? eventID(),
type: "server.instance.disposed",
properties: event.payload.properties ?? {},
})
}
return Effect.acquireRelease(
Effect.sync(() => GlobalBus.on("event", listener)),
() => Effect.sync(() => GlobalBus.off("event", listener)),
)
})
const output = stream.pipe(Stream.merge(disposed, { haltStrategy: "left" }), Stream.takeUntil((event) => event.type === "server.instance.disposed"))
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })),
Stream.map(() => ({ id: eventID(), type: "server.heartbeat", properties: {} })),
)
log.info("event connected")
return HttpServerResponse.stream(
Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.make({ id: eventID(), type: "server.connected", properties: {} }).pipe(
Stream.concat(output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
@@ -55,11 +85,11 @@ function eventResponse(bus: Bus.Interface) {
export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
return handlers.handleRaw(
"subscribe",
Effect.fn("EventHttpApi.subscribe")(function* () {
return yield* eventResponse(bus)
return yield* eventResponse(events)
}),
)
}),
@@ -29,6 +29,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const sessions = yield* Session.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
@@ -127,21 +128,19 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
const limit = ctx.query.limit ?? 100
const sessions = Array.from(
Session.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived,
}),
)
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
const all = yield* sessions.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived,
})
const list = all.length > limit ? all.slice(0, limit) : all
return HttpServerResponse.jsonUnsafe(list, {
headers:
sessions.length > limit && list.length > 0
all.length > limit && list.length > 0
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
: undefined,
})
@@ -1,7 +1,7 @@
import { Config } from "@/config/config"
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { EffectBridge } from "@/effect/bridge"
import { Bus } from "@/bus"
import { EventV2 } from "@opencode-ai/core/event"
import { Installation } from "@/installation"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -44,11 +44,11 @@ function eventResponse() {
})
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })),
Stream.map(() => ({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } })),
)
return HttpServerResponse.stream(
Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe(
Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
@@ -1,6 +1,6 @@
import * as InstanceState from "@/effect/instance-state"
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
@@ -33,7 +33,7 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
})
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
params: { projectID: ProjectID }
params: { projectID: ProjectV2.ID }
payload: Project.UpdatePayload
}) {
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe(
@@ -2,13 +2,14 @@ import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ProviderAuthApiError } from "../groups/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
function mapProviderAuthError<A, R>(self: Effect.Effect<A, ProviderAuth.Error, R>) {
return self.pipe(
@@ -62,7 +63,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
})
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
payload: ProviderAuth.AuthorizeInput
}) {
return yield* mapProviderAuthError(
@@ -75,7 +76,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
})
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
@@ -90,7 +91,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
payload: ProviderAuth.CallbackInput
}) {
yield* mapProviderAuthError(
@@ -1,5 +1,6 @@
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Command } from "@/command"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
@@ -56,7 +57,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const statusSvc = yield* SessionStatus.Service
const todoSvc = yield* Todo.Service
const summary = yield* SessionSummary.Service
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const scope = yield* Scope.Scope
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
@@ -316,7 +317,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
yield* Effect.logError("prompt_async failed").pipe(
Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),
)
yield* bus.publish(Session.Event.Error, {
yield* events.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
})
@@ -395,10 +396,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const updatePart = Effect.fn("SessionHttpApi.updatePart")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
payload: typeof MessageV2.Part.Type
payload: typeof SessionLegacy.Part.Type
}) {
yield* requireSession(ctx.params.sessionID)
const payload = ctx.payload as MessageV2.Part
const payload = ctx.payload as SessionLegacy.Part
if (
payload.id !== ctx.params.partID ||
payload.messageID !== ctx.params.messageID ||
@@ -1,9 +1,10 @@
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Session } from "@/session/session"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventTable } from "@opencode-ai/core/event/sql"
import { asc } from "drizzle-orm"
import { and } from "drizzle-orm"
import { eq } from "drizzle-orm"
@@ -21,8 +22,10 @@ const log = Log.create({ service: "server.sync" })
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const session = yield* Session.Service
const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const events = yield* EventV2Bridge.Service
const { db } = yield* Database.Service
const start = Effect.fn("SyncHttpApi.start")(function* () {
yield* workspace
@@ -32,27 +35,27 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
})
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: event.id,
const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: EventV2.ID.make(event.id),
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,
data: { ...event.data },
}))
const source = events[0].aggregateID
const source = payload[0].aggregateID
log.info("sync replay requested", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
events: payload.length,
first: payload[0]?.seq,
last: payload.at(-1)?.seq,
directory: ctx.payload.directory,
})
yield* sync.replayAll(events)
yield* events.replayAll(payload)
log.info("sync replay complete", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
events: payload.length,
first: payload[0]?.seq,
last: payload.at(-1)?.seq,
})
return { sessionID: source }
})
@@ -61,12 +64,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
const workspaceID = yield* InstanceState.workspaceID
if (!workspaceID) return yield* new HttpApiError.BadRequest({})
yield* sync.run(Session.Event.Updated, {
sessionID: ctx.payload.sessionID,
info: {
workspaceID,
},
})
yield* session.setWorkspace({ sessionID: ctx.payload.sessionID, workspaceID })
log.info("sync session stolen", {
sessionID: ctx.payload.sessionID,
@@ -78,18 +76,17 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all(),
)
return yield* db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
})
return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history)
@@ -1,4 +1,4 @@
import { Bus } from "@/bus"
import { EventV2Bridge } from "@/event-v2-bridge"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Session } from "@/session/session"
import { Effect } from "effect"
@@ -26,15 +26,15 @@ const commandAliases = {
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const session = yield* Session.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
const publishCommand = (command: typeof TuiEvent.CommandExecute.data.Type.command | undefined) =>
events.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.data.Type)
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
payload: typeof TuiEvent.PromptAppend.data.Type
}) {
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
yield* events.publish(TuiEvent.PromptAppend, ctx.payload)
return true
})
@@ -77,29 +77,29 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler
})
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
payload: typeof TuiEvent.ToastShow.properties.Type
payload: typeof TuiEvent.ToastShow.data.Type
}) {
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
yield* events.publish(TuiEvent.ToastShow, ctx.payload)
return true
})
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
if (ctx.payload.type === TuiEvent.PromptAppend.type)
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
yield* events.publish(TuiEvent.PromptAppend, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.CommandExecute.type)
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
yield* events.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* events.publish(TuiEvent.ToastShow, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.SessionSelect.type)
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
yield* events.publish(TuiEvent.SessionSelect, ctx.payload.properties)
return true
})
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
payload: typeof TuiEvent.SessionSelect.properties.Type
payload: typeof TuiEvent.SessionSelect.data.Type
}) {
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sessionID))
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
yield* events.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})
@@ -1,4 +1,4 @@
import { SessionV2 } from "@/v2/session"
import { SessionV2 } from "@opencode-ai/core/session"
import { Layer } from "effect"
import { layer as v2LocationLayer } from "../groups/v2/location"
import { messageHandlers } from "./v2/message"
@@ -1,5 +1,5 @@
import { SessionMessage } from "@opencode-ai/core/session-message"
import { SessionV2 } from "@/v2/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
import { Effect, Schema } from "effect"
import * as DateTime from "effect/DateTime"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -1,5 +1,6 @@
import { WorkspaceID } from "@/control-plane/schema"
import { SessionV2 } from "@/v2/session"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { DateTime, Effect, Option, Schema } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
@@ -20,7 +21,7 @@ const SessionCursor = Schema.Struct({
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
directory: Schema.String.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
workspaceID: WorkspaceID.pipe(Schema.optional),
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
roots: Schema.Boolean.pipe(Schema.optional),
start: Schema.Finite.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
@@ -78,7 +79,7 @@ const sessionCursor = {
function decodeWorkspaceID(input: string | undefined) {
if (input === undefined) return Effect.succeed(undefined)
const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(input)
const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(input)
if (Option.isSome(workspaceID)) return Effect.succeed(workspaceID.value)
return Effect.fail(
new InvalidRequestError({
@@ -114,17 +115,21 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
start: ctx.query.start,
search: ctx.query.search,
}
const sessions = yield* session.list({
const input = {
limit: ctx.query.limit ?? DefaultSessionsLimit,
order,
directory: filters.directory,
path: filters.path,
workspaceID: filters.workspaceID,
roots: filters.roots,
start: filters.start,
search: filters.search,
cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined,
})
}
const sessions = yield* session.list(
filters.directory
? {
...input,
directory: AbsolutePath.make(filters.directory),
}
: input,
)
const first = sessions[0]
const last = sessions.at(-1)
return {
@@ -168,7 +173,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
.handle(
"compact",
Effect.fn(function* (ctx) {
yield* session.compact(ctx.params.sessionID).pipe(
yield* session.compact({ sessionID: ctx.params.sessionID }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
@@ -1,20 +1,25 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { Database } from "@opencode-ai/core/database/database"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import * as Fence from "@/server/shared/fence"
const ignoredMethods = new Set(["GET", "HEAD", "OPTIONS"])
export const fenceLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
export const fenceLayer = HttpRouter.middleware<{ requires: Database.Service; handles: unknown }>()(
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect
const { db } = yield* Database.Service
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect
const previous = Fence.load()
const response = yield* effect
const current = Fence.diff(previous, Fence.load())
if (Object.keys(current).length === 0) return response
const previous = yield* Fence.load(db)
const response = yield* effect
const current = Fence.diff(previous, yield* Fence.load(db))
if (Object.keys(current).length === 0) return response
return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current))
return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current))
})
}),
).layer
@@ -1,4 +1,4 @@
import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { Target } from "@/control-plane/types"
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdapterRuntime } from "@/control-plane/workspace-adapter-runtime"
@@ -30,8 +30,8 @@ type RemoteTarget = Extract<Target, { type: "remote" }>
type RequestPlan = Data.TaggedEnum<{
InvalidWorkspace: {}
MissingWorkspace: { readonly workspaceID: WorkspaceID }
Local: { readonly directory: string; readonly workspaceID?: WorkspaceID }
MissingWorkspace: { readonly workspaceID: WorkspaceV2.ID }
Local: { readonly directory: string; readonly workspaceID?: WorkspaceV2.ID }
Remote: {
readonly request: HttpServerRequest.HttpServerRequest
readonly workspace: Workspace.Info
@@ -46,7 +46,7 @@ export class WorkspaceRouteContext extends Context.Service<
WorkspaceRouteContext,
{
readonly directory: string
readonly workspaceID?: WorkspaceID
readonly workspaceID?: WorkspaceV2.ID
}
>()("@opencode/ExperimentalHttpApiWorkspaceRouteContext") {}
@@ -62,23 +62,23 @@ function requestURL(request: HttpServerRequest.HttpServerRequest): URL {
return new URL(request.url, "http://localhost")
}
function configuredWorkspaceID(): WorkspaceID | undefined {
return Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined
function configuredWorkspaceID(): WorkspaceV2.ID | undefined {
return Flag.OPENCODE_WORKSPACE_ID ? WorkspaceV2.ID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined
}
function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): WorkspaceID | undefined {
function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceV2.ID): WorkspaceV2.ID | undefined {
const workspaceParam = url.searchParams.get("workspace")
return sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined)
return sessionWorkspaceID ?? (workspaceParam ? WorkspaceV2.ID.make(workspaceParam) : undefined)
}
function selectedV2WorkspaceID(
url: URL,
sessionWorkspaceID?: WorkspaceID,
): WorkspaceID | typeof InvalidWorkspaceID | undefined {
sessionWorkspaceID?: WorkspaceV2.ID,
): WorkspaceV2.ID | typeof InvalidWorkspaceID | undefined {
if (sessionWorkspaceID) return sessionWorkspaceID
const workspaceParam = url.searchParams.get("workspace")
if (!workspaceParam) return undefined
const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(workspaceParam)
const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(workspaceParam)
if (Option.isNone(workspaceID)) return InvalidWorkspaceID
return workspaceID.value
}
@@ -92,14 +92,14 @@ function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest,
}
function resolveWorkspace(
id: WorkspaceID | undefined,
envWorkspaceID: WorkspaceID | undefined,
id: WorkspaceV2.ID | undefined,
envWorkspaceID: WorkspaceV2.ID | undefined,
): Effect.Effect<Workspace.Info | void, never, Workspace.Service> {
if (!id || envWorkspaceID) return Effect.void
return Workspace.Service.use((workspace) => workspace.get(id))
}
function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse {
function missingWorkspaceResponse(id: WorkspaceV2.ID): HttpServerResponse.HttpServerResponse {
return HttpServerResponse.text(`Workspace not found: ${id}`, {
status: 500,
contentType: "text/plain; charset=utf-8",
@@ -13,7 +13,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Command } from "@/command"
import * as Observability from "@opencode-ai/core/effect/observability"
@@ -46,9 +45,9 @@ import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { Skill } from "@/skill"
import { Snapshot } from "@/snapshot"
import { SyncEvent } from "@/sync"
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
@@ -191,8 +190,9 @@ export function createRoutes(
errorLayer,
compressionLayer,
corsVaryFix,
fenceLayer,
fenceLayer.pipe(Layer.provide(Database.defaultLayer)),
cors(corsOptions),
Database.defaultLayer,
Account.defaultLayer,
Agent.defaultLayer,
Auth.defaultLayer,
@@ -225,7 +225,6 @@ export function createRoutes(
SessionSummary.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
SyncEvent.defaultLayer,
EventV2Bridge.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
@@ -233,7 +232,6 @@ export function createRoutes(
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
Bus.layer,
AppFileSystem.defaultLayer,
FetchHttpClient.layer,
HttpServer.layerServices,
+12 -12
View File
@@ -1,8 +1,8 @@
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { inArray } from "drizzle-orm"
import { EventSequenceTable } from "@/sync/event.sql"
import { EventSequenceTable } from "@opencode-ai/core/event/sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
@@ -10,16 +10,16 @@ export const HEADER = "x-opencode-sync"
export type State = Record<string, number>
const log = Log.create({ service: "fence" })
export function load(ids?: string[]) {
const rows = Database.use((db) => {
if (!ids?.length) {
return db.select().from(EventSequenceTable).all()
}
export function load(db: Database.Interface["db"], ids?: string[]) {
return Effect.gen(function* () {
const rows = yield* (
ids?.length
? db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
: db.select().from(EventSequenceTable).all()
).pipe(Effect.orDie)
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq]))
})
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq]))
}
export function diff(prev: State, next: State) {
@@ -53,7 +53,7 @@ export function parse(headers: Headers): State | undefined {
)
}
export function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
export function wait(workspaceID: WorkspaceV2.ID, state: State, signal?: AbortSignal) {
return Effect.gen(function* () {
log.info("waiting for state", {
workspaceID,