@rynx-ai/server 0.1.10 → 0.1.11-beta.10

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.
Files changed (30) hide show
  1. package/dist/control-api.d.ts +17 -52
  2. package/dist/control-api.js +755 -341
  3. package/dist/control-web/assets/{highlighted-body-OFNGDK62-DgFZpTNw.js → highlighted-body-OFNGDK62-IdclXfSt.js} +1 -1
  4. package/dist/control-web/assets/index-5n6D-i-c.css +32 -0
  5. package/dist/control-web/assets/index-DhsO7WjD.js +709 -0
  6. package/dist/control-web/assets/{mermaid-GHXKKRXX-BDikE3W1.js → mermaid-GHXKKRXX-BqMPrbIZ.js} +3 -3
  7. package/dist/control-web/index.html +2 -2
  8. package/dist/control-web-dist.d.ts +1 -1
  9. package/dist/control-web-dist.js +4 -2
  10. package/dist/machine-session-service.d.ts +135 -27
  11. package/dist/machine-session-service.js +410 -58
  12. package/dist/remote-runtime-dispatcher.d.ts +12 -2
  13. package/dist/remote-runtime-dispatcher.js +56 -0
  14. package/dist/remote-runtime-session-projection.js +1 -0
  15. package/dist/server.d.ts +59 -29
  16. package/dist/server.js +158 -143
  17. package/dist/session-browser-service.d.ts +8 -0
  18. package/dist/session-browser-service.js +37 -20
  19. package/dist/session-portal.d.ts +42 -0
  20. package/dist/session-portal.js +746 -0
  21. package/dist/session-runtime-index.d.ts +7 -0
  22. package/dist/session-runtime-index.js +42 -6
  23. package/dist/session-terminal-host.d.ts +6 -0
  24. package/dist/session-terminal-host.js +56 -41
  25. package/dist/terminal-ws.js +8 -6
  26. package/package.json +7 -7
  27. package/dist/channel-manager.d.ts +0 -60
  28. package/dist/channel-manager.js +0 -106
  29. package/dist/control-web/assets/index-CnVtOmLv.css +0 -32
  30. package/dist/control-web/assets/index-Dlywgy58.js +0 -661
package/dist/server.js CHANGED
@@ -3,7 +3,6 @@ import Koa from "koa";
3
3
  import Router from "@koa/router";
4
4
  import { loadConfig, listAgentSpecs, loadAgentSpec, resolveSessionExecution, resolveRuntimeModel, ConversationRuntime, InMemorySessionBus, persistSessionEvent, } from "@rynx-ai/core";
5
5
  import { RunnerManager, FileCodexSessionStore, resolveCodexSessionStorePath, } from "@rynx-ai/runtime";
6
- import { ChannelManager } from "./channel-manager.js";
7
6
  import { createControlRouter, } from "./control-api.js";
8
7
  import { attachEmulatorTouchWs } from "./emulator-touch-ws.js";
9
8
  import { attachTerminalWs } from "./terminal-ws.js";
@@ -11,10 +10,11 @@ import { attachBrowserSurfaceWs } from "./browser-surface-ws.js";
11
10
  import { attachEmulatorSurfaceWs } from "./emulator-surface-ws.js";
12
11
  import { attachDesktopBrowserHostServer, } from "./desktop-browser-host.js";
13
12
  import { resolveControlWebDist, sendSpaFile } from "./control-web-dist.js";
13
+ import { startSessionPortal, } from "./session-portal.js";
14
+ export { createSessionPortalApp, startSessionPortal } from "./session-portal.js";
14
15
  import { SessionRuntimeIndex } from "./session-runtime-index.js";
15
16
  import { createRunnerSessionTerminalHost, } from "./session-terminal-host.js";
16
17
  import { runtimeWebAccessPolicyForBindHost, } from "./runtime-web-auth.js";
17
- export { ChannelManager } from "./channel-manager.js";
18
18
  export { MachineSessionService, MachineSessionServiceFailure, MachineSessionServiceInputError, synthesizeSessionTitle, } from "./machine-session-service.js";
