@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.
- package/dist/app.js +1 -1
- package/dist/{chunk-24C5LPNE.js → chunk-PTO4RZT5.js} +798 -281
- package/dist/{chunk-24C5LPNE.js.map → chunk-PTO4RZT5.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 +12 -0
- package/dist/mcp/scheduled-task-view.d.ts +3 -3
- 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 +19 -19
- package/src/mcp/documents.ts +9 -2
- package/src/mcp/first-party-tool-permissions.ts +6 -0
- package/src/mcp/knowledge-entries.ts +15 -2
- package/src/mcp/server.ts +118 -3
- package/src/routes/browser-sessions.ts +167 -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
|
@@ -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 {
|
|
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));
|