@opengeni/api-router 4.1.0 → 4.1.2-canary.36079201993001

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.
@@ -3,6 +3,7 @@ import {
3
3
  environmentsEncryptionKeyBytes,
4
4
  resolveFirstPartyDelegationSecret,
5
5
  resolveStreamTokenSecret,
6
+ sandboxWarmRateMicrosPerSecond,
6
7
  } from "@opengeni/config";
7
8
  import {
8
9
  BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX,
@@ -20,6 +21,8 @@ import {
20
21
  BrowserDownloadSaveRequest,
21
22
  BrowserDownloadSaveResponse,
22
23
  BrowserDownloadListResponse,
24
+ BrowserDomReadRequest,
25
+ BrowserDomReadResponse,
23
26
  BrowserExternalAuthCommand,
24
27
  BrowserOpenTargetRequest,
25
28
  BrowserObservation,
@@ -31,6 +34,7 @@ import {
31
34
  BrowserSessionListResponse,
32
35
  BrowserSessionMutationResponse,
33
36
  BrowserTargetListResponse,
37
+ BrowserTargetState,
34
38
  CreateBrowserSessionRequest,
35
39
  ExternalAuthInteractiveRequest,
36
40
  ExternalAuthInteractiveResponse,
@@ -75,6 +79,7 @@ import {
75
79
  BrowserSessionNotFoundError,
76
80
  BrowserSessionOperationConflictError,
77
81
  BrowserSessionStateError,
82
+ SandboxPaidComputeAdmissionError,
78
83
  completeBrowserSessionEnd,
79
84
  completeExternalAuth,
80
85
  completeBrowserDownloadSave,
@@ -88,6 +93,8 @@ import {
88
93
  dispatchExternalAuth,
89
94
  dispatchProtectedAuthFill,
90
95
  failBrowserSessionOperation,
96
+ failPreparedBrowserSessionEnd,
97
+ failPreparedBrowserSessionSuspend,
91
98
  failBrowserSessionResume,
92
99
  failBrowserSessionResumePreparation,
93
100
  failBrowserSessionSuspension,
@@ -169,6 +176,8 @@ import {
169
176
  provisionBrowserControlClient,
170
177
  renewSandboxProviderExpiration,
171
178
  type BrowserControlPlacementSession,
179
+ type BrowserControlScreenshotOptions,
180
+ type BrowserControlFrame,
172
181
  type PlacementBrowserStateCaptureReceipt,
173
182
  type PlacementBrowserNetworkRoute,
174
183
  type PlacementBrowserTransport,
@@ -952,6 +961,71 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
952
961
  },
953
962
  );
954
963
 
964
+ app.get(
965
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/state",
966
+ async (context) => {
967
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
968
+ context,
969
+ "sessions:read",
970
+ );
971
+ const targetId = requireOpaqueParam(context, "targetId");
972
+ const result = await withActiveBrowserController(
973
+ context,
974
+ grant,
975
+ workspaceId,
976
+ browserSessionId,
977
+ "session.read",
978
+ "browser.read",
979
+ async ({ sessionClient }) => await sessionClient.targetState(targetId),
980
+ );
981
+ return context.json(BrowserTargetState.parse(result));
982
+ },
983
+ );
984
+
985
+ app.post(
986
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/dom-read",
987
+ async (context) => {
988
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
989
+ context,
990
+ "sessions:read",
991
+ );
992
+ const targetId = requireOpaqueParam(context, "targetId");
993
+ const request = await parseJsonBody(context, BrowserDomReadRequest);
994
+ const result = await withActiveBrowserController(
995
+ context,
996
+ grant,
997
+ workspaceId,
998
+ browserSessionId,
999
+ "session.read",
1000
+ "browser.read",
1001
+ async ({ sessionClient }) => await sessionClient.readDom(targetId, request),
1002
+ );
1003
+ return context.json(BrowserDomReadResponse.parse(result));
1004
+ },
1005
+ );
1006
+
1007
+ app.get(
1008
+ "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/targets/:targetId/screenshot",
1009
+ async (context) => {
1010
+ const { workspaceId, grant, browserSessionId } = await browserRoutePreamble(
1011
+ context,
1012
+ "sessions:read",
1013
+ );
1014
+ const targetId = requireOpaqueParam(context, "targetId");
1015
+ const captureOptions = parseBrowserScreenshotOptions(new URL(context.req.url).searchParams);
1016
+ const frame = await withActiveBrowserController(
1017
+ context,
1018
+ grant,
1019
+ workspaceId,
1020
+ browserSessionId,
1021
+ "session.read",
1022
+ "browser.read",
1023
+ async ({ sessionClient }) => await sessionClient.capture(targetId, captureOptions),
1024
+ );
1025
+ return browserScreenshotResponse(frame);
1026
+ },
1027
+ );
1028
+
955
1029
  app.post(
956
1030
  "/v1/workspaces/:workspaceId/browser-sessions/:browserSessionId/actions",
957
1031
  async (context) => {
@@ -2047,6 +2121,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2047
2121
  const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
2048
2122
  const startedAtMs = performance.now();
2049
2123
  const origin = requestOrigin(context, deps.settings);
2124
+ let preparedForDispatch = false;
2050
2125
  try {
2051
2126
  const before = await getBrowserSessionControlRecord(deps.db, {
2052
2127
  accountId: grant.accountId,
@@ -2098,6 +2173,8 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2098
2173
  return context.json(parsed, 200);
2099
2174
  }
2100
2175
 
2176
+ preparedForDispatch = true;
2177
+
2101
2178
  const record = await getBrowserSessionControlRecord(deps.db, {
2102
2179
  accountId: grant.accountId,
2103
2180
  workspaceId,
@@ -2259,6 +2336,15 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2259
2336
  observeLifecycleResult(deps.observability, startedAtMs, parsed);
2260
2337
  return context.json(parsed, 200);
2261
2338
  } catch (error) {
2339
+ if (preparedForDispatch) {
2340
+ await failPreparedBrowserSessionSuspend(deps.db, {
2341
+ accountId: grant.accountId,
2342
+ workspaceId,
2343
+ browserSessionId,
2344
+ operationId: request.operationId,
2345
+ error: interactionFailure(error),
2346
+ });
2347
+ }
2262
2348
  throw browserRouteError(error);
2263
2349
  }
2264
2350
  },
@@ -2522,6 +2608,11 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2522
2608
  const request = await parseJsonBody(context, BrowserSessionLifecycleRequest);
2523
2609
  const startedAtMs = performance.now();
2524
2610
  const origin = requestOrigin(context, deps.settings);
2611
+ let endPreparation: {
2612
+ restoreLifecycle: BrowserSessionValue["lifecycle"];
2613
+ restoreFailureCode: string | null;
2614
+ expectedControllerGeneration: string | null;
2615
+ } | null = null;
2525
2616
  try {
2526
2617
  const before = await getBrowserSessionControlRecord(deps.db, {
2527
2618
  accountId: grant.accountId,
@@ -2536,6 +2627,9 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2536
2627
  browserSessionId,
2537
2628
  operationId: request.operationId,
2538
2629
  actorSubjectId: grant.subjectId,
2630
+ expectedLifecycle: before.session.lifecycle,
2631
+ expectedFailureCode: before.session.failureCode,
2632
+ expectedControllerGeneration: before.session.controller?.controllerGeneration ?? null,
2539
2633
  });
2540
2634
  if (isTerminalOperation(prepared.operation.state)) {
2541
2635
  if (prepared.operation.state === "completed") {
@@ -2551,6 +2645,13 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2551
2645
  observeLifecycleResult(deps.observability, startedAtMs, parsed);
2552
2646
  return context.json(parsed, 200);
2553
2647
  }
2648
+ if (before.session.lifecycle !== "ending") {
2649
+ endPreparation = {
2650
+ restoreLifecycle: before.session.lifecycle,
2651
+ restoreFailureCode: before.session.failureCode,
2652
+ expectedControllerGeneration: before.session.controller?.controllerGeneration ?? null,
2653
+ };
2654
+ }
2554
2655
 
2555
2656
  const record = await getBrowserSessionControlRecord(deps.db, {
2556
2657
  accountId: grant.accountId,
@@ -2644,6 +2745,16 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2644
2745
  observeLifecycleResult(deps.observability, startedAtMs, parsed);
2645
2746
  return context.json(parsed, 200);
2646
2747
  } catch (error) {
2748
+ if (endPreparation) {
2749
+ await failPreparedBrowserSessionEnd(deps.db, {
2750
+ accountId: grant.accountId,
2751
+ workspaceId,
2752
+ browserSessionId,
2753
+ operationId: request.operationId,
2754
+ ...endPreparation,
2755
+ error: interactionFailure(error),
2756
+ });
2757
+ }
2647
2758
  throw browserRouteError(error);
2648
2759
  }
2649
2760
  },
@@ -2792,6 +2903,12 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2792
2903
  resolved.kind !== "selfhosted" ||
2793
2904
  resolved.sandboxId !== expectedPlacement.sandboxId
2794
2905
  ) {
2906
+ if (operation === "browser.create") {
2907
+ throw new HTTPException(422, {
2908
+ message:
2909
+ "The requested Connected Machine is not this session's current placement. Move the session to that machine before creating an interaction resource.",
2910
+ });
2911
+ }
2795
2912
  return await throwBrowserSourcePlacementChanged(
2796
2913
  grant,
2797
2914
  sourceSession.id,
@@ -3748,6 +3865,10 @@ async function ensureInteractionHolder(
3748
3865
  holderId: interactionHolderId(browserSessionId),
3749
3866
  subjectId: sourceSession.id,
3750
3867
  backend: placement.lease.backend,
3868
+ warmBilling: {
3869
+ mode: deps.settings.sandboxWarmBillingMode,
3870
+ rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(deps.settings, placement.lease.backend),
3871
+ },
3751
3872
  os: placement.lease.os,
3752
3873
  image: sandboxRuntime.image,
3753
3874
  rigVersionId: sourceSession.rigVersionId,
@@ -4023,6 +4144,7 @@ async function settleBrowserSessionSuspensionCaptureFailure(
4023
4144
  return await failBrowserSessionSuspension(deps.db, {
4024
4145
  ...input,
4025
4146
  ...(outcomeUnknown ? { state: "outcome_unknown" as const } : {}),
4147
+ missingControllerSession: error.status === 404 && isMissingBrowserControllerSession(error),
4026
4148
  error: error.error,
4027
4149
  });
4028
4150
  }
@@ -4160,6 +4282,49 @@ function optionalBoundedInteger(
4160
4282
  return parsed;
4161
4283
  }
4162
4284
 
4285
+ export function parseBrowserScreenshotOptions(
4286
+ query: URLSearchParams,
4287
+ ): BrowserControlScreenshotOptions {
4288
+ for (const key of ["fullPage", "format", "quality"]) {
4289
+ if (query.getAll(key).length > 1) {
4290
+ throw new HTTPException(400, { message: `duplicate browser screenshot ${key}` });
4291
+ }
4292
+ }
4293
+ const fullPage = query.get("fullPage");
4294
+ if (fullPage !== null && fullPage !== "true" && fullPage !== "false") {
4295
+ throw new HTTPException(400, { message: "invalid browser screenshot fullPage" });
4296
+ }
4297
+ const format = query.get("format");
4298
+ if (format !== null && format !== "jpeg" && format !== "png") {
4299
+ throw new HTTPException(400, { message: "invalid browser screenshot format" });
4300
+ }
4301
+ const quality = query.get("quality");
4302
+ const parsedQuality = quality === null ? null : optionalBoundedInteger(quality, 1, 100);
4303
+ if (quality !== null && parsedQuality === null) {
4304
+ throw new HTTPException(400, { message: "invalid browser screenshot quality" });
4305
+ }
4306
+ return {
4307
+ ...(fullPage === null ? {} : { fullPage: fullPage === "true" }),
4308
+ ...(format === null ? {} : { format }),
4309
+ ...(parsedQuality === null ? {} : { quality: parsedQuality }),
4310
+ };
4311
+ }
4312
+
4313
+ export function browserScreenshotResponse(
4314
+ frame: Pick<BrowserControlFrame, "data" | "mediaType" | "metadataHeader">,
4315
+ ): Response {
4316
+ // Node Buffers can be views into a larger backing allocation. Copy exactly
4317
+ // the image bytes before constructing the Response.
4318
+ return new Response(Uint8Array.from(frame.data).buffer, {
4319
+ status: 200,
4320
+ headers: {
4321
+ "cache-control": "no-store",
4322
+ "content-type": frame.mediaType,
4323
+ "x-opengeni-browser-frame": frame.metadataHeader,
4324
+ },
4325
+ });
4326
+ }
4327
+
4163
4328
  function isUuid(value: unknown): value is string {
4164
4329
  return (
4165
4330
  typeof value === "string" &&
@@ -4716,6 +4881,8 @@ function browserRouteError(error: unknown): HTTPException {
4716
4881
  const connectedMachineError = interactionControlApiError(error, "browser");
4717
4882
  if (connectedMachineError) return connectedMachineError;
4718
4883
  if (error instanceof HTTPException) return error;
4884
+ if (error instanceof SandboxPaidComputeAdmissionError)
4885
+ return new HTTPException(402, { message: error.message, cause: error });
4719
4886
  if (error instanceof BrowserSessionNotFoundError) {
4720
4887
  return new HTTPException(404, { message: error.message, cause: error });
4721
4888
  }
@@ -1,5 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { resolveFirstPartyDelegationSecret, resolveStreamTokenSecret } from "@opengeni/config";
2
+ import {
3
+ resolveFirstPartyDelegationSecret,
4
+ resolveStreamTokenSecret,
5
+ sandboxWarmRateMicrosPerSecond,
6
+ } from "@opengeni/config";
3
7
  import {
4
8
  BROWSER_CONTROL_WEBSOCKET_BEARER_PREFIX,
5
9
  BROWSER_CONTROL_PORT,
@@ -32,6 +36,7 @@ import {
32
36
  ComputerSessionNotFoundError,
33
37
  ComputerSessionOperationConflictError,
34
38
  ComputerSessionStateError,
39
+ SandboxPaidComputeAdmissionError,
35
40
  dispatchComputerSessionOperation,
36
41
  failComputerSessionOperation,
37
42
  findComputerSessionControlRecordByOperation,
@@ -1056,6 +1061,12 @@ export function registerComputerSessionRoutes(app: Hono, deps: ApiRouteDeps): vo
1056
1061
  resolved.kind !== "selfhosted" ||
1057
1062
  resolved.sandboxId !== expectedPlacement.sandboxId
1058
1063
  ) {
1064
+ if (operation === "computer.create") {
1065
+ throw new HTTPException(422, {
1066
+ message:
1067
+ "The requested Connected Machine is not this session's current placement. Move the session to that machine before creating an interaction resource.",
1068
+ });
1069
+ }
1059
1070
  return await throwComputerSourcePlacementChanged(
1060
1071
  grant,
1061
1072
  sourceSession.id,
@@ -1420,6 +1431,10 @@ export function registerComputerSessionRoutes(app: Hono, deps: ApiRouteDeps): vo
1420
1431
  holderId: interactionHolderId(computerSessionId),
1421
1432
  subjectId: sourceSession.id,
1422
1433
  backend: placement.lease.backend,
1434
+ warmBilling: {
1435
+ mode: deps.settings.sandboxWarmBillingMode,
1436
+ rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(deps.settings, placement.lease.backend),
1437
+ },
1423
1438
  os: placement.lease.os,
1424
1439
  image: sandboxRuntime.image,
1425
1440
  rigVersionId: sourceSession.rigVersionId,
@@ -1875,6 +1890,8 @@ function computerRouteError(error: unknown): HTTPException {
1875
1890
  const connectedMachineError = interactionControlApiError(error, "computer");
1876
1891
  if (connectedMachineError) return connectedMachineError;
1877
1892
  if (error instanceof HTTPException) return error;
1893
+ if (error instanceof SandboxPaidComputeAdmissionError)
1894
+ return new HTTPException(402, { message: error.message, cause: error });
1878
1895
  if (error instanceof ComputerSessionNotFoundError) {
1879
1896
  return new HTTPException(404, { message: error.message, cause: error });
1880
1897
  }
@@ -818,6 +818,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
818
818
  await requireAccessGrantAuthorization(c, deps, workspaceId, "documents:search"),
819
819
  "documents:search",
820
820
  ),
821
+ settings: deps.settings,
821
822
  },
822
823
  );
823
824
  await server.connect(transport);
@@ -15,6 +15,7 @@ import {
15
15
  retainedGeneratedImageReferenceFromFile,
16
16
  retainedGeneratedVideoReferenceFromFile,
17
17
  retainedScreenshotReferenceFromFile,
18
+ retainedSessionScreenshotKindFromObjectKey,
18
19
  VideoArtifactPlaybackSource,
19
20
  resolveRetainedOutputRange,
20
21
  type RetainedArtifactMetadata,
@@ -887,12 +888,15 @@ function retainedScreenshotMetadata(
887
888
  artifact.sessionId &&
888
889
  artifact.retentionExpiresAt.getTime() > Date.now()
889
890
  ) {
891
+ const kind = retainedSessionScreenshotKindFromObjectKey(artifact.file.objectKey);
892
+ if (!kind) return retainedArtifactUnavailable(artifact.artifactId, "invalid_content");
890
893
  const reference = retainedScreenshotReferenceFromFile({
891
894
  ...artifact.file,
892
895
  sessionId: artifact.sessionId,
893
896
  width: artifact.width,
894
897
  height: artifact.height,
895
898
  expiresAt: artifact.retentionExpiresAt.toISOString(),
899
+ kind,
896
900
  });
897
901
  if (reference) return reference;
898
902
  }
@@ -0,0 +1,102 @@
1
+ import type { GitHubInstallationBindingCandidate } from "@opengeni/contracts";
2
+
3
+ // GitHub redirects can land on an API-only host. Keep these pages self-contained
4
+ // while using the same dark palette and typography as @opengeni/react.
5
+ const pageStyle = `
6
+ :root{color-scheme:dark;--bg:oklch(.155 .012 260);--surface:oklch(.19 .014 260);--raised:oklch(.225 .015 260);--border:oklch(.3 .014 260);--border-strong:oklch(.42 .016 260);--fg:oklch(.955 .005 260);--muted:oklch(.73 .012 260);--blue:oklch(.54 .18 255);--blue-light:oklch(.72 .15 255)}
7
+ *{box-sizing:border-box}
8
+ html{min-height:100%;background:var(--bg)}
9
+ body{margin:0;min-height:100vh;min-height:100dvh;color:var(--fg);background:var(--bg);font-family:"Inter Variable",Inter,ui-sans-serif,system-ui,sans-serif;font-feature-settings:"cv11","ss01","ss03";-webkit-font-smoothing:antialiased}
10
+ .shell{width:min(100% - 48px,700px);margin:0 auto}
11
+ header{height:84px;display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--border)}
12
+ .brand{font-size:16px;font-weight:670;letter-spacing:-.045em;color:var(--fg);text-decoration:none}
13
+ .context{font-size:12px;color:var(--muted)}
14
+ main{padding:clamp(48px,10vh,100px) 0 96px}
15
+ h1{margin:0 0 14px;font-size:clamp(27px,4vw,36px);font-weight:640;letter-spacing:-.05em;line-height:1.17}
16
+ p{margin:0;color:var(--muted);font-size:14px;line-height:1.65;max-width:59ch}
17
+ .intro{margin-bottom:32px}
18
+ .options{display:grid;gap:10px;margin:0 0 24px;padding:0;border:0;max-height:min(390px,52vh);overflow:auto}
19
+ .option{display:flex;gap:14px;align-items:center;min-height:70px;padding:14px 16px;border:1px solid var(--border);border-radius:10px;background:var(--surface);cursor:pointer;transition:background .15s,border-color .15s}
20
+ .option:hover{background:var(--raised)}
21
+ .option:has(input:checked){border-color:var(--blue-light);background:var(--raised)}
22
+ .option:has(input:focus-visible){outline:2px solid var(--blue-light);outline-offset:3px}
23
+ .option input{margin:0;flex:none;width:17px;height:17px;accent-color:var(--blue-light)}
24
+ .option-text{min-width:0;display:grid;gap:3px}
25
+ .option strong{overflow-wrap:anywhere;font-size:14px;font-weight:600}
26
+ .option small{font-size:12px;color:var(--muted)}
27
+ .actions{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin-top:24px}
28
+ .button{display:inline-flex;min-height:42px;align-items:center;justify-content:center;padding:0 17px;border:1px solid var(--blue);border-radius:8px;background:var(--blue);color:#fff;font-family:inherit;font-size:13px;font-weight:600;text-decoration:none;cursor:pointer}
29
+ .button:hover{background:oklch(.6 .18 255)}
30
+ .button:disabled{cursor:wait;opacity:.7}
31
+ .secondary{border-color:var(--border);background:transparent;color:var(--fg)}
32
+ .secondary:hover{background:var(--raised)}
33
+ :is(button,a):focus-visible{outline:2px solid var(--blue-light);outline-offset:3px}
34
+ .note{margin-top:30px;padding-top:20px;border-top:1px solid var(--border);font-size:12px}
35
+ .env-header{display:flex;justify-content:space-between;align-items:center;gap:14px;margin-top:36px}
36
+ h2{margin:0;font-size:14px;font-weight:600}
37
+ pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:380px;overflow:auto;padding:18px;border:1px solid var(--border);border-radius:10px;background:var(--surface);font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}
38
+ @media(max-width:480px){.shell{width:calc(100% - 32px)}header{height:70px}main{padding-top:56px}.actions{align-items:stretch;flex-direction:column}.actions form,.actions .button{width:100%}}
39
+ @media(prefers-reduced-motion:reduce){.option{transition:none}}
40
+ `;
41
+
42
+ function page(title: string, content: string): string {
43
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} · OpenGeni</title><style>${pageStyle}</style></head><body><div class="shell"><header><span class="brand">OpenGeni</span><span class="context">GitHub connection</span></header><main>${content}</main></div></body></html>`;
44
+ }
45
+
46
+ export function githubInstallationChooserHtml(
47
+ candidates: GitHubInstallationBindingCandidate[],
48
+ state: string,
49
+ workspaceId: string,
50
+ baseUrl: string,
51
+ ): string {
52
+ const action = `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/github/installations/select`;
53
+ const options = candidates
54
+ .map(({ installation, authorityKind }, index) => {
55
+ const account = escapeHtml(
56
+ installation.accountLogin ?? `installation ${installation.installationId}`,
57
+ );
58
+ const label = authorityKind === "personal_owner" ? "Personal account" : "Organization owner";
59
+ return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required${candidates.length === 1 && index === 0 ? " checked" : ""}><span class="option-text"><strong>${account}</strong><small>${label}</small></span></label>`;
60
+ })
61
+ .join("");
62
+ return page(
63
+ "Choose a GitHub account",
64
+ `<h1>Choose a GitHub account</h1><p class="intro">Connect an account where the OpenGeni GitHub App is already installed, or install it on another account.</p><form id="existing-account" method="get" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><fieldset class="options" aria-label="Available GitHub accounts">${options}</fieldset></form><div class="actions"><button class="button" type="submit" form="existing-account">Connect selected account</button><form method="get" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><input type="hidden" name="installation_id" value="new"><button class="button secondary" type="submit">Install on another account</button></form></div><p class="note">Only accounts GitHub has confirmed you own or manage appear here.</p>`,
65
+ );
66
+ }
67
+
68
+ export function githubSetupSuccessHtml(account: string, returnUrl: string): string {
69
+ return page(
70
+ "GitHub connected",
71
+ `<h1>GitHub connected</h1><p class="intro"><strong>${escapeHtml(account)}</strong> is now available in this OpenGeni workspace. OpenGeni can access only the repositories allowed for this installation.</p><a class="button" href="${escapeHtml(returnUrl)}">Back to OpenGeni</a>`,
72
+ );
73
+ }
74
+
75
+ export function githubSetupPendingHtml(): string {
76
+ return page(
77
+ "GitHub approval pending",
78
+ `<h1>Waiting for an organization owner</h1><p>An owner needs to approve your GitHub App request before you can connect. OpenGeni has not created a workspace binding.</p>`,
79
+ );
80
+ }
81
+
82
+ export function githubSuccessHtml(envLines: string[]): string {
83
+ const escaped = escapeHtml(envLines.join("\n"));
84
+ return page(
85
+ "GitHub App created",
86
+ `<h1>GitHub App created</h1><p>Add these values to .env, then restart API and worker.</p><div class="env-header"><h2>Environment variables</h2><button class="button secondary" id="copy-env" type="button">Copy env</button></div><pre id="env-lines">${escaped}</pre><script>(()=>{const button=document.getElementById("copy-env");const env=document.getElementById("env-lines");async function copyText(text){if(navigator.clipboard&&window.isSecureContext){await navigator.clipboard.writeText(text);return;}const area=document.createElement("textarea");area.value=text;area.setAttribute("readonly","");area.style.position="fixed";area.style.inset="-9999px";document.body.append(area);area.select();document.execCommand("copy");area.remove();}button?.addEventListener("click",async()=>{try{await copyText(env?.textContent||"");button.textContent="Copied";setTimeout(()=>button.textContent="Copy env",1600);}catch{button.textContent="Copy failed";setTimeout(()=>button.textContent="Copy env",2200);}});})();</script>`,
87
+ );
88
+ }
89
+
90
+ function escapeHtml(value: string): string {
91
+ return value.replace(
92
+ /[&<>"']/g,
93
+ (char) =>
94
+ ({
95
+ "&": "&amp;",
96
+ "<": "&lt;",
97
+ ">": "&gt;",
98
+ '"': "&quot;",
99
+ "'": "&#39;",
100
+ })[char] ?? char,
101
+ );
102
+ }
@@ -81,6 +81,12 @@ import {
81
81
  isConsistentGitHubBindingCandidates,
82
82
  isConsistentGitHubBindingProof,
83
83
  } from "../integrations/github-installation-proof";
84
+ import {
85
+ githubInstallationChooserHtml,
86
+ githubSetupPendingHtml,
87
+ githubSetupSuccessHtml,
88
+ githubSuccessHtml,
89
+ } from "./github-browser-pages";
84
90
  import {
85
91
  completeGitHubAppConnect,
86
92
  isGitHubAppConnectState,
@@ -1051,39 +1057,6 @@ function isSecureRequest(c: Context, deps: ApiRouteDeps): boolean {
1051
1057
  );
1052
1058
  }
1053
1059
 
1054
- function githubSuccessHtml(envLines: string[]): string {
1055
- const envText = envLines.join("\n");
1056
- const escaped = escapeHtml(envText);
1057
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Created</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(760px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.env-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:22px 0 8px}.env-header h2{margin:0;font-size:13px;line-height:1.2;text-transform:uppercase;letter-spacing:.08em;color:#a1a1aa}pre{white-space:pre-wrap;word-break:break-word;max-height:380px;overflow:auto;background:#09090b;border:1px solid #27272a;border-radius:8px;padding:16px;font-size:13px;line-height:1.5}button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}button:disabled{cursor:not-allowed;opacity:.7}</style></head><body><main><h1>GitHub App created</h1><p>Add these values to .env, then restart API and worker.</p><div class="env-header"><h2>Environment variables</h2><button id="copy-env" type="button">Copy env</button></div><pre id="env-lines">${escaped}</pre><script>(()=>{const button=document.getElementById("copy-env");const env=document.getElementById("env-lines");async function copyText(text){if(navigator.clipboard&&window.isSecureContext){await navigator.clipboard.writeText(text);return;}const area=document.createElement("textarea");area.value=text;area.setAttribute("readonly","");area.style.position="fixed";area.style.inset="-9999px";document.body.append(area);area.select();document.execCommand("copy");area.remove();}button?.addEventListener("click",async()=>{try{await copyText(env?.textContent||"");button.textContent="Copied";setTimeout(()=>button.textContent="Copy env",1600);}catch{button.textContent="Copy failed";setTimeout(()=>button.textContent="Copy env",2200);}});})();</script></main></body></html>`;
1058
- }
1059
-
1060
- function githubSetupSuccessHtml(account: string, returnUrl: string): string {
1061
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Connected</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;text-decoration:none}</style></head><body><main><h1>GitHub App connected</h1><p>${escapeHtml(account)} is now available to this OpenGeni workspace through an explicit repository allowlist.</p><a class="button" href="${escapeHtml(returnUrl)}">Back to OpenGeni</a></main></body></html>`;
1062
- }
1063
-
1064
- function githubInstallationChooserHtml(
1065
- candidates: GitHubInstallationBindingCandidate[],
1066
- state: string,
1067
- workspaceId: string,
1068
- baseUrl: string,
1069
- ): string {
1070
- const action = `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/github/installations/select`;
1071
- const options = candidates
1072
- .map(({ installation, authorityKind }, index) => {
1073
- const account = escapeHtml(
1074
- installation.accountLogin ?? `installation ${installation.installationId}`,
1075
- );
1076
- const label = authorityKind === "personal_owner" ? "Personal account" : "Organization owner";
1077
- return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required${candidates.length === 1 && index === 0 ? " checked" : ""}><span><strong>${account}</strong><small>${label}</small></span></label>`;
1078
- })
1079
- .join("");
1080
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Choose GitHub installation</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:12px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px}p{margin:0 0 18px;color:#d4d4d8}.options{display:grid;gap:8px;margin-bottom:18px}.option{display:flex;align-items:center;gap:12px;border:1px solid #3f3f46;border-radius:8px;padding:12px;cursor:pointer}.option span{display:grid;gap:2px}.option small{color:#a1a1aa}button{min-height:38px;border-radius:7px;border:1px solid #3f3f46;padding:0 14px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}.secondary{margin-left:8px;background:transparent;color:#f4f4f5}</style></head><body><main><h1>Choose a GitHub account</h1><p>These accounts already have the OpenGeni GitHub App installed and GitHub proved you are the personal owner or an active organization owner.</p><form method="get" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><div class="options">${options}</div><button type="submit">Connect selected</button><button class="secondary" type="submit" name="installation_id" value="new" formnovalidate>Install on another account</button></form></main></body></html>`;
1081
- }
1082
-
1083
- function githubSetupPendingHtml(): string {
1084
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Requested</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0;color:#d4d4d8}</style></head><body><main><h1>GitHub App request sent</h1><p>A GitHub organization owner must approve the installation. OpenGeni has not created a workspace binding.</p></main></body></html>`;
1085
- }
1086
-
1087
1060
  function parsePositiveInteger(value: string | undefined | null): number | null {
1088
1061
  if (!value || !/^\d+$/.test(value)) {
1089
1062
  return null;
@@ -1097,20 +1070,6 @@ function isFreshGitHubBindingState(payload: GitHubSignedStatePayload): boolean {
1097
1070
  return age >= 0 && age < githubBindingStateMaxAgeSeconds;
1098
1071
  }
1099
1072
 
1100
- function escapeHtml(value: string): string {
1101
- return value.replace(
1102
- /[&<>"']/g,
1103
- (char) =>
1104
- ({
1105
- "&": "&amp;",
1106
- "<": "&lt;",
1107
- ">": "&gt;",
1108
- '"': "&quot;",
1109
- "'": "&#39;",
1110
- })[char] ?? char,
1111
- );
1112
- }
1113
-
1114
1073
  function openGeniReturnUrl(
1115
1074
  settings: ApiRouteDeps["settings"],
1116
1075
  c: Context,
@@ -9,6 +9,7 @@ import {
9
9
  type ApiRouteDeps,
10
10
  type WorkspaceInsightsFilterField,
11
11
  } from "@opengeni/core";
12
+ import { currentSessionRlsActorIdentityKey } from "@opengeni/db";
12
13
  import { workspaceInsightsMetricObserver } from "@opengeni/observability";
13
14
  import type { Hono } from "hono";
14
15
  import { HTTPException } from "hono/http-exception";
@@ -27,9 +28,62 @@ export function normalizeWorkspaceInsightsQueryFilter(
27
28
  }
28
29
  }
29
30
 
31
+ /**
32
+ * Share one in-flight computation between identical concurrent callers.
33
+ *
34
+ * A reload, a second tab, or a retried fetch joins the rollup already running
35
+ * instead of starting another multi-second aggregate on the database.
36
+ * Authorization and filter validation still run per request before a caller
37
+ * may join; the map only ever holds running work. The caller supplies the
38
+ * complete sharing key, which must identify everything the result depends on.
39
+ */
40
+ export function createInFlightCoalescer<T>(): {
41
+ run: (key: string, work: () => Promise<T>) => Promise<T>;
42
+ readonly size: number;
43
+ } {
44
+ const inFlight = new Map<string, Promise<T>>();
45
+ return {
46
+ run(key, work) {
47
+ const pending = inFlight.get(key);
48
+ if (pending) return pending;
49
+ const started: Promise<T> = work().finally(() => {
50
+ if (inFlight.get(key) === started) inFlight.delete(key);
51
+ });
52
+ inFlight.set(key, started);
53
+ return started;
54
+ },
55
+ get size() {
56
+ return inFlight.size;
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Insights rows are filtered by the database RLS actor (private sessions are
63
+ * visible only to their owner), so two administrators of one workspace can
64
+ * legitimately receive different responses. The actor identity is part of
65
+ * the key; requests share work only when every visibility input is equal.
66
+ */
67
+ export function workspaceInsightsCoalesceKey(input: {
68
+ workspaceId: string;
69
+ range: string;
70
+ provider: string | null;
71
+ model: string | null;
72
+ rlsActor: string | null;
73
+ }): string {
74
+ return JSON.stringify([
75
+ input.workspaceId,
76
+ input.range,
77
+ input.provider,
78
+ input.model,
79
+ input.rlsActor,
80
+ ]);
81
+ }
82
+
30
83
  export function registerInsightsRoutes(app: Hono, deps: ApiRouteDeps): void {
31
84
  const observeRequest = workspaceInsightsMetricObserver(deps.observability);
32
85
  const observePhase = workspaceInsightsPhaseMetricObserver(deps.observability);
86
+ const coalesce = createInFlightCoalescer<Awaited<ReturnType<typeof getWorkspaceInsights>>>();
33
87
  app.get("/v1/workspaces/:workspaceId/insights", async (c) => {
34
88
  const startedAtMs = performance.now();
35
89
  const rangeRaw = c.req.query("range") ?? "week";
@@ -54,16 +108,26 @@ export function registerInsightsRoutes(app: Hono, deps: ApiRouteDeps): void {
54
108
  provider = normalizeWorkspaceInsightsQueryFilter(providerRaw, "provider");
55
109
  model = normalizeWorkspaceInsightsQueryFilter(modelRaw, "model");
56
110
 
57
- const response = await getWorkspaceInsights(
58
- deps.db,
59
- deps.settings,
60
- {
111
+ const response = await coalesce.run(
112
+ workspaceInsightsCoalesceKey({
61
113
  workspaceId,
62
114
  range: rangeParsed.data,
63
115
  provider,
64
116
  model,
65
- },
66
- observePhase,
117
+ rlsActor: currentSessionRlsActorIdentityKey(),
118
+ }),
119
+ () =>
120
+ getWorkspaceInsights(
121
+ deps.db,
122
+ deps.settings,
123
+ {
124
+ workspaceId,
125
+ range: rangeParsed.data,
126
+ provider,
127
+ model,
128
+ },
129
+ observePhase,
130
+ ),
67
131
  );
68
132
  c.header("cache-control", "private, no-store");
69
133
  const result = c.json(WorkspaceInsightsResponse.parse(response));