@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.
- package/dist/app.js +1 -1
- package/dist/{chunk-24C5LPNE.js → chunk-2SSU5KWV.js} +715 -279
- package/dist/{chunk-24C5LPNE.js.map → chunk-2SSU5KWV.js.map} +1 -1
- package/dist/index.js +1 -1
- package/dist/integrations/github-lens-connect.d.ts +1 -1
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/documents.d.ts +2 -0
- package/dist/mcp/first-party-tool-permissions.d.ts +8 -0
- package/dist/routes/browser-sessions.d.ts +3 -0
- package/dist/routes/github-browser-pages.d.ts +5 -0
- package/dist/routes/insights.d.ts +26 -0
- package/dist/sandbox-file-artifacts.d.ts +17 -1
- package/package.json +15 -15
- package/src/mcp/documents.ts +9 -2
- package/src/mcp/first-party-tool-permissions.ts +2 -0
- package/src/mcp/knowledge-entries.ts +15 -2
- package/src/mcp/server.ts +51 -2
- package/src/routes/browser-sessions.ts +140 -0
- package/src/routes/computer-sessions.ts +18 -1
- package/src/routes/documents.ts +1 -0
- package/src/routes/files.ts +4 -0
- package/src/routes/github-browser-pages.ts +102 -0
- package/src/routes/github.ts +6 -47
- package/src/routes/insights.ts +70 -6
- package/src/routes/knowledge.ts +19 -0
- package/src/routes/managed-onboarding.ts +3 -0
- package/src/routes/organization-memberships.ts +3 -0
- package/src/routes/sessions.ts +89 -0
- package/src/sandbox/channel-a.ts +8 -0
- package/src/sandbox/viewer.ts +16 -3
- package/src/sandbox-file-artifacts.ts +37 -26
- package/src/workspace-tool-gateway.ts +1 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
}
|
package/src/routes/documents.ts
CHANGED
|
@@ -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);
|
package/src/routes/files.ts
CHANGED
|
@@ -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
|
+
"&": "&",
|
|
96
|
+
"<": "<",
|
|
97
|
+
">": ">",
|
|
98
|
+
'"': """,
|
|
99
|
+
"'": "'",
|
|
100
|
+
})[char] ?? char,
|
|
101
|
+
);
|
|
102
|
+
}
|
package/src/routes/github.ts
CHANGED
|
@@ -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
|
-
"&": "&",
|
|
1106
|
-
"<": "<",
|
|
1107
|
-
">": ">",
|
|
1108
|
-
'"': """,
|
|
1109
|
-
"'": "'",
|
|
1110
|
-
})[char] ?? char,
|
|
1111
|
-
);
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
1073
|
function openGeniReturnUrl(
|
|
1115
1074
|
settings: ApiRouteDeps["settings"],
|
|
1116
1075
|
c: Context,
|
package/src/routes/insights.ts
CHANGED
|
@@ -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
|
|
58
|
-
|
|
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
|
-
|
|
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));
|
package/src/routes/knowledge.ts
CHANGED
|
@@ -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,
|
package/src/routes/sessions.ts
CHANGED
|
@@ -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";
|
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -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",
|