opencode-plugboard 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/index.ts +99 -0
- package/package.json +32 -4
- package/src/config.ts +150 -0
- package/src/contract.ts +155 -0
- package/src/http.ts +324 -0
- package/src/log.ts +28 -0
- package/src/models.ts +100 -0
- package/src/openai/request.ts +334 -0
- package/src/openai/response.ts +286 -0
- package/src/openai/route.ts +214 -0
- package/src/runtime.ts +463 -0
- package/src/session/caller.ts +192 -0
- package/src/session/context.ts +175 -0
- package/src/session/events.ts +344 -0
- package/src/session/history.ts +89 -0
- package/src/session/host.ts +175 -0
- package/src/session/media.ts +89 -0
- package/src/session/seeds.ts +47 -0
- package/src/session/state.ts +134 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI front-end route table: `GET /healthz`, `GET /v1/models`, `GET /v1/models/{id}`,
|
|
3
|
+
* `POST /v1/chat/completions`.
|
|
4
|
+
*
|
|
5
|
+
* The flow is: establish cancellation and the deadline, check origin, authenticate, parse, resolve
|
|
6
|
+
* the model, call the ModelCaller, encode. Parsing, policy and encoding are imported directly;
|
|
7
|
+
* only effects (the model caller, the catalog and the serving generation's options) arrive
|
|
8
|
+
* through `RouteHost`.
|
|
9
|
+
*
|
|
10
|
+
* Response header `x-plugboard-session: <id>` carries the id of the history record the call
|
|
11
|
+
* writes (the `ModelCaller` reports it through `CallContext.onRecord`). It is set before the
|
|
12
|
+
* response headers go out (JSON body or first SSE chunk); an id reported after that is ignored.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
16
|
+
import { authenticate, type PlugboardOptions } from "../config.js"
|
|
17
|
+
import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
|
|
18
|
+
import {
|
|
19
|
+
BodyError,
|
|
20
|
+
RequestAborted,
|
|
21
|
+
beginRequest,
|
|
22
|
+
boundEvents,
|
|
23
|
+
checkJsonContentType,
|
|
24
|
+
checkOrigin,
|
|
25
|
+
finishRequest,
|
|
26
|
+
readJsonBody,
|
|
27
|
+
sendJson,
|
|
28
|
+
sseWriter,
|
|
29
|
+
type RequestScope,
|
|
30
|
+
} from "../http.js"
|
|
31
|
+
import { errorMessage, log, logError } from "../log.js"
|
|
32
|
+
import { listAvailableModels, resolveModel } from "../models.js"
|
|
33
|
+
import { parseChatRequest } from "./request.js"
|
|
34
|
+
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
35
|
+
|
|
36
|
+
/** What the serving generation supplies per request. */
|
|
37
|
+
export interface RouteHost {
|
|
38
|
+
readonly options: PlugboardOptions
|
|
39
|
+
readonly caller: ModelCaller
|
|
40
|
+
/** The serving Location's enabled models. */
|
|
41
|
+
loadCatalog(): Promise<ReadonlyArray<CatalogModel>>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SESSION_HEADER = "x-plugboard-session"
|
|
45
|
+
const MODEL_PREFIX = "/v1/models/"
|
|
46
|
+
const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions"]
|
|
47
|
+
|
|
48
|
+
const decodeSegment = (raw: string): string => {
|
|
49
|
+
try {
|
|
50
|
+
return decodeURIComponent(raw)
|
|
51
|
+
} catch {
|
|
52
|
+
return raw
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a request log line may carry: ids and outcomes only. */
|
|
57
|
+
interface RequestInfo {
|
|
58
|
+
route: string
|
|
59
|
+
app?: string
|
|
60
|
+
model?: string
|
|
61
|
+
/** Set when a response committed as 200 and then failed (a stream error frame): the log outcome and the error code. */
|
|
62
|
+
failed?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createOpenAiRoute(host: RouteHost) {
|
|
66
|
+
const fail = (response: ServerResponse, error: PlugError, close = false) =>
|
|
67
|
+
sendJson(response, error.status, encodeError(error), { close })
|
|
68
|
+
|
|
69
|
+
/** The route proper. Throws `BodyError`, `RequestAborted` or an unexpected error; `handle` maps them. */
|
|
70
|
+
async function serve(request: IncomingMessage, response: ServerResponse, scope: RequestScope, info: RequestInfo): Promise<void> {
|
|
71
|
+
let url: URL
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(request.url ?? "/", "http://127.0.0.1")
|
|
74
|
+
} catch {
|
|
75
|
+
return fail(response, { status: 400, kind: "invalid_request", code: "invalid_request_target", message: "Malformed request target" })
|
|
76
|
+
}
|
|
77
|
+
const path = url.pathname
|
|
78
|
+
const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
|
|
79
|
+
const route = `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
|
|
80
|
+
// Arbitrary client paths stay out of the log.
|
|
81
|
+
info.route = modelId !== undefined || KNOWN_ROUTES.includes(route) || route === "GET /healthz" ? route : "unknown"
|
|
82
|
+
|
|
83
|
+
const rejected = checkOrigin(request, host.options)
|
|
84
|
+
if (rejected) return fail(response, rejected)
|
|
85
|
+
|
|
86
|
+
if (route === "GET /healthz") return sendJson(response, 200, { ok: true })
|
|
87
|
+
|
|
88
|
+
if (modelId === undefined && !KNOWN_ROUTES.includes(route)) {
|
|
89
|
+
return fail(response, {
|
|
90
|
+
status: 404,
|
|
91
|
+
kind: "not_found",
|
|
92
|
+
code: "not_found",
|
|
93
|
+
message: `No route for ${request.method} ${path}`,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const auth = authenticate(host.options, request.headers.authorization)
|
|
98
|
+
if (isPlugError(auth)) return fail(response, auth)
|
|
99
|
+
info.app = auth.app
|
|
100
|
+
|
|
101
|
+
if (route === "GET /v1/models" || modelId !== undefined) {
|
|
102
|
+
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()))
|
|
103
|
+
if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
|
|
104
|
+
const item = listed.find((m) => m.id === modelId)
|
|
105
|
+
if (!item) {
|
|
106
|
+
return fail(response, {
|
|
107
|
+
status: 404,
|
|
108
|
+
kind: "not_found",
|
|
109
|
+
code: "model_not_found",
|
|
110
|
+
param: "model",
|
|
111
|
+
message: `Model ${modelId} is not available`,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
info.model = item.id
|
|
115
|
+
return sendJson(response, 200, (encodeModelList([item]) as { data: object[] }).data[0])
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const unsupported = checkJsonContentType(request)
|
|
119
|
+
if (unsupported) return fail(response, unsupported)
|
|
120
|
+
|
|
121
|
+
const body = await readJsonBody(request, scope.signal)
|
|
122
|
+
const parsed = parseChatRequest(body, host.options)
|
|
123
|
+
if (isPlugError(parsed)) return fail(response, parsed)
|
|
124
|
+
const catalog = await scope.race(host.loadCatalog())
|
|
125
|
+
const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
|
|
126
|
+
if (isPlugError(ref)) return fail(response, ref)
|
|
127
|
+
info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
|
|
128
|
+
const call: PlugRequest = { ...parsed.request, ref }
|
|
129
|
+
const { exchange } = parsed
|
|
130
|
+
|
|
131
|
+
// Abandoned requests (client gone, deadline passed) must never reach the model caller.
|
|
132
|
+
if (scope.signal.aborted) throw scope.signal.reason
|
|
133
|
+
const events = boundEvents(
|
|
134
|
+
host.caller.stream(call, {
|
|
135
|
+
app: auth.app,
|
|
136
|
+
signal: scope.signal,
|
|
137
|
+
onRecord: (recordID) => {
|
|
138
|
+
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
139
|
+
response.setHeader(SESSION_HEADER, recordID)
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
scope,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if (exchange.stream) {
|
|
146
|
+
// Nothing is committed until the encoder writes its first frame, so a failure before any
|
|
147
|
+
// wire-visible output (including suppressed reasoning or structured prose) is a real HTTP error.
|
|
148
|
+
const sse = sseWriter(response, scope.signal)
|
|
149
|
+
const { failure } = await streamCompletion(sse.paced(events), exchange, sse.write)
|
|
150
|
+
if (!sse.started && !response.headersSent) {
|
|
151
|
+
if (!scope.signal.aborted || scope.timedOut) fail(response, failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
152
|
+
} else {
|
|
153
|
+
if (failure) info.failed = failure.code ?? failure.kind
|
|
154
|
+
if (!response.writableEnded) response.end()
|
|
155
|
+
}
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = await collectCompletion(events, exchange)
|
|
160
|
+
sendJson(response, result.status, result.body)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
|
|
164
|
+
return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
165
|
+
const started = Date.now()
|
|
166
|
+
const id = crypto.randomUUID().slice(0, 8)
|
|
167
|
+
const info: RequestInfo = { route: "unparsed" }
|
|
168
|
+
const scope = beginRequest(request, response, host.options.requestTimeoutMs)
|
|
169
|
+
let outcome = "ok"
|
|
170
|
+
let error: string | undefined
|
|
171
|
+
try {
|
|
172
|
+
try {
|
|
173
|
+
await serve(request, response, scope, info)
|
|
174
|
+
} catch (caught) {
|
|
175
|
+
if (caught instanceof RequestAborted) {
|
|
176
|
+
outcome = caught.kind
|
|
177
|
+
if (caught.kind === "timeout") {
|
|
178
|
+
if (response.headersSent) response.end()
|
|
179
|
+
else fail(response, scope.timeoutError, true)
|
|
180
|
+
}
|
|
181
|
+
} else if (caught instanceof BodyError) {
|
|
182
|
+
outcome = `body_${caught.status}`
|
|
183
|
+
// 413: the client may still be sending; answer, then close (see `finishRequest`).
|
|
184
|
+
fail(response, { status: caught.status, kind: "invalid_request", message: caught.message }, caught.status === 413)
|
|
185
|
+
} else {
|
|
186
|
+
// Unexpected: the message goes to the log only; the client gets a generic one.
|
|
187
|
+
outcome = "error"
|
|
188
|
+
error = errorMessage(caught)
|
|
189
|
+
if (response.headersSent) {
|
|
190
|
+
if (!response.writableEnded) response.end()
|
|
191
|
+
} else fail(response, { status: 500, kind: "server", message: "Internal server error" })
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (outcome === "ok" && scope.signal.aborted) outcome = scope.timedOut ? "timeout" : "disconnected"
|
|
195
|
+
else if (outcome === "ok" && info.failed !== undefined) {
|
|
196
|
+
outcome = "error"
|
|
197
|
+
error = info.failed
|
|
198
|
+
} else if (outcome === "ok" && response.statusCode >= 400) outcome = `status_${response.statusCode}`
|
|
199
|
+
} catch (caught) {
|
|
200
|
+
outcome = "error"
|
|
201
|
+
error = errorMessage(caught)
|
|
202
|
+
} finally {
|
|
203
|
+
scope.dispose()
|
|
204
|
+
try {
|
|
205
|
+
finishRequest(request, response)
|
|
206
|
+
} catch {}
|
|
207
|
+
if (info.route !== "GET /healthz" || outcome !== "ok") {
|
|
208
|
+
const fields = { id, route: info.route, app: info.app, model: info.model, status: response.statusCode, ms: Date.now() - started, outcome, error }
|
|
209
|
+
if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
|
|
210
|
+
else log("request", fields)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process runtime: the one owner of everything that outlives a plugin generation.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode loads the plugin once per Location, so many generations exist at once, and the module
|
|
5
|
+
* itself may be re-evaluated per Location or on reload. The runtime therefore lives on
|
|
6
|
+
* `globalThis` under a versioned key and holds:
|
|
7
|
+
* - the generation list: the ACCEPTED generations (request handler, catalog loader), newest
|
|
8
|
+
* last. A generation whose Location is a confirmed plugboard app directory is the preferred
|
|
9
|
+
* owner: it serves requests and supplies the catalog; without one the newest generation does;
|
|
10
|
+
* - the confirmed directories: app directories (real paths strictly inside the canonical data
|
|
11
|
+
* root) that `ensureBackend` has verified asynchronously. Nothing in setup touches the
|
|
12
|
+
* filesystem, so a Location is unconfirmed, and plugboard's hook and transforms stay no-ops
|
|
13
|
+
* there, until a request has confirmed its directory (fail closed);
|
|
14
|
+
* - the HTTP listener lifetime: the first generation binds the port, later ones join; when the
|
|
15
|
+
* last generation is gone the listener waits a grace period (a config reload recreates every
|
|
16
|
+
* generation at once) before closing; requests arriving with no live generation get 503;
|
|
17
|
+
* - the event pump lifetime: one fiber on the host's default runtime, owned by no generation
|
|
18
|
+
* scope, forwarding bus events of request sessions to the session store. If it dies, the
|
|
19
|
+
* requests in flight fail with a 502 instead of waiting for their deadline;
|
|
20
|
+
* - the session store (src/session/state.ts), which outlives the listener and owns the request
|
|
21
|
+
* guards, their cleanup timers included.
|
|
22
|
+
*
|
|
23
|
+
* Registration is explicit: `registerGeneration` returns whether the generation was accepted. A
|
|
24
|
+
* rejected one (different `listen`, options that differ from the live ones, foreign runtime) never
|
|
25
|
+
* serves and must not install hooks or seeds. `ensureBackend` is the per-directory readiness gate
|
|
26
|
+
* the session caller awaits before creating a session: it confirms the app directory, boots its
|
|
27
|
+
* Location from request handling when no generation is there yet, and fails with
|
|
28
|
+
* `backend_unavailable` (503) when the generation there is rejected or never becomes ready.
|
|
29
|
+
*
|
|
30
|
+
* Version policy: a runtime written by a different version is never touched or replaced; this
|
|
31
|
+
* module then behaves as inert (no listener, no pump, an empty detached session store) and logs
|
|
32
|
+
* once that the server needs a restart.
|
|
33
|
+
*
|
|
34
|
+
* Freeze safety: everything a generation calls from plugin setup or a finalizer here is
|
|
35
|
+
* synchronous and makes no call on the plugin context or the filesystem. `Registration.dispose`
|
|
36
|
+
* only mutates state and arms an unref'd timer; the pump is started lazily by request handling,
|
|
37
|
+
* never awaited. `ensureBackend` and `Registration.refresh` run from request handling only.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
|
41
|
+
import { Cause, Effect, Stream } from "effect"
|
|
42
|
+
import { isPlugError, type CatalogModel, type PlugError } from "./contract.js"
|
|
43
|
+
import { errorMessage, log, logError } from "./log.js"
|
|
44
|
+
import { ensureAppDir } from "./session/history.js"
|
|
45
|
+
import { createSessionStore, type SessionStore } from "./session/state.js"
|
|
46
|
+
|
|
47
|
+
export type Handler = (request: IncomingMessage, response: ServerResponse) => void
|
|
48
|
+
|
|
49
|
+
export interface Registration {
|
|
50
|
+
/** False when the generation was rejected: it serves nothing and must not install hooks or seeds. */
|
|
51
|
+
readonly accepted: boolean
|
|
52
|
+
/** Synchronous; safe in a finalizer. */
|
|
53
|
+
dispose(): void
|
|
54
|
+
/**
|
|
55
|
+
* Whether `ensureBackend` has confirmed this generation's Location as a plugboard app directory.
|
|
56
|
+
* Synchronous, no filesystem; false until then. Everything plugboard installs for app
|
|
57
|
+
* directories (context hook, agent deny, seed tools) must be a no-op while this is false.
|
|
58
|
+
*/
|
|
59
|
+
confirmed(): boolean
|
|
60
|
+
/** Setup has finished installing the app-directory registrations (the context hook is in place). */
|
|
61
|
+
ready(): void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface GenerationInput {
|
|
65
|
+
readonly listen: { readonly host: string; readonly port: number }
|
|
66
|
+
/** The parsed options. Generations must agree on them; compared by canonical JSON (key order is irrelevant). */
|
|
67
|
+
readonly options: unknown
|
|
68
|
+
/** This generation's Location directory as the host reports it. Compared as a string with the real path `ensureAppDir` returns; never resolved against the filesystem here. */
|
|
69
|
+
readonly directory: string
|
|
70
|
+
readonly handler: Handler
|
|
71
|
+
/** The enabled models as this generation's Location sees them. */
|
|
72
|
+
readonly loadCatalog: () => Promise<ReadonlyArray<CatalogModel>>
|
|
73
|
+
/**
|
|
74
|
+
* Rebuild what depends on `Registration.confirmed()` (the agent and tool transforms) after it
|
|
75
|
+
* flipped to true for a generation that was set up before. Called from request handling only,
|
|
76
|
+
* never from setup or a finalizer.
|
|
77
|
+
*/
|
|
78
|
+
readonly refresh: () => Promise<void>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const GRACE_MS = 10_000
|
|
82
|
+
/** How long `ensureBackend` waits for a Location to boot and its generation to become ready. */
|
|
83
|
+
const BACKEND_TIMEOUT_MS = 15_000
|
|
84
|
+
const BACKEND_POLL_MS = 25
|
|
85
|
+
|
|
86
|
+
/** 3: the runtime tracks confirmed directories and rejected generations (a version-2 runtime lacks both). */
|
|
87
|
+
const VERSION = 3
|
|
88
|
+
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
89
|
+
|
|
90
|
+
interface Listener {
|
|
91
|
+
readonly server: Server
|
|
92
|
+
readonly listen: { readonly host: string; readonly port: number }
|
|
93
|
+
/** Canonical JSON of the parsed options of the generations the listener serves; later generations must match it. */
|
|
94
|
+
fingerprint: string
|
|
95
|
+
mismatchLogged: boolean
|
|
96
|
+
bound: boolean
|
|
97
|
+
graceTimer?: ReturnType<typeof setTimeout>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface Generation {
|
|
101
|
+
readonly directory: string
|
|
102
|
+
readonly handler: Handler
|
|
103
|
+
readonly loadCatalog: GenerationInput["loadCatalog"]
|
|
104
|
+
readonly refresh: GenerationInput["refresh"]
|
|
105
|
+
/** The context hook and transforms are installed (setup got to its end). */
|
|
106
|
+
hooked: boolean
|
|
107
|
+
/** The transforms have been rebuilt since `confirmed` became true for the directory. */
|
|
108
|
+
activated: boolean
|
|
109
|
+
activating?: Promise<void>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface Pump {
|
|
113
|
+
/** Set by `stop`: the end of the fiber is then expected. */
|
|
114
|
+
stopped: boolean
|
|
115
|
+
stop(): void
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface Runtime {
|
|
119
|
+
readonly version: number
|
|
120
|
+
readonly sessions: SessionStore
|
|
121
|
+
/** Accepted generations only, newest last. */
|
|
122
|
+
readonly generations: Generation[]
|
|
123
|
+
/** App directories (real paths, strictly inside the canonical data root) confirmed by `ensureBackend`. */
|
|
124
|
+
readonly confirmed: Set<string>
|
|
125
|
+
/** Live rejected generations per directory: tells "rejected" from "no generation yet". */
|
|
126
|
+
readonly rejected: Map<string, number>
|
|
127
|
+
listener?: Listener
|
|
128
|
+
/** Present while a fiber is subscribed to the server event bus. */
|
|
129
|
+
pump?: Pump
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const REJECTED: Registration = { accepted: false, dispose() {}, confirmed: () => false, ready() {} }
|
|
133
|
+
|
|
134
|
+
/** Used when a foreign-version runtime owns the process: never registered into, never dispatched to. */
|
|
135
|
+
const detachedSessions = createSessionStore()
|
|
136
|
+
let foreignVersionLogged = false
|
|
137
|
+
|
|
138
|
+
/** The process runtime, created on first use; undefined when a different version already owns the process. */
|
|
139
|
+
function runtime(): Runtime | undefined {
|
|
140
|
+
const slot = globalThis as Record<symbol, unknown>
|
|
141
|
+
const existing = slot[KEY] as { readonly version: number } | undefined
|
|
142
|
+
if (!existing) {
|
|
143
|
+
const created: Runtime = { version: VERSION, sessions: createSessionStore(), generations: [], confirmed: new Set(), rejected: new Map() }
|
|
144
|
+
slot[KEY] = created
|
|
145
|
+
return created
|
|
146
|
+
}
|
|
147
|
+
if (existing.version === VERSION) return existing as Runtime
|
|
148
|
+
if (!foreignVersionLogged) {
|
|
149
|
+
foreignVersionLogged = true
|
|
150
|
+
logError("runtime.foreign_version", { found: existing.version, expected: VERSION, action: "restart the server" })
|
|
151
|
+
}
|
|
152
|
+
return undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The session store shared by every generation in the process. */
|
|
156
|
+
export const sessionStore = (): SessionStore => runtime()?.sessions ?? detachedSessions
|
|
157
|
+
|
|
158
|
+
/** The generation that serves requests and supplies the catalog: the newest confirmed app-directory one, else the newest. */
|
|
159
|
+
function preferred(rt: Runtime): Generation | undefined {
|
|
160
|
+
for (let index = rt.generations.length - 1; index >= 0; index--) if (rt.confirmed.has(rt.generations[index]!.directory)) return rt.generations[index]
|
|
161
|
+
return rt.generations.at(-1)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The catalog of the preferred accepted generation. `fallback` (the caller's own loader) is only
|
|
166
|
+
* used when no generation is registered, which a live request does not normally see.
|
|
167
|
+
*/
|
|
168
|
+
export function loadServingCatalog(fallback: () => Promise<ReadonlyArray<CatalogModel>>): Promise<ReadonlyArray<CatalogModel>> {
|
|
169
|
+
const rt = runtime()
|
|
170
|
+
return (rt ? (preferred(rt)?.loadCatalog ?? fallback) : fallback)()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** JSON with object keys sorted at every level, so equal options compare equal whatever their key order. */
|
|
174
|
+
function canonicalJson(value: unknown): string {
|
|
175
|
+
const canonical = (item: unknown): unknown => {
|
|
176
|
+
if (Array.isArray(item)) return item.map(canonical)
|
|
177
|
+
if (typeof item !== "object" || item === null) return item
|
|
178
|
+
return Object.fromEntries(
|
|
179
|
+
Object.entries(item)
|
|
180
|
+
.filter(([, entry]) => entry !== undefined)
|
|
181
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
182
|
+
.map(([key, entry]) => [key, canonical(entry)]),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
return JSON.stringify(canonical(value))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const unavailable = (response: ServerResponse, message: string) => {
|
|
189
|
+
if (response.headersSent) return response.end()
|
|
190
|
+
const body = JSON.stringify({ error: { message, type: "server_error", code: "unavailable" } })
|
|
191
|
+
response.writeHead(503, { "content-type": "application/json", "content-length": Buffer.byteLength(body) })
|
|
192
|
+
response.end(body)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function startListener(rt: Runtime, listen: { host: string; port: number }, fingerprint: string): Listener {
|
|
196
|
+
const listener: Listener = { server: createServer(), listen, fingerprint, mismatchLogged: false, bound: false }
|
|
197
|
+
listener.server.on("request", (request, response) => {
|
|
198
|
+
const current = preferred(rt)
|
|
199
|
+
if (!current) return unavailable(response, "plugboard is reloading")
|
|
200
|
+
try {
|
|
201
|
+
current.handler(request, response)
|
|
202
|
+
} catch (error) {
|
|
203
|
+
logError("listener.handler_failed", { error: errorMessage(error) })
|
|
204
|
+
unavailable(response, "plugboard handler failed")
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
listener.server.on("error", (error: NodeJS.ErrnoException) => {
|
|
208
|
+
if (listener.bound) return logError("listener.error", { code: error.code, error: error.message })
|
|
209
|
+
// Never bound: another process (or an older runtime) owns the port. Log once, stay inert.
|
|
210
|
+
logError(error.code === "EADDRINUSE" ? "listener.address_in_use" : "listener.failed", {
|
|
211
|
+
listen: `${listen.host}:${listen.port}`,
|
|
212
|
+
error: error.message,
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
listener.server.listen(listen.port, listen.host, () => {
|
|
216
|
+
listener.bound = true
|
|
217
|
+
log("listener.listening", { listen: `${listen.host}:${listen.port}` })
|
|
218
|
+
})
|
|
219
|
+
return listener
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function closeListener(rt: Runtime, listener: Listener): void {
|
|
223
|
+
if (rt.listener !== listener) return
|
|
224
|
+
rt.listener = undefined
|
|
225
|
+
try {
|
|
226
|
+
rt.pump?.stop()
|
|
227
|
+
} catch {}
|
|
228
|
+
listener.server.close()
|
|
229
|
+
listener.server.closeAllConnections()
|
|
230
|
+
if (listener.bound) log("listener.closed")
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Join (or create) the shared listener. The first accepted generation's options are the
|
|
235
|
+
* listener's (`fingerprint`, canonical JSON of the parsed options): a generation whose options
|
|
236
|
+
* differ is rejected and logs once while any generation is live, so a project config entry can
|
|
237
|
+
* never serve with weaker auth than the live one. When none is live (config reload) the next
|
|
238
|
+
* generation's options are adopted. A different `listen` needs a server restart and is rejected.
|
|
239
|
+
* A rejected generation is not in the generation list: it never serves, supplies no catalog, and
|
|
240
|
+
* the caller must not install hooks or seeds for it.
|
|
241
|
+
*/
|
|
242
|
+
export function registerGeneration(input: GenerationInput): Registration {
|
|
243
|
+
const rt = runtime()
|
|
244
|
+
if (!rt) return REJECTED
|
|
245
|
+
const { listen, directory } = input
|
|
246
|
+
const reject = (): Registration => {
|
|
247
|
+
rt.rejected.set(directory, (rt.rejected.get(directory) ?? 0) + 1)
|
|
248
|
+
let disposed = false
|
|
249
|
+
return {
|
|
250
|
+
accepted: false,
|
|
251
|
+
confirmed: () => false,
|
|
252
|
+
ready() {},
|
|
253
|
+
dispose() {
|
|
254
|
+
if (disposed) return
|
|
255
|
+
disposed = true
|
|
256
|
+
const left = (rt.rejected.get(directory) ?? 1) - 1
|
|
257
|
+
if (left > 0) rt.rejected.set(directory, left)
|
|
258
|
+
else rt.rejected.delete(directory)
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const fingerprint = canonicalJson(input.options)
|
|
263
|
+
const listener = (rt.listener ??= startListener(rt, listen, fingerprint))
|
|
264
|
+
if (listener.listen.host !== listen.host || listener.listen.port !== listen.port) {
|
|
265
|
+
if (!listener.mismatchLogged) {
|
|
266
|
+
listener.mismatchLogged = true
|
|
267
|
+
logError("listener.listen_mismatch", {
|
|
268
|
+
bound: `${listener.listen.host}:${listener.listen.port}`,
|
|
269
|
+
requested: `${listen.host}:${listen.port}`,
|
|
270
|
+
action: "this Location is rejected; restart the server after changing `listen`",
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
return reject()
|
|
274
|
+
}
|
|
275
|
+
if (listener.fingerprint !== fingerprint) {
|
|
276
|
+
if (rt.generations.length > 0) {
|
|
277
|
+
if (!listener.mismatchLogged) {
|
|
278
|
+
listener.mismatchLogged = true
|
|
279
|
+
logError("listener.options_mismatch", { action: "this Location is rejected; global config is authoritative" })
|
|
280
|
+
}
|
|
281
|
+
return reject()
|
|
282
|
+
}
|
|
283
|
+
listener.fingerprint = fingerprint
|
|
284
|
+
listener.mismatchLogged = false
|
|
285
|
+
}
|
|
286
|
+
if (listener.graceTimer) clearTimeout(listener.graceTimer)
|
|
287
|
+
listener.graceTimer = undefined
|
|
288
|
+
const generation: Generation = {
|
|
289
|
+
directory,
|
|
290
|
+
handler: input.handler,
|
|
291
|
+
loadCatalog: input.loadCatalog,
|
|
292
|
+
refresh: input.refresh,
|
|
293
|
+
hooked: false,
|
|
294
|
+
// A directory confirmed before this Location booted needs no rebuild: its transforms already read `confirmed()` as true.
|
|
295
|
+
activated: rt.confirmed.has(directory),
|
|
296
|
+
}
|
|
297
|
+
rt.generations.push(generation)
|
|
298
|
+
return {
|
|
299
|
+
accepted: true,
|
|
300
|
+
confirmed: () => rt.confirmed.has(directory),
|
|
301
|
+
ready() {
|
|
302
|
+
generation.hooked = true
|
|
303
|
+
},
|
|
304
|
+
dispose() {
|
|
305
|
+
const index = rt.generations.indexOf(generation)
|
|
306
|
+
if (index !== -1) rt.generations.splice(index, 1)
|
|
307
|
+
if (rt.listener !== listener || rt.generations.length > 0 || listener.graceTimer) return
|
|
308
|
+
listener.graceTimer = setTimeout(() => {
|
|
309
|
+
listener.graceTimer = undefined
|
|
310
|
+
if (rt.generations.length === 0) closeListener(rt, listener)
|
|
311
|
+
}, GRACE_MS)
|
|
312
|
+
listener.graceTimer.unref?.()
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Start the process-wide event pump if it is not running. The bus is server-wide, so the store
|
|
319
|
+
* drops everything that is not a registered request session before it is buffered. The pump is
|
|
320
|
+
* stopped when the listener closes and restarted by the next caller. `subscribe` is the calling
|
|
321
|
+
* generation's bus subscription; it is only invoked when a pump actually starts.
|
|
322
|
+
*
|
|
323
|
+
* If the fiber ends without `stop` (the subscription failed or was closed), no more events
|
|
324
|
+
* reach the store: every registered request is failed with a 502 and the pump is restarted by
|
|
325
|
+
* the next request.
|
|
326
|
+
*/
|
|
327
|
+
export function ensurePump(subscribe: () => Stream.Stream<unknown, unknown, never>): void {
|
|
328
|
+
const rt = runtime()
|
|
329
|
+
if (!rt || rt.pump) return
|
|
330
|
+
const fiber = Effect.runFork(subscribe().pipe(Stream.runForEach((event) => Effect.sync(() => rt.sessions.dispatch(event)))))
|
|
331
|
+
const pump: Pump = {
|
|
332
|
+
stopped: false,
|
|
333
|
+
stop: () => {
|
|
334
|
+
pump.stopped = true
|
|
335
|
+
fiber.interruptUnsafe()
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
rt.pump = pump
|
|
339
|
+
fiber.addObserver((exit) => {
|
|
340
|
+
if (rt.pump === pump) rt.pump = undefined
|
|
341
|
+
if (pump.stopped) return
|
|
342
|
+
const failed = rt.sessions.failAll({
|
|
343
|
+
status: 502,
|
|
344
|
+
kind: "server",
|
|
345
|
+
code: "event_stream_lost",
|
|
346
|
+
message: "Lost the event stream of the OpenCode server",
|
|
347
|
+
})
|
|
348
|
+
logError("pump.stopped", {
|
|
349
|
+
exit: exit._tag,
|
|
350
|
+
failedRequests: failed,
|
|
351
|
+
...(exit._tag === "Failure" ? { error: (Cause.pretty(exit.cause).split("\n")[0] ?? "").slice(0, 200) } : {}),
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const backendUnavailable = (message: string): PlugError => ({ status: 503, kind: "server", code: "backend_unavailable", message })
|
|
357
|
+
|
|
358
|
+
/** Rejects when `signal` aborts; the underlying promise is left running (its late result or error is swallowed). */
|
|
359
|
+
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
360
|
+
return new Promise<T>((resolve, reject) => {
|
|
361
|
+
const onAbort = () => reject(new Error("aborted"))
|
|
362
|
+
if (signal.aborted) return onAbort()
|
|
363
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
364
|
+
promise.then(
|
|
365
|
+
(value) => (signal.removeEventListener("abort", onAbort), resolve(value)),
|
|
366
|
+
(error) => (signal.removeEventListener("abort", onAbort), reject(error)),
|
|
367
|
+
)
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const sleep = (ms: number, signal: AbortSignal) =>
|
|
372
|
+
new Promise<void>((resolve) => {
|
|
373
|
+
if (signal.aborted) return resolve()
|
|
374
|
+
const timer = setTimeout(done, ms)
|
|
375
|
+
function done() {
|
|
376
|
+
clearTimeout(timer)
|
|
377
|
+
signal.removeEventListener("abort", done)
|
|
378
|
+
resolve()
|
|
379
|
+
}
|
|
380
|
+
signal.addEventListener("abort", done, { once: true })
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
/** Rebuild the transforms of a generation set up before its directory was confirmed; shared by concurrent requests. */
|
|
384
|
+
function activate(generation: Generation): Promise<void> {
|
|
385
|
+
generation.activating ??= generation.refresh().then(
|
|
386
|
+
() => void (generation.activated = true),
|
|
387
|
+
(error) => {
|
|
388
|
+
generation.activating = undefined
|
|
389
|
+
throw error
|
|
390
|
+
},
|
|
391
|
+
)
|
|
392
|
+
return generation.activating
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The per-directory readiness gate (docs/architecture.md): resolves with undefined once an
|
|
397
|
+
* accepted generation in the app directory of `app` is ready to rewrite request sessions, else
|
|
398
|
+
* with a `PlugError`, so the caller never prompts a session that no context hook would rewrite.
|
|
399
|
+
* Only ever called from request handling, never from plugin setup.
|
|
400
|
+
*
|
|
401
|
+
* 1. Create and canonicalize the app directory asynchronously (`ensureAppDir`: symlink
|
|
402
|
+
* containment, the resolved directory must be strictly inside the canonical data root).
|
|
403
|
+
* 2. Confirm it (before booting, so a Location that boots now sees it at setup).
|
|
404
|
+
* 3. No generation there yet: `boot(directory)` makes the host boot the Location (any host call
|
|
405
|
+
* scoped to that directory that builds it; the entry passes `ctx.agent.list({ location })`),
|
|
406
|
+
* which loads the plugin there. Abandoned, not interrupted, when the request ends.
|
|
407
|
+
* 4. Generations set up before the directory was confirmed get their transforms rebuilt
|
|
408
|
+
* (`GenerationInput.refresh`).
|
|
409
|
+
* 5. Wait, bounded by `signal` and a timeout, until a generation there is accepted (so it runs
|
|
410
|
+
* with the live options) and has installed its hook. A generation that was rejected (options
|
|
411
|
+
* or `listen` differ) is a 503 `backend_unavailable` at once.
|
|
412
|
+
*/
|
|
413
|
+
export async function ensureBackend(
|
|
414
|
+
app: string,
|
|
415
|
+
signal: AbortSignal,
|
|
416
|
+
boot: (directory: string) => Promise<unknown>,
|
|
417
|
+
): Promise<PlugError | undefined> {
|
|
418
|
+
const rt = runtime()
|
|
419
|
+
if (!rt) return backendUnavailable("A different plugboard runtime owns this server process; restart the server")
|
|
420
|
+
try {
|
|
421
|
+
const directory = await ensureAppDir(app)
|
|
422
|
+
if (isPlugError(directory)) return directory
|
|
423
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(BACKEND_TIMEOUT_MS)])
|
|
424
|
+
rt.confirmed.add(directory)
|
|
425
|
+
|
|
426
|
+
let booted = false
|
|
427
|
+
while (true) {
|
|
428
|
+
if (bounded.aborted)
|
|
429
|
+
return backendUnavailable(
|
|
430
|
+
signal.aborted ? "The request ended before the app directory was ready" : "Timed out waiting for the plugboard app directory to load its backend",
|
|
431
|
+
)
|
|
432
|
+
const here = rt.generations.filter((generation) => generation.directory === directory)
|
|
433
|
+
if (here.length === 0) {
|
|
434
|
+
if ((rt.rejected.get(directory) ?? 0) > 0)
|
|
435
|
+
return backendUnavailable("The plugboard app directory was rejected (its plugin options or `listen` differ from the live ones)")
|
|
436
|
+
if (!booted) {
|
|
437
|
+
booted = true
|
|
438
|
+
try {
|
|
439
|
+
await abortable(boot(directory), bounded)
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (bounded.aborted) continue
|
|
442
|
+
logError("backend.boot_failed", { app, error: errorMessage(error) })
|
|
443
|
+
return backendUnavailable("Could not start the plugboard app directory")
|
|
444
|
+
}
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
try {
|
|
449
|
+
await abortable(Promise.all(here.filter((generation) => !generation.activated).map(activate)), bounded)
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (bounded.aborted) continue
|
|
452
|
+
throw error
|
|
453
|
+
}
|
|
454
|
+
if (here.some((generation) => generation.hooked && generation.activated)) return undefined
|
|
455
|
+
}
|
|
456
|
+
await sleep(BACKEND_POLL_MS, bounded)
|
|
457
|
+
}
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (signal.aborted) return backendUnavailable("The request ended before the app directory was ready")
|
|
460
|
+
logError("backend.check_failed", { app, error: errorMessage(error) })
|
|
461
|
+
return backendUnavailable("Could not prepare the plugboard app directory")
|
|
462
|
+
}
|
|
463
|
+
}
|