@opengeni/api-router 4.1.0 → 4.1.2

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.
@@ -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));
@@ -18,6 +18,8 @@ import {
18
18
  prepareKnowledgeFile,
19
19
  prepareKnowledgeSave,
20
20
  searchKnowledgeEntries,
21
+ KnowledgeVectorFundingError,
22
+ KnowledgeVectorQueryRejectedError,
21
23
  hasPermission,
22
24
  knowledgeContextForAccess,
23
25
  requireAccessGrantAuthorization,
@@ -47,6 +49,7 @@ import type { Context, Hono } from "hono";
47
49
  import { HTTPException } from "hono/http-exception";
48
50
  import { z } from "zod";
49
51
  import { withAccessGrantSessionRlsContext } from "../access-grant-rls";
52
+ import { ApiHttpError } from "../http/api-error";
50
53
 
51
54
  const ReadSettings = z
52
55
  .object({
@@ -63,6 +66,18 @@ const WriteSettings = ReadSettings.extend({
63
66
  const Id = z.uuid();
64
67
  function knowledgeHttpError(error: unknown): never {
65
68
  if (error instanceof HTTPException) throw error;
69
+ if (error instanceof KnowledgeVectorFundingError)
70
+ throw new ApiHttpError(402, {
71
+ code: "payment_required",
72
+ message: error.message,
73
+ details: { code: error.code, keywordAvailable: true },
74
+ });
75
+ if (error instanceof KnowledgeVectorQueryRejectedError)
76
+ throw new ApiHttpError(error.code === "quota" ? 429 : 422, {
77
+ code: error.code === "quota" ? "limit_exceeded" : "validation_failed",
78
+ message: error.message,
79
+ details: { code: error.code, keywordAvailable: true },
80
+ });
66
81
  if (error instanceof z.ZodError)
67
82
  throw new HTTPException(422, {
68
83
  message: error.issues[0]?.message ?? "Invalid Knowledge request",
@@ -216,6 +231,8 @@ export function registerKnowledgeRoutes(app: Hono, deps: ApiRouteDeps) {
216
231
  context,
217
232
  KnowledgeSavePreparationRequest.parse(await c.req.json()),
218
233
  () => deps.getDocumentServices().embedder,
234
+ undefined,
235
+ deps.settings,
219
236
  ),
220
237
  );
221
238
  }),
@@ -228,6 +245,7 @@ export function registerKnowledgeRoutes(app: Hono, deps: ApiRouteDeps) {
228
245
  context,
229
246
  KnowledgeEntryListRequest.parse(await c.req.json()),
230
247
  () => deps.getDocumentServices().embedder,
248
+ deps.settings,
231
249
  ),
232
250
  ),
233
251
  ),
@@ -270,6 +288,7 @@ export function registerKnowledgeRoutes(app: Hono, deps: ApiRouteDeps) {
270
288
  ...(query.limit ? { limit: Number(query.limit) } : {}),
271
289
  }),
272
290
  () => deps.getDocumentServices().embedder,
291
+ deps.settings,
273
292
  ),
274
293
  );
275
294
  }),
@@ -77,6 +77,9 @@ export function registerManagedOnboardingRoutes(
77
77
  organizationName,
78
78
  operationId: parsed.data.operationId,
79
79
  requestFingerprint,
80
+ trialCreditsEnabled:
81
+ deps.settings.productAccessMode === "managed" &&
82
+ deps.settings.verifiedSignupTrialCreditsEnabled,
80
83
  }),
81
84
  ),
82
85
  );
@@ -237,6 +237,9 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
237
237
  subjectId,
238
238
  subjectLabel: session.user.email || session.user.name,
239
239
  ...payload,
240
+ trialCreditsEnabled:
241
+ deps.settings.productAccessMode === "managed" &&
242
+ deps.settings.verifiedSignupTrialCreditsEnabled,
240
243
  }),
241
244
  ),
242
245
  201,
@@ -1,5 +1,8 @@
1
1
  import { getRetainedProviderCommand } from "@opengeni/db/retained-provider-commands";
2
2
  import { searchSessionMessagesForSubject, SessionMessageSearchCursorError } from "@opengeni/db";
3
+ import { listSessionEventSlices } from "@opengeni/db/session-event-slices";
4
+ import * as sessionPreviewSchema from "@opengeni/db/schema";
5
+ import { and, eq, sql } from "drizzle-orm";
3
6
  import { SessionMessageSearchRequest } from "@opengeni/contracts";
4
7
  import { scheduledSessionIds } from "@opengeni/db";
5
8
  import { withSiteSessionOrigin } from "@opengeni/core";
@@ -2924,6 +2927,90 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2924
2927
  return c.json(page.events);
2925
2928
  });
2926
2929
 
