Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 26 additions & 17 deletions packages/opencode/src/bus/bus-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import type { ZodType } from "zod"

export namespace BusEvent {
export type Definition = ReturnType<typeof define>
type PayloadOptions = {
include?: Iterable<string>
}

const registry = new Map<string, Definition>()

Expand All @@ -15,24 +18,30 @@ export namespace BusEvent {
return result
}

export function payloads() {
function payloadEntries(options?: PayloadOptions) {
if (!options?.include) return registry.entries().toArray()

return Array.from(options.include, (type) => {
const def = registry.get(type)
if (!def) throw new Error(`Bus event schema is not registered: ${type}`)
return [type, def] as const
})
}

export function payloads(options?: PayloadOptions) {
const schemas = payloadEntries(options).map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
})

return z
.discriminatedUnion(
"type",
registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
})
.toArray() as any,
)
.discriminatedUnion("type", schemas as any)
.meta({
ref: "Event",
})
Expand Down
126 changes: 117 additions & 9 deletions packages/opencode/src/server/control-openapi.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { OpenApi } from "effect/unstable/httpapi"
import { resolver } from "hono-openapi"
import { BusEvent } from "@/bus/bus-event"
import { Info as ConfigInfo } from "@/config/config"
import { PtyID } from "@/pty/schema"
import { BadRequestErrorSchema } from "./error"
import { globalEventOpenApiSchema, globalSyncEventOpenApiSchema } from "./global-openapi-schema"
import { ControlApi } from "./routes/instance/httpapi/groups/control"
import { GlobalApi } from "./routes/instance/httpapi/groups/global"
import { ProductionApi } from "./production-api"
import { productionBusEventTypes, productionSyncEventTypes } from "./production-event-sources"

type OpenApiDocument = {
openapi?: string
Expand All @@ -18,8 +21,6 @@ type OpenApiDocument = {
}
}

const ControlDocApi = HttpApi.make("controlDoc").addHttpApi(ControlApi).addHttpApi(GlobalApi)

function mergeSchemas(document: OpenApiDocument, schemas: Record<string, unknown>, options?: { override?: boolean }) {
document.components ??= {}
document.components.schemas = options?.override
Expand All @@ -33,12 +34,52 @@ function mergeSchemas(document: OpenApiDocument, schemas: Record<string, unknown
}
}

function sortRefUnions(value: unknown) {
if (!value || typeof value !== "object") return
if (Array.isArray(value)) {
for (const item of value) sortRefUnions(item)
return
}

const record = value as Record<string, unknown>
const anyOf = record.anyOf
if (
Array.isArray(anyOf) &&
anyOf.every((item) => item && typeof item === "object" && typeof (item as Record<string, unknown>).$ref === "string")
) {
anyOf.sort((left, right) =>
String((left as Record<string, unknown>).$ref).localeCompare(String((right as Record<string, unknown>).$ref)),
)
}
for (const item of Object.values(record)) sortRefUnions(item)
}

const workspaceRoutingParameters = [
{
in: "query",
name: "directory",
schema: {
type: "string",
},
},
{
in: "query",
name: "workspace",
schema: {
type: "string",
},
},
]

export async function controlOpenApi() {
const document = structuredClone(OpenApi.fromApi(ControlDocApi) as OpenApiDocument)
const [globalEvent, globalSyncEvent, badRequest] = await Promise.all([
resolver(globalEventOpenApiSchema()).toOpenAPISchema(),
resolver(globalSyncEventOpenApiSchema()).toOpenAPISchema(),
const document = structuredClone(OpenApi.fromApi(ProductionApi) as OpenApiDocument)
const [instanceEvent, globalEvent, globalSyncEvent, badRequest, config, ptyID] = await Promise.all([
resolver(BusEvent.payloads({ include: productionBusEventTypes })).toOpenAPISchema(),
resolver(globalEventOpenApiSchema({ busEventTypes: productionBusEventTypes })).toOpenAPISchema(),
resolver(globalSyncEventOpenApiSchema({ syncEventTypes: productionSyncEventTypes })).toOpenAPISchema(),
resolver(BadRequestErrorSchema).toOpenAPISchema(),
resolver(ConfigInfo.zod).toOpenAPISchema(),
resolver(PtyID.zod).toOpenAPISchema(),
])

document.openapi = "3.1.1"
Expand All @@ -49,6 +90,24 @@ export async function controlOpenApi() {
}
document.paths ??= {}
delete document.paths["/doc"]
document.paths["/event"] = {
get: {
operationId: "event.subscribe",
summary: "Subscribe to events",
description: "Get events",
parameters: workspaceRoutingParameters,
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: instanceEvent.schema,
},
},
},
},
},
}
document.paths["/global/event"] = {
get: {
operationId: "global.event",
Expand Down Expand Up @@ -83,8 +142,57 @@ export async function controlOpenApi() {
},
},
}
document.paths["/pty/{ptyID}/connect"] = {
get: {
operationId: "pty.connect",
summary: "Connect to PTY session",
description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
parameters: [
...workspaceRoutingParameters,
{
in: "path",
name: "ptyID",
schema: ptyID.schema,
required: true,
},
{
in: "query",
name: "cursor",
schema: {
type: "string",
},
},
{
in: "query",
name: "ticket",
schema: {
type: "string",
},
},
],
responses: {
101: {
description: "WebSocket protocol upgrade",
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
404: {
description: "Not found",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/NotFoundError",
},
},
},
},
},
},
}
mergeSchemas(document, instanceEvent.components?.schemas ?? {})
mergeSchemas(document, globalEvent.components?.schemas ?? {})
mergeSchemas(document, globalSyncEvent.components?.schemas ?? {})
mergeSchemas(document, ptyID.components?.schemas ?? {})
mergeSchemas(document, badRequest.components?.schemas ?? {}, { override: true })
mergeSchemas(document, config.components?.schemas ?? {}, { override: true })
sortRefUnions(document)
return document
}
8 changes: 4 additions & 4 deletions packages/opencode/src/server/global-openapi-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@ import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import z from "zod"

