opencode-plugboard 0.0.0-stage → 0.3.0

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/src/runtime.ts ADDED
@@ -0,0 +1,535 @@
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
+ * - the history sweep state: every registered request session is indexed in the host's plugin
23
+ * storage and, at most every `SWEEP_INTERVAL_MS`, a sweep prunes old ones past the `history`
24
+ * limits (src/session/prune.ts). Both start from request handling (the store's `register`).
25
+ *
26
+ * Registration is explicit: `registerGeneration` returns whether the generation was accepted. A
27
+ * rejected one (different `listen`, options that differ from the live ones, foreign runtime) never
28
+ * serves and must not install hooks or seeds. `ensureBackend` is the per-directory readiness gate
29
+ * the session caller awaits before creating a session: it confirms the app directory, boots its
30
+ * Location from request handling when no generation is there yet, and fails with
31
+ * `backend_unavailable` (503) when the generation there is rejected or never becomes ready.
32
+ *
33
+ * Version policy: a runtime written by a different version is never touched or replaced; this
34
+ * module then behaves as inert (no listener, no pump, an empty detached session store) and logs
35
+ * once that the server needs a restart.
36
+ *
37
+ * Freeze safety: everything a generation calls from plugin setup or a finalizer here is
38
+ * synchronous and makes no call on the plugin context or the filesystem. `Registration.dispose`
39
+ * only mutates state and arms an unref'd timer; the pump is started lazily by request handling,
40
+ * never awaited. `ensureBackend` and `Registration.refresh` run from request handling only.
41
+ */
42
+
43
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
44
+ import { Cause, Effect, Stream } from "effect"
45
+ import { isPlugError, type CatalogModel, type PlugError } from "./contract.js"
46
+ import { errorMessage, log, logError } from "./log.js"
47
+ import { ensureAppDir } from "./session/history.js"
48
+ import type { HistoryPort } from "./session/host.js"
49
+ import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
50
+ import { createSessionStore, type SessionStore } from "./session/state.js"
51
+
52
+ export type Handler = (request: IncomingMessage, response: ServerResponse) => void
53
+
54
+ export interface Registration {
55
+ /** False when the generation was rejected: it serves nothing and must not install hooks or seeds. */
56
+ readonly accepted: boolean
57
+ /** Synchronous; safe in a finalizer. */
58
+ dispose(): void
59
+ /**
60
+ * Whether `ensureBackend` has confirmed this generation's Location as a plugboard app directory.
61
+ * Synchronous, no filesystem; false until then. Everything plugboard installs for app
62
+ * directories (context hook, agent deny, seed tools) must be a no-op while this is false.
63
+ */
64
+ confirmed(): boolean
65
+ /** Setup has finished installing the app-directory registrations (the context hook is in place). */
66
+ ready(): void
67
+ }
68
+
69
+ export interface GenerationInput {
70
+ readonly listen: { readonly host: string; readonly port: number }
71
+ /** The parsed options. Generations must agree on them; compared by canonical JSON (key order is irrelevant). */
72
+ readonly options: unknown
73
+ /** 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. */
74
+ readonly directory: string
75
+ readonly handler: Handler
76
+ /** The enabled models as this generation's Location sees them. */
77
+ readonly loadCatalog: () => Promise<ReadonlyArray<CatalogModel>>
78
+ /**
79
+ * Rebuild what depends on `Registration.confirmed()` (the agent and tool transforms) after it
80
+ * flipped to true for a generation that was set up before. Called from request handling only,
81
+ * never from setup or a finalizer.
82
+ */
83
+ readonly refresh: () => Promise<void>
84
+ /** The host storage and session deletion used to index and prune request sessions, with the configured limits (identical in every accepted generation). */
85
+ readonly history: { readonly port: HistoryPort; readonly limits: HistoryLimits }
86
+ }
87
+
88
+ const GRACE_MS = 10_000
89
+ /** Earliest next history sweep after one ran (sweeps are triggered by requests, never by a timer). */
90
+ const SWEEP_INTERVAL_MS = 10 * 60_000
91
+ /** Earliest next sweep when one left work behind (its budget ran out). */
92
+ const SWEEP_BACKLOG_MS = 60_000
93
+ /** A sweep stops starting host calls after this long; a hung one stops blocking later sweeps 5 s after. */
94
+ const SWEEP_DEADLINE_MS = 60_000
95
+ /** How long `ensureBackend` waits for a Location to boot and its generation to become ready. */
96
+ const BACKEND_TIMEOUT_MS = 15_000
97
+ const BACKEND_POLL_MS = 25
98
+
99
+ /** 4: adds the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
100
+ const VERSION = 4
101
+ const KEY = Symbol.for("opencode-plugboard.runtime")
102
+
103
+ interface Listener {
104
+ readonly server: Server
105
+ readonly listen: { readonly host: string; readonly port: number }
106
+ /** Canonical JSON of the parsed options of the generations the listener serves; later generations must match it. */
107
+ fingerprint: string
108
+ mismatchLogged: boolean
109
+ bound: boolean
110
+ graceTimer?: ReturnType<typeof setTimeout>
111
+ }
112
+
113
+ interface Generation {
114
+ readonly directory: string
115
+ readonly handler: Handler
116
+ readonly loadCatalog: GenerationInput["loadCatalog"]
117
+ readonly refresh: GenerationInput["refresh"]
118
+ readonly history: GenerationInput["history"]
119
+ /** The context hook and transforms are installed (setup got to its end). */
120
+ hooked: boolean
121
+ /** The transforms have been rebuilt since `confirmed` became true for the directory. */
122
+ activated: boolean
123
+ activating?: Promise<void>
124
+ }
125
+
126
+ interface Pump {
127
+ /** Set by `stop`: the end of the fiber is then expected. */
128
+ stopped: boolean
129
+ stop(): void
130
+ }
131
+
132
+ interface Runtime {
133
+ readonly version: number
134
+ readonly sessions: SessionStore
135
+ /** Accepted generations only, newest last. */
136
+ readonly generations: Generation[]
137
+ /** App directories (real paths, strictly inside the canonical data root) confirmed by `ensureBackend`. */
138
+ readonly confirmed: Set<string>
139
+ /** Live rejected generations per directory: tells "rejected" from "no generation yet". */
140
+ readonly rejected: Map<string, number>
141
+ /** History sweep rate limit: no sweep starts before `nextAt` (epoch ms) or while one `running`. */
142
+ readonly sweep: { nextAt: number; running: boolean }
143
+ listener?: Listener
144
+ /** Present while a fiber is subscribed to the server event bus. */
145
+ pump?: Pump
146
+ }
147
+
148
+ const REJECTED: Registration = { accepted: false, dispose() {}, confirmed: () => false, ready() {} }
149
+
150
+ /** Used when a foreign-version runtime owns the process: never registered into, never dispatched to. */
151
+ const detachedSessions = createSessionStore()
152
+ let foreignVersionLogged = false
153
+
154
+ /** The process runtime, created on first use; undefined when a different version already owns the process. */
155
+ function runtime(): Runtime | undefined {
156
+ const slot = globalThis as Record<symbol, unknown>
157
+ const existing = slot[KEY] as { readonly version: number } | undefined
158
+ if (!existing) {
159
+ const created: Runtime = {
160
+ version: VERSION,
161
+ sessions: createSessionStore({ onRegister: (state) => trackSession(created, state.app, state.sessionID) }),
162
+ generations: [],
163
+ confirmed: new Set(),
164
+ rejected: new Map(),
165
+ sweep: { nextAt: 0, running: false },
166
+ }
167
+ slot[KEY] = created
168
+ return created
169
+ }
170
+ if (existing.version === VERSION) return existing as Runtime
171
+ if (!foreignVersionLogged) {
172
+ foreignVersionLogged = true
173
+ logError("runtime.foreign_version", { found: existing.version, expected: VERSION, action: "restart the server" })
174
+ }
175
+ return undefined
176
+ }
177
+
178
+ /**
179
+ * A request session was just created: index it, then sweep if due. Runs from request handling
180
+ * (the store's `register`), never awaited, never throws. Uses the preferred generation's host
181
+ * handles; the storage and `session.remove` are server-wide, so any live generation works.
182
+ */
183
+ function trackSession(rt: Runtime, app: string, sessionID: string): void {
184
+ const history = preferred(rt)?.history
185
+ if (!history) return logError("history.index_skipped", { app, sessionID, reason: "no live generation" })
186
+ // No limits, no index: an unbounded index would grow opencode.db by a row per request forever (#85).
187
+ if (history.limits.maxSessions === undefined && history.limits.maxAgeDays === undefined) return
188
+ void recordSession(history.port, app, sessionID, Date.now())
189
+ .catch((error) => logError("history.index_failed", { app, sessionID, error: errorMessage(error) }))
190
+ .then(() => maybeSweep(rt, history))
191
+ }
192
+
193
+ /**
194
+ * Run a history sweep when limits are set, none is running and the rate limit allows. Bounded:
195
+ * see `sweepHistory`. A sweep that leaves work behind is followed up after `SWEEP_BACKLOG_MS`,
196
+ * a complete one after `SWEEP_INTERVAL_MS`. Logs one line with counts only.
197
+ */
198
+ async function maybeSweep(rt: Runtime, history: GenerationInput["history"]): Promise<void> {
199
+ const { limits, port } = history
200
+ if (limits.maxSessions === undefined && limits.maxAgeDays === undefined) return
201
+ const now = Date.now()
202
+ if (rt.sweep.running || now < rt.sweep.nextAt) return
203
+ rt.sweep.running = true
204
+ rt.sweep.nextAt = now + SWEEP_INTERVAL_MS
205
+ const deadline = AbortSignal.timeout(SWEEP_DEADLINE_MS)
206
+ let timer: ReturnType<typeof setTimeout> | undefined
207
+ try {
208
+ const result = await Promise.race([
209
+ sweepHistory({ port, limits, sessions: rt.sessions, now, signal: deadline }),
210
+ new Promise<undefined>((resolve) => {
211
+ timer = setTimeout(() => resolve(undefined), SWEEP_DEADLINE_MS + 5_000)
212
+ timer.unref?.()
213
+ }),
214
+ ])
215
+ if (!result) return logError("history.sweep_timeout", { limit: SWEEP_DEADLINE_MS })
216
+ if (result.more) rt.sweep.nextAt = Date.now() + SWEEP_BACKLOG_MS
217
+ log("history.swept", { ...result, durationMs: Date.now() - now })
218
+ } catch (error) {
219
+ logError("history.sweep_failed", { error: errorMessage(error) })
220
+ } finally {
221
+ clearTimeout(timer)
222
+ rt.sweep.running = false
223
+ }
224
+ }
225
+
226
+ /** The session store shared by every generation in the process. */
227
+ export const sessionStore = (): SessionStore => runtime()?.sessions ?? detachedSessions
228
+
229
+ /** The generation that serves requests and supplies the catalog: the newest confirmed app-directory one, else the newest. */
230
+ function preferred(rt: Runtime): Generation | undefined {
231
+ for (let index = rt.generations.length - 1; index >= 0; index--) if (rt.confirmed.has(rt.generations[index]!.directory)) return rt.generations[index]
232
+ return rt.generations.at(-1)
233
+ }
234
+
235
+ /**
236
+ * The catalog of the preferred accepted generation. `fallback` (the caller's own loader) is only
237
+ * used when no generation is registered, which a live request does not normally see.
238
+ */
239
+ export function loadServingCatalog(fallback: () => Promise<ReadonlyArray<CatalogModel>>): Promise<ReadonlyArray<CatalogModel>> {
240
+ const rt = runtime()
241
+ return (rt ? (preferred(rt)?.loadCatalog ?? fallback) : fallback)()
242
+ }
243
+
244
+ /** JSON with object keys sorted at every level, so equal options compare equal whatever their key order. */
245
+ function canonicalJson(value: unknown): string {
246
+ const canonical = (item: unknown): unknown => {
247
+ if (Array.isArray(item)) return item.map(canonical)
248
+ if (typeof item !== "object" || item === null) return item
249
+ return Object.fromEntries(
250
+ Object.entries(item)
251
+ .filter(([, entry]) => entry !== undefined)
252
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
253
+ .map(([key, entry]) => [key, canonical(entry)]),
254
+ )
255
+ }
256
+ return JSON.stringify(canonical(value))
257
+ }
258
+
259
+ const unavailable = (response: ServerResponse, message: string) => {
260
+ if (response.headersSent) return response.end()
261
+ const body = JSON.stringify({ error: { message, type: "server_error", code: "unavailable" } })
262
+ response.writeHead(503, { "content-type": "application/json", "content-length": Buffer.byteLength(body) })
263
+ response.end(body)
264
+ }
265
+
266
+ function startListener(rt: Runtime, listen: { host: string; port: number }, fingerprint: string): Listener {
267
+ const listener: Listener = { server: createServer(), listen, fingerprint, mismatchLogged: false, bound: false }
268
+ listener.server.on("request", (request, response) => {
269
+ const current = preferred(rt)
270
+ if (!current) return unavailable(response, "plugboard is reloading")
271
+ try {
272
+ current.handler(request, response)
273
+ } catch (error) {
274
+ logError("listener.handler_failed", { error: errorMessage(error) })
275
+ unavailable(response, "plugboard handler failed")
276
+ }
277
+ })
278
+ listener.server.on("error", (error: NodeJS.ErrnoException) => {
279
+ if (listener.bound) return logError("listener.error", { code: error.code, error: error.message })
280
+ // Never bound: another process (or an older runtime) owns the port. Log once, stay inert.
281
+ logError(error.code === "EADDRINUSE" ? "listener.address_in_use" : "listener.failed", {
282
+ listen: `${listen.host}:${listen.port}`,
283
+ error: error.message,
284
+ })
285
+ })
286
+ listener.server.listen(listen.port, listen.host, () => {
287
+ listener.bound = true
288
+ log("listener.listening", { listen: `${listen.host}:${listen.port}` })
289
+ })
290
+ return listener
291
+ }
292
+
293
+ function closeListener(rt: Runtime, listener: Listener): void {
294
+ if (rt.listener !== listener) return
295
+ rt.listener = undefined
296
+ try {
297
+ rt.pump?.stop()
298
+ } catch {}
299
+ listener.server.close()
300
+ listener.server.closeAllConnections()
301
+ if (listener.bound) log("listener.closed")
302
+ }
303
+
304
+ /**
305
+ * Join (or create) the shared listener. The first accepted generation's options are the
306
+ * listener's (`fingerprint`, canonical JSON of the parsed options): a generation whose options
307
+ * differ is rejected and logs once while any generation is live, so a project config entry can
308
+ * never serve with weaker auth than the live one. When none is live (config reload) the next
309
+ * generation's options are adopted. A different `listen` needs a server restart and is rejected.
310
+ * A rejected generation is not in the generation list: it never serves, supplies no catalog, and
311
+ * the caller must not install hooks or seeds for it.
312
+ */
313
+ export function registerGeneration(input: GenerationInput): Registration {
314
+ const rt = runtime()
315
+ if (!rt) return REJECTED
316
+ const { listen, directory } = input
317
+ const reject = (): Registration => {
318
+ rt.rejected.set(directory, (rt.rejected.get(directory) ?? 0) + 1)
319
+ let disposed = false
320
+ return {
321
+ accepted: false,
322
+ confirmed: () => false,
323
+ ready() {},
324
+ dispose() {
325
+ if (disposed) return
326
+ disposed = true
327
+ const left = (rt.rejected.get(directory) ?? 1) - 1
328
+ if (left > 0) rt.rejected.set(directory, left)
329
+ else rt.rejected.delete(directory)
330
+ },
331
+ }
332
+ }
333
+ const fingerprint = canonicalJson(input.options)
334
+ const listener = (rt.listener ??= startListener(rt, listen, fingerprint))
335
+ if (listener.listen.host !== listen.host || listener.listen.port !== listen.port) {
336
+ if (!listener.mismatchLogged) {
337
+ listener.mismatchLogged = true
338
+ logError("listener.listen_mismatch", {
339
+ bound: `${listener.listen.host}:${listener.listen.port}`,
340
+ requested: `${listen.host}:${listen.port}`,
341
+ action: "this Location is rejected; restart the server after changing `listen`",
342
+ })
343
+ }
344
+ return reject()
345
+ }
346
+ if (listener.fingerprint !== fingerprint) {
347
+ if (rt.generations.length > 0) {
348
+ if (!listener.mismatchLogged) {
349
+ listener.mismatchLogged = true
350
+ logError("listener.options_mismatch", { action: "this Location is rejected; global config is authoritative" })
351
+ }
352
+ return reject()
353
+ }
354
+ listener.fingerprint = fingerprint
355
+ listener.mismatchLogged = false
356
+ }
357
+ if (listener.graceTimer) clearTimeout(listener.graceTimer)
358
+ listener.graceTimer = undefined
359
+ const generation: Generation = {
360
+ directory,
361
+ handler: input.handler,
362
+ loadCatalog: input.loadCatalog,
363
+ refresh: input.refresh,
364
+ history: input.history,
365
+ hooked: false,
366
+ // A directory confirmed before this Location booted needs no rebuild: its transforms already read `confirmed()` as true.
367
+ activated: rt.confirmed.has(directory),
368
+ }
369
+ rt.generations.push(generation)
370
+ return {
371
+ accepted: true,
372
+ confirmed: () => rt.confirmed.has(directory),
373
+ ready() {
374
+ generation.hooked = true
375
+ },
376
+ dispose() {
377
+ const index = rt.generations.indexOf(generation)
378
+ if (index !== -1) rt.generations.splice(index, 1)
379
+ if (rt.listener !== listener || rt.generations.length > 0 || listener.graceTimer) return
380
+ listener.graceTimer = setTimeout(() => {
381
+ listener.graceTimer = undefined
382
+ if (rt.generations.length === 0) closeListener(rt, listener)
383
+ }, GRACE_MS)
384
+ listener.graceTimer.unref?.()
385
+ },
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Start the process-wide event pump if it is not running. The bus is server-wide, so the store
391
+ * drops everything that is not a registered request session before it is buffered. The pump is
392
+ * stopped when the listener closes and restarted by the next caller. `subscribe` is the calling
393
+ * generation's bus subscription; it is only invoked when a pump actually starts.
394
+ *
395
+ * If the fiber ends without `stop` (the subscription failed or was closed), no more events
396
+ * reach the store: every registered request is failed with a 502 and the pump is restarted by
397
+ * the next request.
398
+ */
399
+ export function ensurePump(subscribe: () => Stream.Stream<unknown, unknown, never>): void {
400
+ const rt = runtime()
401
+ if (!rt || rt.pump) return
402
+ const fiber = Effect.runFork(subscribe().pipe(Stream.runForEach((event) => Effect.sync(() => rt.sessions.dispatch(event)))))
403
+ const pump: Pump = {
404
+ stopped: false,
405
+ stop: () => {
406
+ pump.stopped = true
407
+ fiber.interruptUnsafe()
408
+ },
409
+ }
410
+ rt.pump = pump
411
+ fiber.addObserver((exit) => {
412
+ if (rt.pump === pump) rt.pump = undefined
413
+ if (pump.stopped) return
414
+ const failed = rt.sessions.failAll({
415
+ status: 502,
416
+ kind: "server",
417
+ code: "event_stream_lost",
418
+ message: "Lost the event stream of the OpenCode server",
419
+ })
420
+ logError("pump.stopped", {
421
+ exit: exit._tag,
422
+ failedRequests: failed,
423
+ ...(exit._tag === "Failure" ? { error: (Cause.pretty(exit.cause).split("\n")[0] ?? "").slice(0, 200) } : {}),
424
+ })
425
+ })
426
+ }
427
+
428
+ const backendUnavailable = (message: string): PlugError => ({ status: 503, kind: "server", code: "backend_unavailable", message })
429
+
430
+ /** Rejects when `signal` aborts; the underlying promise is left running (its late result or error is swallowed). */
431
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
432
+ return new Promise<T>((resolve, reject) => {
433
+ const onAbort = () => reject(new Error("aborted"))
434
+ if (signal.aborted) return onAbort()
435
+ signal.addEventListener("abort", onAbort, { once: true })
436
+ promise.then(
437
+ (value) => (signal.removeEventListener("abort", onAbort), resolve(value)),
438
+ (error) => (signal.removeEventListener("abort", onAbort), reject(error)),
439
+ )
440
+ })
441
+ }
442
+
443
+ const sleep = (ms: number, signal: AbortSignal) =>
444
+ new Promise<void>((resolve) => {
445
+ if (signal.aborted) return resolve()
446
+ const timer = setTimeout(done, ms)
447
+ function done() {
448
+ clearTimeout(timer)
449
+ signal.removeEventListener("abort", done)
450
+ resolve()
451
+ }
452
+ signal.addEventListener("abort", done, { once: true })
453
+ })
454
+
455
+ /** Rebuild the transforms of a generation set up before its directory was confirmed; shared by concurrent requests. */
456
+ function activate(generation: Generation): Promise<void> {
457
+ generation.activating ??= generation.refresh().then(
458
+ () => void (generation.activated = true),
459
+ (error) => {
460
+ generation.activating = undefined
461
+ throw error
462
+ },
463
+ )
464
+ return generation.activating
465
+ }
466
+
467
+ /**
468
+ * The per-directory readiness gate (docs/architecture.md): resolves with undefined once an
469
+ * accepted generation in the app directory of `app` is ready to rewrite request sessions, else
470
+ * with a `PlugError`, so the caller never prompts a session that no context hook would rewrite.
471
+ * Only ever called from request handling, never from plugin setup.
472
+ *
473
+ * 1. Create and canonicalize the app directory asynchronously (`ensureAppDir`: symlink
474
+ * containment, the resolved directory must be strictly inside the canonical data root).
475
+ * 2. Confirm it (before booting, so a Location that boots now sees it at setup).
476
+ * 3. No generation there yet: `boot(directory)` makes the host boot the Location (any host call
477
+ * scoped to that directory that builds it; the entry passes `ctx.agent.list({ location })`),
478
+ * which loads the plugin there. Abandoned, not interrupted, when the request ends.
479
+ * 4. Generations set up before the directory was confirmed get their transforms rebuilt
480
+ * (`GenerationInput.refresh`).
481
+ * 5. Wait, bounded by `signal` and a timeout, until a generation there is accepted (so it runs
482
+ * with the live options) and has installed its hook. A generation that was rejected (options
483
+ * or `listen` differ) is a 503 `backend_unavailable` at once.
484
+ */
485
+ export async function ensureBackend(
486
+ app: string,
487
+ signal: AbortSignal,
488
+ boot: (directory: string) => Promise<unknown>,
489
+ ): Promise<PlugError | undefined> {
490
+ const rt = runtime()
491
+ if (!rt) return backendUnavailable("A different plugboard runtime owns this server process; restart the server")
492
+ try {
493
+ const directory = await ensureAppDir(app)
494
+ if (isPlugError(directory)) return directory
495
+ const bounded = AbortSignal.any([signal, AbortSignal.timeout(BACKEND_TIMEOUT_MS)])
496
+ rt.confirmed.add(directory)
497
+
498
+ let booted = false
499
+ while (true) {
500
+ if (bounded.aborted)
501
+ return backendUnavailable(
502
+ signal.aborted ? "The request ended before the app directory was ready" : "Timed out waiting for the plugboard app directory to load its backend",
503
+ )
504
+ const here = rt.generations.filter((generation) => generation.directory === directory)
505
+ if (here.length === 0) {
506
+ if ((rt.rejected.get(directory) ?? 0) > 0)
507
+ return backendUnavailable("The plugboard app directory was rejected (its plugin options or `listen` differ from the live ones)")
508
+ if (!booted) {
509
+ booted = true
510
+ try {
511
+ await abortable(boot(directory), bounded)
512
+ } catch (error) {
513
+ if (bounded.aborted) continue
514
+ logError("backend.boot_failed", { app, error: errorMessage(error) })
515
+ return backendUnavailable("Could not start the plugboard app directory")
516
+ }
517
+ continue
518
+ }
519
+ } else {
520
+ try {
521
+ await abortable(Promise.all(here.filter((generation) => !generation.activated).map(activate)), bounded)
522
+ } catch (error) {
523
+ if (bounded.aborted) continue
524
+ throw error
525
+ }
526
+ if (here.some((generation) => generation.hooked && generation.activated)) return undefined
527
+ }
528
+ await sleep(BACKEND_POLL_MS, bounded)
529
+ }
530
+ } catch (error) {
531
+ if (signal.aborted) return backendUnavailable("The request ended before the app directory was ready")
532
+ logError("backend.check_failed", { app, error: errorMessage(error) })
533
+ return backendUnavailable("Could not prepare the plugboard app directory")
534
+ }
535
+ }