19
19
  export { SessionBrowserHostError, SessionBrowserService, SessionBrowserServiceError, SessionBrowserSurfaceUnsupportedError, } from "./session-browser-service.js";
20
20
  export { SessionEmulatorBindingConflictError, SessionEmulatorService, SessionEmulatorServiceError, } from "./session-emulator-service.js";
@@ -26,6 +26,7 @@ export { attachDirectRuntimeBrowserSurfaceServer, DirectRuntimeBrowserSurfaceHos
26
26
  export { attachDirectRuntimeBrowserInspectServer, DirectRuntimeBrowserInspectHostError, } from "./direct-runtime-browser-inspect-server.js";
27
27
  export { RemoteRuntimeDispatcher } from "./remote-runtime-dispatcher.js";
28
28
  export { createRunnerSessionTerminalHost, SessionTerminalOpenError, SessionTerminalStreamError, } from "./session-terminal-host.js";
29
+ export { listRuntimeModels } from "@rynx-ai/runtime";
29
30
  /** Preserve one session's mirror order without serializing unrelated sessions. */
30
31
  export class SessionMirrorQueue {
31
32
  persist;
@@ -60,18 +61,24 @@ export function createSessionRuntimeServices(input) {
60
61
  ...(input.sessionContextProvider
61
62
  ? { sessionContextProvider: input.sessionContextProvider }
62
63
  : {}),
64
+ ...(input.admissionOpen ? { admissionOpen: input.admissionOpen } : {}),
65
+ ...(input.admissionReserve ? { admissionReserve: input.admissionReserve } : {}),
63
66
  });
64
67
  const sessionBus = new InMemorySessionBus();
65
68
  const sessionRuntimeIndex = new SessionRuntimeIndex();
69
+ const sessionMirrorQueue = new SessionMirrorQueue((sessionId, event) => persistSessionEvent(sessionId, event, { sessionLog: input.sessionLog, sessionBus }));
70
+ const mirrorSessionEvent = (sessionId, event) => {
71
+ sessionRuntimeIndex.observe(sessionId, event);
72
+ return sessionMirrorQueue.enqueue(sessionId, event);
73
+ };
66
74
  const conversationRuntime = new ConversationRuntime({
67
75
  config: input.config,
68
76
  executor: runnerManager,
69
77
  sessionBus,
78
+ sessionEventSink: mirrorSessionEvent,
70
79
  });
71
- const sessionMirrorQueue = new SessionMirrorQueue((sessionId, event) => persistSessionEvent(sessionId, event, { sessionLog: input.sessionLog, sessionBus }));
72
80
  runnerManager.onMirror((sessionId, event) => {
73
- sessionRuntimeIndex.observe(sessionId, event);
74
- void sessionMirrorQueue.enqueue(sessionId, event).catch((error) => {
81
+ void mirrorSessionEvent(sessionId, event).catch((error) => {
75
82
  input.onMirrorError?.(error, sessionId);
76
83
  });
77
84
  });
@@ -81,6 +88,7 @@ export function createSessionRuntimeServices(input) {
81
88
  sessionBus,
82
89
  sessionRuntimeIndex,
83
90
  conversationRuntime,
91
+ publishSessionEvent: mirrorSessionEvent,
84
92
  };
85
93
  }
86
94
  /**
@@ -131,7 +139,7 @@ export function createApp({ config = loadConfig(), control } = {}) {
131
139
  }
132
140
  return app;
133
141
  }
134
- export async function startServer({ config = loadConfig(), channelInstances, loadInstances, control, sessionLog, sessionRegistry, pluginRuntime, daemonManagement, remoteRuntime, remoteRuntimeAdmin, runtimeTargetControl, runtimeConnectionResolver, localSessionResources, sessionEmulators, sessionRuntimeServices, sessionLifecycle, sessionTerminals, runtimeLocalBrowser, providerClis, desktopBrowserHost, } = {}) {
142
+ export async function startServer({ config = loadConfig(), control, sessionLog, sessionRegistry, pluginRuntime, daemonManagement, remoteRuntime, remoteRuntimeAdmin, runtimeTargetControl, runtimeConnectionResolver, localSessionResources, localMachineSessions, sessionEmulators, sessionRuntimeServices, sessionLifecycle, sessionTerminals, runtimeLocalBrowser, providerClis, desktopBrowserHost, sessionPortalAuthorization, } = {}) {
135
143
  const runtimeWebAccess = runtimeWebAccessPolicyForBindHost(config.HOST);
136
144
  const desktopBrowserManagementToken = desktopBrowserHost
137
145
  ? requireDaemonManagementToken(daemonManagement)
@@ -148,12 +156,52 @@ export async function startServer({ config = loadConfig(), channelInstances, loa
148
156
  createRunnerSessionTerminalHost({
149
157
  runnerManager,
150
158
  resolveLiveSession: async (sessionId) => {
151
- const session = await sessionStore.get(sessionId);
159
+ const session = sessionRegistry?.get(sessionId);
152
160
  if (!session || !runnerManager.hasLiveSession(sessionId))
153
161
  return undefined;
154
- return { cwd: session.cwd };
162
+ return { cwd: session.workspace.cwd };
155
163
  },
156
164
  });
165
+ const controlShutdown = new AbortController();
166
+ const controlSurface = control
167
+ ? {
168
+ deps: control,
169
+ runtime: conversationRuntime,
170
+ sessionBus,
171
+ sessionLog,
172
+ runnerManager,
173
+ sessionStore,
174
+ pluginRuntimeStatus: pluginRuntime
175
+ ? () => pluginRuntime.statusSnapshot()
176
+ : undefined,
177
+ daemonManagement,
178
+ remoteRuntime,
179
+ remoteRuntimeAdmin,
180
+ runtimeTargetControl,
181
+ runtimeConnectionResolver,
182
+ localSessionResources,
183
+ localMachineSessions,
184
+ sessionEmulators,
185
+ shutdownSignal: controlShutdown.signal,
186
+ sessionRuntimeIndex,
187
+ sessionLifecycle,
188
+ runtimeLocalBrowser,
189
+ providerClis,
190
+ }
191
+ : undefined;
192
+ const sessionPortalConfig = config.RYNX_SESSION_PORTAL_PORT !== undefined
193
+ ? (() => {
194
+ if (runtimeWebAccess.mode !== "loopback") {
195
+ throw new Error("Session Portal requires the ordinary Control listener to bind loopback only");
196
+ }
197
+ const controlWebDist = resolveControlWebDist();
198
+ if (!sessionPortalAuthorization || !controlSurface || !controlWebDist) {
199
+ throw new Error("Session Portal requires authorization, Control services, and the native Control Web build");
200
+ }
201
+ return { authorization: sessionPortalAuthorization, controlWebDist };
202
+ })()
203
+ : undefined;
204
+ let sessionPortalServer;
157
205
  if (pluginRuntime) {
158
206
  if (!sessionRegistry)
159
207
  throw new Error("plugin runtime requires a machine session registry");
@@ -175,165 +223,132 @@ export async function startServer({ config = loadConfig(), channelInstances, loa
175
223
  }),
176
224
  },
177
225
  interruptSession: (sessionId) => runnerManager.interruptLiveSession(sessionId),
226
+ terminateSession: (sessionId) => runnerManager.terminateLiveSession(sessionId),
178
227
  resolveSessionInteraction: (sessionId, interactionId, resolution) => runnerManager.resolveInteraction(sessionId, interactionId, resolution),
228
+ ...(sessionPortalConfig
229
+ ? {
230
+ sessionPortal: {
231
+ scheme: "http",
232
+ bindHost: config.RYNX_SESSION_PORTAL_HOST,
233
+ port: config.RYNX_SESSION_PORTAL_PORT,
234
+ },
235
+ }
236
+ : {}),
179
237
  });
180
238
  }
181
- // claude `/clear`·`/fork` rotates to a fresh machine-session; record its meta so
182
- // the console list shows it with the carried-over launch target/model + a source tag.
183
- // (It also surfaces via the log-merge once its first turn mirrors.)
239
+ // Provider TUI `/clear`·`/fork` uses the same publication boundary as an
240
+ // explicit Rynx fork. The manager exposes the transferred terminal only after
241
+ // this resolves, so no target can appear with missing history or metadata.
184
242
  runnerManager.onRotate((r) => {
185
- const source = control?.getSessionMeta(r.from);
186
- control?.createSessionMeta({
187
- id: r.to,
188
- source: r.kind === "fork" ? "fork" : "clear",
189
- ...(r.agent
190
- ? { agent: r.agent }
191
- : source?.provider
192
- ? { provider: source.provider }
193
- : {}),
194
- ...(r.model ? { model: r.model } : {}),
195
- createdAt: new Date().toISOString(),
243
+ if (!localMachineSessions) {
244
+ throw new Error("native Session rotation requires the machine Session service");
245
+ }
246
+ return localMachineSessions.recordNativeRotation({
247
+ sourceSessionId: r.from,
248
+ targetSessionId: r.to,
249
+ kind: r.kind,
196
250
  });
197
251
  });
198
- // New path: per-instance contexts via the ChannelManager (the daemon supplies
199
- // `loadInstances`; the control plane drives live start/stop/reload).
200
- const resolveInstances = loadInstances ?? (channelInstances ? async () => channelInstances : null);
201
- if (resolveInstances) {
202
- const controlShutdown = new AbortController();
203
- const manager = new ChannelManager({
204
- config,
205
- conversationRuntime,
206
- capabilities: runnerManager,
207
- sessionStore,
208
- sessionLog,
209
- sessionBus,
210
- sessionRegistry,
211
- loadInstances: resolveInstances,
212
- });
213
- const app = createApp({
214
- config,
215
- control: control
216
- ? {
217
- manager,
218
- deps: control,
219
- runtime: conversationRuntime,
220
- sessionBus,
221
- sessionLog,
222
- runnerManager,
223
- sessionStore,
224
- pluginRuntimeStatus: pluginRuntime
225
- ? () => pluginRuntime.statusSnapshot()
226
- : undefined,
227
- pluginRuntimeReload: pluginRuntime
228
- ? (pluginId) => pluginRuntime.reloadPluginRuntime(pluginId)
229
- : undefined,
230
- pluginRuntimeEnsure: pluginRuntime
231
- ? (pluginId) => pluginRuntime.ensurePluginRuntime(pluginId)
232
- : undefined,
233
- daemonManagement,
234
- remoteRuntime,
235
- remoteRuntimeAdmin,
236
- runtimeTargetControl,
237
- runtimeConnectionResolver,
238
- localSessionResources,
239
- sessionEmulators,
240
- shutdownSignal: controlShutdown.signal,
241
- sessionRuntimeIndex,
242
- sessionLifecycle,
243
- runtimeLocalBrowser,
244
- providerClis,
245
- }
246
- : undefined,
247
- });
248
- return new Promise((resolve) => {
249
- const server = app.listen(config.PORT, config.HOST, () => {
250
- console.log(JSON.stringify({
251
- level: config.LOG_LEVEL,
252
- msg: "Harness agent server listening",
253
- host: config.HOST,
254
- port: config.PORT,
255
- runtime: config.AGENT_RUNTIME,
256
- model: resolveRuntimeModel(config, config.AGENT_RUNTIME),
257
- }));
258
- const startup = (async () => {
259
- await pluginRuntime?.startPluginRuntimes();
260
- await manager.startAll();
261
- })();
262
- void startup.catch((error) => {
263
- console.error(JSON.stringify({
264
- level: "error",
265
- type: "plugin-runtime",
266
- event: "startup_failed",
267
- error: error instanceof Error ? error.message : String(error),
268
- }));
269
- });
270
- // Live WS bridges share this http.Server (no second port).
271
- const webSocketServers = [
272
- attachTerminalWs(server, {
273
- runtimeConnectionResolver,
274
- legacyLocalTerminalHost,
275
- runtimeWebAccess,
276
- }),
277
- attachBrowserSurfaceWs(server, { runtimeConnectionResolver, runtimeWebAccess }),
278
- attachEmulatorSurfaceWs(server, { runtimeConnectionResolver, runtimeWebAccess }),
279
- ];
280
- if (desktopBrowserHost && desktopBrowserManagementToken) {
281
- webSocketServers.push(attachDesktopBrowserHostServer(server, {
282
- ...desktopBrowserHost,
283
- managementToken: desktopBrowserManagementToken,
284
- }));
285
- }
286
- if (control?.emulator) {
287
- webSocketServers.push(attachEmulatorTouchWs(server, { emulator: control.emulator }));
288
- }
289
- resolve(bindServerLifecycle(server, {
290
- fence: () => {
291
- controlShutdown.abort(new Error("Rynx server is shutting down"));
292
- },
293
- closeUpgrades: async () => {
294
- await closeWebSocketServers(webSocketServers);
295
- },
296
- cleanup: async () => {
297
- await runCleanupSteps([
298
- () => startup.catch(() => undefined),
299
- () => manager.stopAll(),
300
- () => Promise.resolve(daemonManagement?.pluginManagement?.dispose?.()),
301
- () => pluginRuntime?.dispose() ?? Promise.resolve(),
302
- () => runnerManager.stop(),
303
- ]);
304
- },
305
- }));
252
+ const app = createApp({
253
+ config,
254
+ control: controlSurface,
255
+ });
256
+ return new Promise((resolve, reject) => {
257
+ let startupWebSocketServers = [];
258
+ const rejectBeforeListening = (error) => reject(error);
259
+ const server = app.listen(config.PORT, config.HOST, () => {
260
+ server.off("error", rejectBeforeListening);
261
+ void finishStartup(server).catch(async (error) => {
262
+ controlShutdown.abort(asError(error));
263
+ sessionPortalServer?.stopAccepting();
264
+ server.closeAllConnections?.();
265
+ await Promise.allSettled([
266
+ closeWebSocketServers(startupWebSocketServers),
267
+ sessionPortalServer?.shutdown() ?? Promise.resolve(),
268
+ new Promise((done) => server.close(() => done())),
269
+ Promise.resolve(daemonManagement?.pluginManagement?.dispose?.()),
270
+ Promise.resolve(pluginRuntime?.dispose()),
271
+ runnerManager.stop(),
272
+ ]);
273
+ reject(error);
306
274
  });
307
275
  });
308
- }
309
- // Core-only mode: no chat instances and no plugin code are required.
310
- const app = createApp();
311
- return new Promise((resolve) => {
312
- const server = app.listen(config.PORT, config.HOST, () => {
276
+ server.once("error", rejectBeforeListening);
277
+ const finishStartup = async (server) => {
313
278
  console.log(JSON.stringify({
314
279
  level: config.LOG_LEVEL,
315
280
  msg: "Harness agent server listening",
316
281
  host: config.HOST,
317
282
  port: config.PORT,
318
- runtime: config.AGENT_RUNTIME,
319
- model: resolveRuntimeModel(config, config.AGENT_RUNTIME),
283
+ runtime: config.DEFAULT_RUNTIME,
284
+ model: resolveRuntimeModel(config, config.DEFAULT_RUNTIME),
320
285
  }));
321
- const webSocketServers = [];
286
+ const webSocketServers = [
287
+ attachTerminalWs(server, {
288
+ runtimeConnectionResolver,
289
+ legacyLocalTerminalHost,
290
+ runtimeWebAccess,
291
+ }),
292
+ attachBrowserSurfaceWs(server, { runtimeConnectionResolver, runtimeWebAccess }),
293
+ attachEmulatorSurfaceWs(server, { runtimeConnectionResolver, runtimeWebAccess }),
294
+ ];
322
295
  if (desktopBrowserHost && desktopBrowserManagementToken) {
323
296
  webSocketServers.push(attachDesktopBrowserHostServer(server, {
324
297
  ...desktopBrowserHost,
325
298
  managementToken: desktopBrowserManagementToken,
326
299
  }));
327
300
  }
301
+ if (control?.emulator) {
302
+ webSocketServers.push(attachEmulatorTouchWs(server, { emulator: control.emulator }));
303
+ }
304
+ startupWebSocketServers = webSocketServers;
305
+ if (sessionPortalConfig) {
306
+ sessionPortalServer = await startSessionPortal({
307
+ host: config.RYNX_SESSION_PORTAL_HOST,
308
+ port: config.RYNX_SESSION_PORTAL_PORT,
309
+ scheme: "http",
310
+ authorization: sessionPortalConfig.authorization,
311
+ controlOrigin: listeningHttpOrigin(server),
312
+ controlWebDist: sessionPortalConfig.controlWebDist,
313
+ shutdownSignal: controlShutdown.signal,
314
+ });
315
+ }
316
+ const startup = pluginRuntime?.startPluginRuntimes() ?? Promise.resolve();
317
+ void startup.catch((error) => {
318
+ console.error(JSON.stringify({
319
+ level: "error",
320
+ type: "plugin-runtime",
321
+ event: "startup_failed",
322
+ error: error instanceof Error ? error.message : String(error),
323
+ }));
324
+ });
328
325
  resolve(bindServerLifecycle(server, {
329
- ...(webSocketServers.length > 0
330
- ? { closeUpgrades: () => closeWebSocketServers(webSocketServers) }
331
- : {}),
332
- cleanup: () => runnerManager.stop(),
326
+ fence: () => {
327
+ controlShutdown.abort(new Error("Rynx server is shutting down"));
328
+ sessionPortalServer?.stopAccepting();
329
+ },
330
+ closeUpgrades: () => Promise.all([
331
+ closeWebSocketServers(webSocketServers),
332
+ sessionPortalServer?.shutdown() ?? Promise.resolve(),
333
+ ]).then(() => undefined),
334
+ cleanup: async () => runCleanupSteps([
335
+ () => startup.catch(() => undefined),
336
+ () => Promise.resolve(daemonManagement?.pluginManagement?.dispose?.()),
337
+ () => pluginRuntime?.dispose() ?? Promise.resolve(),
338
+ () => runnerManager.stop(),
339
+ ]),
333
340
  }));
334
- });
341
+ };
335
342
  });
336
343
  }
344
+ function listeningHttpOrigin(server) {
345
+ const address = server.address();
346
+ if (!address || typeof address === "string") {
347
+ throw new Error("Control listener did not expose an HTTP address");
348
+ }
349
+ const host = address.address.includes(":") ? `[${address.address}]` : address.address;
350
+ return `http://${host}:${address.port}`;
351
+ }
337
352
  function requireDaemonManagementToken(host) {
338
353
  const token = host?.managementToken;
339
354
  const bytes = typeof token === "string" ? Buffer.byteLength(token, "utf8") : 0;
@@ -1,3 +1,4 @@
1
+ import type { AdmissionReservation } from "@rynx-ai/core";
1
2
  import { type RuntimeBrowserCloseParams, type RuntimeBrowserCloseResult, type RuntimeBrowserExecutionBackend, type RuntimeBrowserOpenParams, type RuntimeBrowserPageCreateParams, type RuntimeBrowserPageMutationParams, type RuntimeBrowserPageNavigateParams, type RuntimeBrowserState, type RuntimeBrowserStateGetParams } from "@rynx-ai/protocol/runtime-browser";
2
3
  import type { RuntimeBrowserSurfaceFormat, RuntimeBrowserSurfaceInputEvent } from "@rynx-ai/protocol/runtime-browser-surface";
3
4
  import { type RuntimeBrowserEndpointDescriptor } from "@rynx-ai/protocol/runtime-browser-bootstrap";
@@ -155,6 +156,11 @@ export interface SessionBrowserServiceOptions {
155
156
  pageIdFactory?: () => string;
156
157
  /** Injectable boot nonce; production randomization fences stale pre-restart mutations. */
157
158
  bootGenerationSeed?: number;
159
+ /**
160
+ * Synchronous daemon admission fence. A successful reservation is held until
161
+ * Browser open has either failed or registered the physical Host handle.
162
+ */
163
+ admissionReserve?: () => AdmissionReservation | undefined;
158
164
  }
159
165
  export type SessionBrowserHostErrorCode = "unavailable" | "page_not_found" | "capacity" | "outcome_unknown";
160
166
  /** Expected failure reported by a Browser Host implementation. */
@@ -181,6 +187,7 @@ export declare class SessionBrowserService {
181
187
  private readonly observers;
182
188
  private readonly pageIdFactory;
183
189
  private readonly bootGenerationSeed;
190
+ private readonly admissionReserve?;
184
191
  private shuttingDown;
185
192
  private closeAllPromise?;
186
193
  constructor(ports: SessionBrowserServicePorts, options?: SessionBrowserServiceOptions);
@@ -225,6 +232,7 @@ export declare class SessionBrowserService {
225
232
  closeAll(): Promise<void>;
226
233
  private closeEveryRecord;
227
234
  private createBrowser;
235
+ private reserveOpenAdmission;
228
236
  private closeUnavailableForRecreate;
229
237
  private pageMutation;
230
238
  private pageMutationParsed;
@@ -42,12 +42,14 @@ export class SessionBrowserService {
42
42
  observers = new Set();
43
43
  pageIdFactory;
44
44
  bootGenerationSeed;
45
+ admissionReserve;
45
46
  shuttingDown = false;
46
47
  closeAllPromise;
47
48
  constructor(ports, options = {}) {
48
49
  this.ports = ports;
49
50
  this.pageIdFactory = options.pageIdFactory ?? (() => `page_${randomUUID()}`);
50
51
  this.bootGenerationSeed = options.bootGenerationSeed ?? randomBootGenerationSeed();
52
+ this.admissionReserve = options.admissionReserve;
51
53
  if (!Number.isSafeInteger(this.bootGenerationSeed) ||
52
54
  this.bootGenerationSeed < 0 ||
53
55
  this.bootGenerationSeed >= Number.MAX_SAFE_INTEGER) {
@@ -224,29 +226,35 @@ export class SessionBrowserService {
224
226
  });
225
227
  }
226
228
  async open(input) {
227
- const params = parseInput(() => parseRuntimeBrowserOpenParams(input), "Browser open request is invalid");
228
- return this.exclusive(params.sessionId, async () => {
229
- this.ensureRunning();
230
- await this.ensureSessionExists(params.sessionId);
231
- const existing = this.records.get(params.sessionId);
232
- if (existing?.status === "opening") {
233
- return this.project(existing);
234
- }
235
- if (existing?.status === "ready") {
236
- try {
237
- await this.reconcileForRead(existing);
229
+ const admission = this.reserveOpenAdmission();
230
+ try {
231
+ const params = parseInput(() => parseRuntimeBrowserOpenParams(input), "Browser open request is invalid");
232
+ return await this.exclusive(params.sessionId, async () => {
233
+ this.ensureRunning();
234
+ await this.ensureSessionExists(params.sessionId);
235
+ const existing = this.records.get(params.sessionId);
236
+ if (existing?.status === "opening") {
238
237
  return this.project(existing);
239
238
  }
240
- catch {
241
- // An App Host may have gone away immediately before this open. The
242
- // failed reconciliation marks the old generation unavailable, so an
243
- // idempotent open can recreate it on the current preferred Host.
239
+ if (existing?.status === "ready") {
240
+ try {
241
+ await this.reconcileForRead(existing);
242
+ return this.project(existing);
243
+ }
244
+ catch {
245
+ // An App Host may have gone away immediately before this open. The
246
+ // failed reconciliation marks the old generation unavailable, so an
247
+ // idempotent open can recreate it on the current preferred Host.
248
+ }
244
249
  }
245
- }
246
- if (existing)
247
- await this.closeUnavailableForRecreate(existing);
248
- return this.createBrowser(params);
249
- });
250
+ if (existing)
251
+ await this.closeUnavailableForRecreate(existing);
252
+ return this.createBrowser(params);
253
+ });
254
+ }
255
+ finally {
256
+ admission?.release();
257
+ }
250
258
  }
251
259
  async close(input) {
252
260
  const params = parseInput(() => parseRuntimeBrowserCloseParams(input), "Browser close request is invalid");
@@ -438,6 +446,15 @@ export class SessionBrowserService {
438
446
  throw new SessionBrowserServiceError("host_failure", "Browser Host state could not be initialized", { cause: error });
439
447
  }
440
448
  }
449
+ reserveOpenAdmission() {
450
+ if (!this.admissionReserve)
451
+ return undefined;
452
+ const admission = this.admissionReserve();
453
+ if (!admission) {
454
+ throw new SessionBrowserServiceError("shutting_down", "Browser service is shutting down");
455
+ }
456
+ return admission;
457
+ }
441
458
  async closeUnavailableForRecreate(record) {
442
459
  record.status = "closing";
443
460
  try {
@@ -0,0 +1,42 @@
1
+ import { type IncomingMessage, type Server as HttpServer } from "node:http";
2
+ import type { Duplex } from "node:stream";
3
+ import Koa from "koa";
4
+ export declare const SESSION_PORTAL_HEADER = "x-rynx-session-portal";
5
+ export declare const SESSION_PORTAL_QUERY = "portal";
6
+ export interface SessionPortalAuthorization {
7
+ portalId: string;
8
+ pluginId: string;
9
+ scope: "plugin_sessions" | "single_session";
10
+ access: "read_only" | "read_write";
11
+ sessionId?: string;
12
+ expiresAt: string;
13
+ }
14
+ export interface SessionPortalAuthorizationPorts {
15
+ redeem(ticket: string): (SessionPortalAuthorization & {
16
+ secret: string;
17
+ }) | undefined;
18
+ authorize(portalId: string, secret: string): SessionPortalAuthorization | undefined;
19
+ ownsSession(pluginId: string, sessionId: string): boolean;
20
+ }
21
+ export interface SessionPortalOptions {
22
+ host: string;
23
+ port: number;
24
+ scheme: "http" | "https";
25
+ authorization: SessionPortalAuthorizationPorts;
26
+ controlOrigin: string;
27
+ controlWebDist: string;
28
+ shutdownSignal?: AbortSignal;
29
+ }
30
+ export interface SessionPortalServer {
31
+ server: HttpServer;
32
+ stopAccepting(): void;
33
+ shutdown(): Promise<void>;
34
+ }
35
+ /** Session Portal is a narrow adapter in front of the one Control service. It
36
+ * serves the native SPA, validates Portal grants, and forwards only authorized
37
+ * Session HTTP calls to Control; it owns no Session or Runtime handlers. */
38
+ export declare function createSessionPortalApp(options: SessionPortalOptions): Koa;
39
+ export declare function startSessionPortal(options: SessionPortalOptions): Promise<SessionPortalServer>;
40
+ export declare function authorizeSessionPortalWebSocket(request: IncomingMessage, ports: SessionPortalAuthorizationPorts, runtimeSelector: string, sessionId: string): SessionPortalAuthorization | undefined;
41
+ export declare function rejectSessionPortalUpgrade(socket: Duplex): void;
42
+ export declare function scheduleSessionPortalExpiry(expiresAt: string, onExpire: () => void): () => void;