export function globalEventOpenApiSchema() {
export function globalEventOpenApiSchema(options?: { busEventTypes?: Iterable<string> }) {
return z
.object({
directory: z.string(),
project: z.string().optional(),
workspace: z.string().optional(),
payload: BusEvent.payloads(),
payload: BusEvent.payloads({ include: options?.busEventTypes }),
})
.meta({
ref: "GlobalEvent",
})
}

export function globalSyncEventOpenApiSchema() {
export function globalSyncEventOpenApiSchema(options?: { syncEventTypes?: Iterable<string> }) {
return z
.object({
payload: SyncEvent.payloads(),
payload: SyncEvent.payloads({ include: options?.syncEventTypes }),
})
.meta({
ref: "SyncEvent",
Expand Down
35 changes: 35 additions & 0 deletions packages/opencode/src/server/production-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { HttpApi } from "effect/unstable/httpapi"
import { AutomationApi } from "./routes/instance/httpapi/groups/automation"
import { ConfigApi } from "./routes/instance/httpapi/groups/config"
import { ControlApi } from "./routes/instance/httpapi/groups/control"
import { ExperimentalApi } from "./routes/instance/httpapi/groups/experimental"
import { ExternalResultApi } from "./routes/instance/httpapi/groups/external-result"
import { FileApi } from "./routes/instance/httpapi/groups/file"
import { GlobalApi } from "./routes/instance/httpapi/groups/global"
import { McpApi } from "./routes/instance/httpapi/groups/mcp"
import { MemoryApi } from "./routes/instance/httpapi/groups/memory"
import { PermissionApi } from "./routes/instance/httpapi/groups/permission"
import { ProjectApi } from "./routes/instance/httpapi/groups/project"
import { ProviderApi } from "./routes/instance/httpapi/groups/provider"
import { PtyApi } from "./routes/instance/httpapi/groups/pty"
import { RootApi } from "./routes/instance/httpapi/groups/root"
import { SessionApi } from "./routes/instance/httpapi/groups/session"
import { WorkspaceApi } from "./routes/instance/httpapi/groups/workspace"

export const ProductionApi = HttpApi.make("production")
.addHttpApi(ControlApi)
.addHttpApi(GlobalApi)
.addHttpApi(WorkspaceApi)
.addHttpApi(RootApi)
.addHttpApi(ProjectApi)
.addHttpApi(PtyApi)
.addHttpApi(ConfigApi)
.addHttpApi(ExperimentalApi)
.addHttpApi(SessionApi)
.addHttpApi(PermissionApi)
.addHttpApi(ExternalResultApi)
.addHttpApi(ProviderApi)
.addHttpApi(MemoryApi)
.addHttpApi(AutomationApi)
.addHttpApi(FileApi)
.addHttpApi(McpApi)
82 changes: 82 additions & 0 deletions packages/opencode/src/server/production-event-sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Keep /doc event schemas aligned with the production server modules that define SSE payloads.
import "@/automation"
import "@/bus"
import "@/command"
import "@/control-plane/workspace"
import "@/file"
import "@/file/watcher"
import "@/installation"
import "@/lsp"
import "@/lsp/client"
import "@/mcp"
import "@/permission"
import "@/project/project"
import "@/project/vcs"
import "@/pty"
import "@/server/event"
// Installs the latest SyncEvent definitions onto BusEvent for production SSE schemas.
import "@/server/projectors"
import "@/session/compaction"
import "@/session/message-v2"
import "@/session/session"
import "@/session/status"
import "@/session/todo"
import "@/worktree"

export const productionBusEventTypes = [
"automation.definition.deleted",
"automation.definition.updated",
"automation.run.updated",
"command.executed",
"file.edited",
"file.watcher.rescan",
"file.watcher.updated",
"global.disposed",
"installation.update-available",
"installation.updated",
"lsp.client.diagnostics",
"lsp.server.install.failed",
"lsp.updated",
"mcp.browser.open.failed",
"mcp.tools.changed",
"message.part.delta",
"message.part.removed",
"message.part.updated",
"message.removed",
"message.updated",
"permission.asked",
"permission.replied",
"project.updated",
"pty.created",
"pty.deleted",
"pty.exited",
"pty.updated",
"server.connected",
"server.instance.disposed",
"session.compacted",
"session.created",
"session.deleted",
"session.diff",
"session.error",
"session.idle",
"session.status",
"session.turn_change_invalidated",
"session.updated",
"todo.updated",
"vcs.branch.updated",
"workspace.failed",
"workspace.ready",
"workspace.status",
"worktree.failed",
"worktree.ready",
] as const

export const productionSyncEventTypes = [
"message.part.removed",
"message.part.updated",
"message.removed",
"message.updated",
"session.created",
"session.deleted",
"session.updated",
] as const
Loading