Add CodeNomad plugin bridge for opencode
This commit is contained in:
32
packages/opencode-config/README.md
Normal file
32
packages/opencode-config/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# opencode-config
|
||||||
|
|
||||||
|
## TLDR
|
||||||
|
Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
|
||||||
|
- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
|
||||||
|
- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
|
||||||
|
- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
|
||||||
|
- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
|
||||||
|
|
||||||
|
## Expectations
|
||||||
|
- Local-only bridge (no auth/token yet).
|
||||||
|
- Plugin must fail startup if it cannot connect after 3 retries.
|
||||||
|
- Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers.
|
||||||
|
- Keep event shapes small and explicit; use `type` + `properties` only.
|
||||||
|
|
||||||
|
## Ideas
|
||||||
|
- Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands).
|
||||||
|
- Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed.
|
||||||
|
- Promote stable event shapes and version tags once the protocol settles.
|
||||||
|
|
||||||
|
## Pointers
|
||||||
|
- Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
|
||||||
|
- Plugin client: `packages/opencode-config/plugin/lib/client.ts`
|
||||||
|
- Plugin server routes: `packages/server/src/server/routes/plugin.ts`
|
||||||
|
- Plugin event handling: `packages/server/src/plugins/handlers.ts`
|
||||||
|
- Workspace env injection: `packages/server/src/workspaces/manager.ts`
|
||||||
35
packages/opencode-config/plugin/codenomad.ts
Normal file
35
packages/opencode-config/plugin/codenomad.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||||
|
|
||||||
|
export async function CodeNomadPlugin() {
|
||||||
|
const config = getCodeNomadConfig()
|
||||||
|
const client = createCodeNomadClient(config)
|
||||||
|
|
||||||
|
await client.startEvents((event) => {
|
||||||
|
if (event.type === "codenomad.ping") {
|
||||||
|
void client.postEvent({
|
||||||
|
type: "codenomad.pong",
|
||||||
|
properties: {
|
||||||
|
ts: Date.now(),
|
||||||
|
pingTs: (event.properties as any)?.ts,
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
async event(input: { event: any }) {
|
||||||
|
const opencodeEvent = input?.event
|
||||||
|
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
||||||
|
|
||||||
|
if (opencodeEvent.type === "session.idle") {
|
||||||
|
const sessionID = (opencodeEvent as any).properties?.sessionID
|
||||||
|
void client.postEvent({
|
||||||
|
type: "opencode.session.idle",
|
||||||
|
properties: {
|
||||||
|
sessionID,
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
165
packages/opencode-config/plugin/lib/client.ts
Normal file
165
packages/opencode-config/plugin/lib/client.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
export type PluginEvent = {
|
||||||
|
type: string
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodeNomadConfig = {
|
||||||
|
instanceId: string
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCodeNomadConfig(): CodeNomadConfig {
|
||||||
|
return {
|
||||||
|
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
||||||
|
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCodeNomadClient(config: CodeNomadConfig) {
|
||||||
|
return {
|
||||||
|
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
|
||||||
|
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEnv(key: string): string {
|
||||||
|
const value = process.env[key]
|
||||||
|
if (!value || !value.trim()) {
|
||||||
|
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
|
||||||
|
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(event),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
|
||||||
|
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
|
||||||
|
|
||||||
|
// Fail plugin startup if we cannot establish the initial connection.
|
||||||
|
const initialBody = await connectWithRetries(url, 3)
|
||||||
|
|
||||||
|
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
||||||
|
void consumeWithReconnect(url, onEvent, initialBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectWithRetries(url: string, maxAttempts: number) {
|
||||||
|
let lastError: unknown
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
|
||||||
|
}
|
||||||
|
return response.body
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
await delay(500 * attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
||||||
|
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeWithReconnect(
|
||||||
|
url: string,
|
||||||
|
onEvent: (event: PluginEvent) => void,
|
||||||
|
initialBody: ReadableStream<Uint8Array>,
|
||||||
|
) {
|
||||||
|
let consecutiveFailures = 0
|
||||||
|
let body: ReadableStream<Uint8Array> | null = initialBody
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
if (!body) {
|
||||||
|
body = await connectWithRetries(url, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
await consumeSseBody(body, onEvent)
|
||||||
|
body = null
|
||||||
|
consecutiveFailures = 0
|
||||||
|
} catch (error) {
|
||||||
|
body = null
|
||||||
|
consecutiveFailures += 1
|
||||||
|
if (consecutiveFailures >= 3) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error)
|
||||||
|
throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`)
|
||||||
|
}
|
||||||
|
await delay(500 * consecutiveFailures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeSseBody(body: ReadableStream<Uint8Array>, onEvent: (event: PluginEvent) => void) {
|
||||||
|
const reader = body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ""
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done || !value) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
|
let separatorIndex = buffer.indexOf("\n\n")
|
||||||
|
while (separatorIndex >= 0) {
|
||||||
|
const chunk = buffer.slice(0, separatorIndex)
|
||||||
|
buffer = buffer.slice(separatorIndex + 2)
|
||||||
|
separatorIndex = buffer.indexOf("\n\n")
|
||||||
|
|
||||||
|
const event = parseSseChunk(chunk)
|
||||||
|
if (event) {
|
||||||
|
onEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("SSE stream ended")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseChunk(chunk: string): PluginEvent | null {
|
||||||
|
const lines = chunk.split(/\r?\n/)
|
||||||
|
const dataLines: string[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith(":")) continue
|
||||||
|
if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice(5).trimStart())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataLines.length === 0) return null
|
||||||
|
|
||||||
|
const payload = dataLines.join("\n").trim()
|
||||||
|
if (!payload) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload)
|
||||||
|
if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return parsed as PluginEvent
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,22 +122,6 @@ async function main() {
|
|||||||
logger.info({ options }, "Starting CodeNomad CLI server")
|
logger.info({ options }, "Starting CodeNomad CLI server")
|
||||||
|
|
||||||
const eventBus = new EventBus(eventLogger)
|
const eventBus = new EventBus(eventLogger)
|
||||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
|
||||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
|
||||||
const workspaceManager = new WorkspaceManager({
|
|
||||||
rootDir: options.rootDir,
|
|
||||||
configStore,
|
|
||||||
binaryRegistry,
|
|
||||||
eventBus,
|
|
||||||
logger: workspaceLogger,
|
|
||||||
})
|
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
|
||||||
const instanceStore = new InstanceStore()
|
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
|
||||||
workspaceManager,
|
|
||||||
eventBus,
|
|
||||||
logger: logger.child({ component: "instance-events" }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const serverMeta: ServerMeta = {
|
const serverMeta: ServerMeta = {
|
||||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||||
@@ -150,6 +134,24 @@ async function main() {
|
|||||||
addresses: [],
|
addresses: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||||
|
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||||
|
const workspaceManager = new WorkspaceManager({
|
||||||
|
rootDir: options.rootDir,
|
||||||
|
configStore,
|
||||||
|
binaryRegistry,
|
||||||
|
eventBus,
|
||||||
|
logger: workspaceLogger,
|
||||||
|
getServerBaseUrl: () => serverMeta.httpBaseUrl,
|
||||||
|
})
|
||||||
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
|
const instanceStore = new InstanceStore()
|
||||||
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
|
workspaceManager,
|
||||||
|
eventBus,
|
||||||
|
logger: logger.child({ component: "instance-events" }),
|
||||||
|
})
|
||||||
|
|
||||||
const releaseMonitor = startReleaseMonitor({
|
const releaseMonitor = startReleaseMonitor({
|
||||||
currentVersion: packageJson.version,
|
currentVersion: packageJson.version,
|
||||||
logger: logger.child({ component: "release-monitor" }),
|
logger: logger.child({ component: "release-monitor" }),
|
||||||
|
|||||||
55
packages/server/src/plugins/channel.ts
Normal file
55
packages/server/src/plugins/channel.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { FastifyReply } from "fastify"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
|
||||||
|
export interface PluginOutboundEvent {
|
||||||
|
type: string
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientConnection {
|
||||||
|
reply: FastifyReply
|
||||||
|
workspaceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginChannelManager {
|
||||||
|
private readonly clients = new Set<ClientConnection>()
|
||||||
|
|
||||||
|
constructor(private readonly logger: Logger) {}
|
||||||
|
|
||||||
|
register(workspaceId: string, reply: FastifyReply) {
|
||||||
|
const connection: ClientConnection = { workspaceId, reply }
|
||||||
|
this.clients.add(connection)
|
||||||
|
this.logger.debug({ workspaceId }, "Plugin SSE client connected")
|
||||||
|
|
||||||
|
let closed = false
|
||||||
|
const close = () => {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
this.clients.delete(connection)
|
||||||
|
this.logger.debug({ workspaceId }, "Plugin SSE client disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { close }
|
||||||
|
}
|
||||||
|
|
||||||
|
send(workspaceId: string, event: PluginOutboundEvent) {
|
||||||
|
for (const client of this.clients) {
|
||||||
|
if (client.workspaceId !== workspaceId) continue
|
||||||
|
this.write(client.reply, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(event: PluginOutboundEvent) {
|
||||||
|
for (const client of this.clients) {
|
||||||
|
this.write(client.reply, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private write(reply: FastifyReply, event: PluginOutboundEvent) {
|
||||||
|
try {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error }, "Failed to write plugin SSE event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/server/src/plugins/handlers.ts
Normal file
62
packages/server/src/plugins/handlers.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { EventBus } from "../events/bus"
|
||||||
|
import type { WorkspaceManager } from "../workspaces/manager"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { PluginOutboundEvent } from "./channel"
|
||||||
|
|
||||||
|
export interface PluginInboundEvent {
|
||||||
|
type: string
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HandlerDeps {
|
||||||
|
workspaceManager: WorkspaceManager
|
||||||
|
eventBus: EventBus
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePluginEvent(workspaceId: string, event: PluginInboundEvent, deps: HandlerDeps) {
|
||||||
|
switch (event.type) {
|
||||||
|
case "codenomad.pong":
|
||||||
|
deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received")
|
||||||
|
return
|
||||||
|
|
||||||
|
case "opencode.session.idle": {
|
||||||
|
const workspace = deps.workspaceManager.get(workspaceId)
|
||||||
|
const title = workspace?.name || workspace?.path?.split(/[\\/]/).filter(Boolean).pop() || "CodeNomad"
|
||||||
|
|
||||||
|
const sessionId = readString(event.properties?.sessionID)
|
||||||
|
const message = sessionId ? `Session ${sessionId} is idle` : "Session is idle"
|
||||||
|
|
||||||
|
deps.eventBus.publish({
|
||||||
|
type: "instance.event",
|
||||||
|
instanceId: workspaceId,
|
||||||
|
event: {
|
||||||
|
type: "tui.toast.show",
|
||||||
|
properties: {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
variant: "info",
|
||||||
|
duration: 8000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPingEvent(): PluginOutboundEvent {
|
||||||
|
return {
|
||||||
|
type: "codenomad.ping",
|
||||||
|
properties: {
|
||||||
|
ts: Date.now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim() ? value : undefined
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { registerFilesystemRoutes } from "./routes/filesystem"
|
|||||||
import { registerMetaRoutes } from "./routes/meta"
|
import { registerMetaRoutes } from "./routes/meta"
|
||||||
import { registerEventRoutes } from "./routes/events"
|
import { registerEventRoutes } from "./routes/events"
|
||||||
import { registerStorageRoutes } from "./routes/storage"
|
import { registerStorageRoutes } from "./routes/storage"
|
||||||
|
import { registerPluginRoutes } from "./routes/plugin"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
|
|
||||||
@@ -110,6 +111,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
|
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
75
packages/server/src/server/routes/plugin.ts
Normal file
75
packages/server/src/server/routes/plugin.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||||
|
import type { EventBus } from "../../events/bus"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import { PluginChannelManager } from "../../plugins/channel"
|
||||||
|
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
workspaceManager: WorkspaceManager
|
||||||
|
eventBus: EventBus
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginEventSchema = z.object({
|
||||||
|
type: z.string().min(1),
|
||||||
|
properties: z.record(z.unknown()).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404).send({ error: "Workspace not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||||
|
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||||
|
reply.raw.setHeader("Connection", "keep-alive")
|
||||||
|
reply.raw.flushHeaders?.()
|
||||||
|
reply.hijack()
|
||||||
|
|
||||||
|
const registration = channel.register(request.params.id, reply)
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
channel.send(request.params.id, buildPingEvent())
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
registration.close()
|
||||||
|
reply.raw.end?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
request.raw.on("close", close)
|
||||||
|
request.raw.on("error", close)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleWildcard = async (request: any, reply: any) => {
|
||||||
|
const workspaceId = request.params.id as string
|
||||||
|
const workspace = deps.workspaceManager.get(workspaceId)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404).send({ error: "Workspace not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = (request.params["*"] as string | undefined) ?? ""
|
||||||
|
const normalized = suffix.replace(/^\/+/, "")
|
||||||
|
|
||||||
|
if (normalized === "event" && request.method === "POST") {
|
||||||
|
const parsed = PluginEventSchema.parse(request.body ?? {})
|
||||||
|
handlePluginEvent(workspaceId, parsed, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger })
|
||||||
|
reply.code(204).send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(404).send({ error: "Unknown plugin endpoint" })
|
||||||
|
}
|
||||||
|
|
||||||
|
app.all("/workspaces/:id/plugin/*", handleWildcard)
|
||||||
|
app.all("/workspaces/:id/plugin", handleWildcard)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ interface WorkspaceManagerOptions {
|
|||||||
binaryRegistry: BinaryRegistry
|
binaryRegistry: BinaryRegistry
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
|
getServerBaseUrl: () => string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||||
@@ -108,6 +109,8 @@ export class WorkspaceManager {
|
|||||||
const environment = {
|
const environment = {
|
||||||
...userEnvironment,
|
...userEnvironment,
|
||||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||||
|
CODENOMAD_INSTANCE_ID: id,
|
||||||
|
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user