2930
+ // Selected search hit only: never use the audit event projection here, even
2931
+ // in summary mode (it can contain modelContext or truncate visible text).
2932
+ app.get(
2933
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/events/:eventId/message-preview",
2934
+ async (c) => {
2935
+ const workspaceId = c.req.param("workspaceId");
2936
+ await requireAccessGrant(c, deps, workspaceId, "sessions:read");
2937
+ const sessionId = c.req.param("sessionId");
2938
+ await assertSessionExists(db, workspaceId, sessionId);
2939
+ const eventId = c.req.param("eventId");
2940
+ const rawSequence = c.req.query("sequence");
2941
+ const sequence = Number(rawSequence);
2942
+ if (
2943
+ !z.string().uuid().safeParse(eventId).success ||
2944
+ rawSequence === undefined ||
2945
+ !/^\d+$/.test(rawSequence) ||
2946
+ !Number.isSafeInteger(sequence) ||
2947
+ sequence < 1 ||
2948
+ sequence > 2_147_483_647
2949
+ )
2950
+ throw new HTTPException(400, { message: "Invalid message preview reference" });
2951
+
2952
+ const notFound = () => new HTTPException(404, { message: "message not found" });
2953
+ const maxUnits = 12_000;
2954
+ const eventTable = sessionPreviewSchema.sessionEvents;
2955
+ // The slice helper supports structured result views too. Check only the
2956
+ // JSON scalar *kind* before calling it; never fetch a structured text value.
2957
+ const candidates = await withWorkspaceRls(db, workspaceId, (tx) =>
2958
+ tx
2959
+ .select({ kind: sql<string | null>`jsonb_typeof(${eventTable.payload}->'text')` })
2960
+ .from(eventTable)
2961
+ .where(
2962
+ and(
2963
+ eq(eventTable.workspaceId, workspaceId),
2964
+ eq(eventTable.sessionId, sessionId),
2965
+ eq(eventTable.id, eventId),
2966
+ eq(eventTable.sequence, sequence),
2967
+ sql`${eventTable.type} in ('user.message', 'agent.message.completed')`,
2968
+ ),
2969
+ )
2970
+ .limit(1),
2971
+ );
2972
+ if (candidates[0]?.kind !== "string") throw notFound();
2973
+ const read = (offset: number) =>
2974
+ listSessionEventSlices(db, workspaceId, sessionId, {
2975
+ sourceSequence: sequence,
2976
+ sourceOffset: offset,
2977
+ after: sequence - 1,
2978
+ before: sequence + 1,
2979
+ includeTypes: ["user.message", "agent.message.completed"],
2980
+ view: "conversation",
2981
+ });
2982
+ const first = await read(0);
2983
+ const event = first.events[0];
2984
+ const slice = first.slices?.[sequence];
2985
+ // Includes the slice reader's duplicate, late and unclaimed-prompt gates.
2986
+ if (!event || event.id !== eventId || event.sequence !== sequence || !slice || slice.omitted)
2987
+ throw notFound();
2988
+ if (slice.total > maxUnits) return c.json({ status: "unavailable" as const });
2989
+
2990
+ let text = slice.text;
2991
+ let offset = slice.unit === "utf16" ? slice.text.length : Array.from(slice.text).length;
2992
+ if (text.length > maxUnits) return c.json({ status: "unavailable" as const });
2993
+ while (offset < slice.total) {
2994
+ if (offset === 0) throw notFound();
2995
+ const next = await read(offset);
2996
+ const part = next.slices?.[sequence];
2997
+ if (
2998
+ next.events[0]?.id !== eventId ||
2999
+ !part ||
3000
+ part.omitted ||
3001
+ part.unit !== slice.unit ||
3002
+ part.total !== slice.total ||
3003
+ part.offset !== offset
3004
+ )
3005
+ throw notFound();
3006
+ text += part.text;
3007
+ offset += slice.unit === "utf16" ? part.text.length : Array.from(part.text).length;
3008
+ if (text.length > maxUnits) return c.json({ status: "unavailable" as const });
3009
+ }
3010
+ return c.json({ status: "available" as const, text });
3011
+ },
3012
+ );
3013
+
2927
3014
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
2928
3015
  const workspaceId = c.req.param("workspaceId");
2929
3016
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
@@ -4817,6 +4904,8 @@ export function sessionAuthorizationOperationForHttp(
4817
4904
  return verb === "POST" ? "session.context.write" : null;
4818
4905
  }
4819
4906
  if (suffix === "/events/stream" && verb === "GET") return "session.stream.read";
4907
+ if (/^\/events\/[^/]+\/message-preview$/.test(suffix) && verb === "GET")
4908
+ return "session.events.read";
4820
4909
  if (suffix === "/events") {
4821
4910
  if (verb === "GET") return "session.events.read";
4822
4911
  if (verb === "POST") return "session.append";
@@ -22,6 +22,7 @@ import {
22
22
  hasGitCredentialRepositorySelection,
23
23
  hasGitHubRepositorySelection,
24
24
  sandboxLifecycleTransitionWaitMs,
25
+ sandboxWarmRateMicrosPerSecond,
25
26
  stableSandboxEnvironmentForRun,
26
27
  type Settings,
27
28
  } from "@opengeni/config";
@@ -42,6 +43,7 @@ import {
42
43
  SandboxImageConflictError,
43
44
  SandboxProviderReadLockUnavailableError,
44
45
  SandboxRigConflictError,
46
+ SandboxPaidComputeAdmissionError,
45
47
  withSandboxProviderReadLock,
46
48
  type Database,
47
49
  type LeaseSnapshot,
@@ -823,6 +825,10 @@ async function withChannelAOperation<T>(
823
825
  holderId,
824
826
  subjectId: session.id,
825
827
  backend: session.sandboxBackend,
828
+ warmBilling: {
829
+ mode: settings.sandboxWarmBillingMode,
830
+ rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(settings, session.sandboxBackend),
831
+ },
826
832
  os: session.sandboxOs,
827
833
  image: sandboxRuntime.image,
828
834
  rigVersionId: session.rigVersionId,
@@ -1210,6 +1216,8 @@ async function withChannelAOperation<T>(
1210
1216
  * already-HTTPException unchanged. */
1211
1217
  export function mapChannelAError(error: unknown, waitSignal?: AbortSignal): unknown {
1212
1218
  if (error instanceof HTTPException) return error;
1219
+ if (error instanceof SandboxPaidComputeAdmissionError)
1220
+ return new HTTPException(402, { message: error.message, cause: error });
1213
1221
  if (isChannelARequestCancellation(error, waitSignal))
1214
1222
  return new HTTPException(499 as never, {
1215
1223
  message: "request cancelled",