@opengeni/core 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +1 -1
  2. package/dist/application/connect-operation.d.ts +3 -0
  3. package/dist/application/sandbox-recovery.d.ts +49 -0
  4. package/dist/{chunk-H7X52RI2.js → chunk-TFHSE7XR.js} +13 -2
  5. package/dist/chunk-TFHSE7XR.js.map +1 -0
  6. package/dist/dependencies.d.ts +1 -1
  7. package/dist/domain/capabilities.d.ts +2 -2
  8. package/dist/domain/connection-account-selection-error.d.ts +6 -0
  9. package/dist/domain/connector-tool-permissions.d.ts +27 -0
  10. package/dist/domain/editable-artifacts/agent-application.d.ts +8 -0
  11. package/dist/domain/environments.d.ts +3 -7
  12. package/dist/domain/insights-timing.d.ts +16 -0
  13. package/dist/domain/insights.d.ts +4 -1
  14. package/dist/domain/knowledge-files.d.ts +1 -1
  15. package/dist/domain/knowledge-preparation.d.ts +9 -0
  16. package/dist/domain/knowledge-preparation.test.d.ts +1 -0
  17. package/dist/domain/mcp-account-bindings.d.ts +19 -0
  18. package/dist/domain/native-mcp-connection-admission.d.ts +3 -0
  19. package/dist/domain/organization-integration-catalog.d.ts +15 -0
  20. package/dist/domain/personal-connection-delegations.d.ts +19 -10
  21. package/dist/domain/pr-review.d.ts +56 -4
  22. package/dist/domain/scheduled-tasks.d.ts +8 -5
  23. package/dist/domain/session-file-access.d.ts +4 -0
  24. package/dist/domain/sessions.d.ts +8 -6
  25. package/dist/domain/skills.d.ts +29 -1
  26. package/dist/editable-artifacts.js +1 -1
  27. package/dist/index.d.ts +7 -5
  28. package/dist/index.js +4868 -4803
  29. package/dist/index.js.map +1 -1
  30. package/dist/sandbox/routing.d.ts +2 -1
  31. package/dist/sandbox/runtime-settings.d.ts +1 -15
  32. package/package.json +11 -19
  33. package/src/application/composer-submit.ts +2 -1
  34. package/src/application/connect-operation.ts +32 -3
  35. package/src/application/sandbox-recovery.ts +55 -0
  36. package/src/application/user-resource-grants.ts +1 -16
  37. package/src/dependencies.ts +5 -1
  38. package/src/domain/capabilities.ts +169 -122
  39. package/src/domain/connection-account-selection-error.ts +9 -0
  40. package/src/domain/connector-tool-permissions.ts +305 -0
  41. package/src/domain/editable-artifacts/agent-application.ts +31 -2
  42. package/src/domain/environments.ts +4 -9
  43. package/src/domain/file-owner.ts +16 -1
  44. package/src/domain/insights-timing.ts +95 -0
  45. package/src/domain/insights.ts +59 -35
  46. package/src/domain/knowledge-files.ts +10 -3
  47. package/src/domain/knowledge-messages.ts +1 -0
  48. package/src/domain/knowledge-preparation.test.ts +251 -0
  49. package/src/domain/knowledge-preparation.ts +126 -0
  50. package/src/domain/mcp-account-bindings.ts +152 -0
  51. package/src/domain/native-mcp-connection-admission.ts +14 -0
  52. package/src/domain/organization-integration-catalog.ts +83 -0
  53. package/src/domain/personal-connection-delegations.ts +226 -177
  54. package/src/domain/pr-review.ts +22 -6
  55. package/src/domain/resources.ts +9 -2
  56. package/src/domain/scheduled-tasks.ts +200 -155
  57. package/src/domain/session-file-access.ts +47 -0
  58. package/src/domain/session-tool-policy.ts +12 -4
  59. package/src/domain/sessions.ts +390 -261
  60. package/src/domain/skills.ts +9 -0
  61. package/src/index.ts +10 -6
  62. package/src/rigs/index.ts +17 -17
  63. package/src/sandbox/routing.ts +18 -7
  64. package/src/sandbox/runtime-settings.ts +7 -91
  65. package/dist/application/host-mcp-owner.d.ts +0 -6
  66. package/dist/application/host-mcp-resolvers.d.ts +0 -13
  67. package/dist/chunk-5WZYFL45.js +0 -266
  68. package/dist/chunk-5WZYFL45.js.map +0 -1
  69. package/dist/chunk-H7X52RI2.js.map +0 -1
  70. package/dist/domain/connection-authority.d.ts +0 -97
  71. package/dist/domain/connection-authority.js +0 -169
  72. package/dist/domain/connection-authority.js.map +0 -1
  73. package/dist/domain/host-mcp-authority-source-admission.d.ts +0 -10
  74. package/dist/domain/host-mcp-task-admission.d.ts +0 -18
  75. package/dist/domain/packs.d.ts +0 -59
  76. package/dist/domain/product-integration-pack.d.ts +0 -44
  77. package/dist/domain/product-integration-skill.gen.d.ts +0 -5
  78. package/dist/remote-mcp-credentials.d.ts +0 -23
  79. package/dist/remote-mcp-credentials.js +0 -11
  80. package/dist/remote-mcp-credentials.js.map +0 -1
  81. package/src/application/host-mcp-owner.ts +0 -41
  82. package/src/application/host-mcp-resolvers.ts +0 -84
  83. package/src/domain/connection-authority.ts +0 -329
  84. package/src/domain/host-mcp-authority-source-admission.ts +0 -25
  85. package/src/domain/host-mcp-task-admission.ts +0 -121
  86. package/src/domain/packs.ts +0 -699
  87. package/src/domain/product-integration-pack.ts +0 -42
  88. package/src/domain/product-integration-skill.gen.ts +0 -52
  89. package/src/remote-mcp-credentials.ts +0 -361
@@ -0,0 +1,305 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
5
+ import {
6
+ boundMcpResponseBody,
7
+ MCP_MAX_RESPONSE_BYTES,
8
+ assertMcpToolListWithinBounds,
9
+ } from "@opengeni/runtime/mcp-network";
10
+ import { createPinnedIntegrationTransport } from "@opengeni/capabilities";
11
+ import type { Settings } from "@opengeni/config";
12
+ import type {
13
+ AccessGrant,
14
+ ConnectorToolPermission,
15
+ ConnectorToolPermissionEntry,
16
+ ConnectorToolPermissionsResponse,
17
+ UpdateConnectorToolPermissionsRequest,
18
+ } from "@opengeni/contracts";
19
+ import {
20
+ buildConnectionTokenResolver,
21
+ getConnectionMetadata,
22
+ listConnectionsMetadata,
23
+ listEnabledMcpCapabilityServers,
24
+ listConnectorToolPermissionPolicies,
25
+ updateConnectorToolPermissionPolicies,
26
+ resolveConnectorActionPolicy,
27
+ type Database,
28
+ } from "@opengeni/db";
29
+ import { HTTPException } from "hono/http-exception";
30
+ import { GMAIL_REST_MCP_TOOLS, isOfficialGmailMcpConfig } from "@opengeni/runtime";
31
+ import { hasPermission } from "../access";
32
+ import { buildCapabilityCatalog, settingsWithMcpCapabilityServers } from "./capabilities";
33
+
34
+ type Input = {
35
+ db: Database;
36
+ settings: Settings;
37
+ workspaceId: string;
38
+ grant: AccessGrant;
39
+ capabilityId: string;
40
+ personalOwnerVerified: boolean;
41
+ };
42
+ type ListedTool = {
43
+ name: string;
44
+ title?: string | undefined;
45
+ description?: string | undefined;
46
+ annotations?:
47
+ | {
48
+ readOnlyHint?: boolean | undefined;
49
+ destructiveHint?: boolean | undefined;
50
+ title?: string | undefined;
51
+ }
52
+ | undefined;
53
+ };
54
+
55
+ export function connectorToolGroup(tool: ListedTool): "read" | "write" | "other" {
56
+ if (tool.annotations?.destructiveHint === true || tool.annotations?.readOnlyHint === false)
57
+ return "write";
58
+ if (tool.annotations?.readOnlyHint === true) return "read";
59
+ return "other";
60
+ }
61
+
62
+ async function resolveTarget(input: Input) {
63
+ const catalog = await buildCapabilityCatalog(input);
64
+ const item = catalog.items.find((candidate) => candidate.id === input.capabilityId);
65
+ if (
66
+ !item ||
67
+ !item.enabled ||
68
+ item.kind !== "mcp" ||
69
+ !item.runtime.mcpServerId ||
70
+ item.source === "built_in"
71
+ ) {
72
+ throw new HTTPException(404, { message: "Connected MCP connector not found" });
73
+ }
74
+ const enabled = await listEnabledMcpCapabilityServers(input.db, input.workspaceId);
75
+ const settings = settingsWithMcpCapabilityServers(input.settings, enabled);
76
+ const server = settings.mcpServers.find((candidate) => candidate.id === item.runtime.mcpServerId);
77
+ if (!server || server.url !== item.endpointUrl)
78
+ throw new HTTPException(409, { message: "Connector configuration is unavailable" });
79
+ const ref = server.connectionRef;
80
+ if (ref?.authoritySource === "host")
81
+ throw new HTTPException(409, {
82
+ message: "Tool permissions for this connection are managed by its host",
83
+ });
84
+ let connection = ref?.connectionId
85
+ ? await getConnectionMetadata(
86
+ input.db,
87
+ input.workspaceId,
88
+ ref.connectionId,
89
+ input.grant.subjectId,
90
+ )
91
+ : null;
92
+ if (ref?.subjectScope === "subject" && !ref.connectionId) {
93
+ const visible = await listConnectionsMetadata(
94
+ input.db,
95
+ input.workspaceId,
96
+ input.grant.subjectId,
97
+ );
98
+ connection =
99
+ visible.find(
100
+ (candidate) =>
101
+ candidate.subjectId === input.grant.subjectId &&
102
+ candidate.providerDomain === ref.providerDomain &&
103
+ (!ref.kind || candidate.kind === ref.kind) &&
104
+ candidate.status === "active",
105
+ ) ?? null;
106
+ }
107
+ if (
108
+ ref &&
109
+ (!connection ||
110
+ connection.providerDomain !== ref.providerDomain ||
111
+ (ref.kind && connection.kind !== ref.kind) ||
112
+ (ref.subjectScope === "subject"
113
+ ? connection.subjectId !== input.grant.subjectId
114
+ : connection.subjectId !== null))
115
+ ) {
116
+ throw new HTTPException(409, { message: "Reconnect this connector to manage its tools" });
117
+ }
118
+ if (connection?.subjectId && !input.personalOwnerVerified)
119
+ throw new HTTPException(403, {
120
+ message: "Only the authenticated connection owner may manage personal tool permissions",
121
+ });
122
+ // Same secret-free target identity used by runtime sessionMcpApprovalConnectionId.
123
+ const connectionId =
124
+ connection?.id ??
125
+ `session-mcp:${server.id}:${createHash("sha256").update(server.url, "utf8").digest("hex")}`;
126
+ return { server, connection, connectionId };
127
+ }
128
+
129
+ async function listTools(
130
+ input: Input,
131
+ target: Awaited<ReturnType<typeof resolveTarget>>,
132
+ ): Promise<ListedTool[]> {
133
+ // Runtime substitutes the reviewed REST bridge for this exact MCP identity.
134
+ // Its static catalog must not depend on Google's hosted MCP preview.
135
+ if (isOfficialGmailMcpConfig(target.server.url, target.server.connectionRef)) {
136
+ return GMAIL_REST_MCP_TOOLS.filter(
137
+ (tool) => !target.server.allowedTools || target.server.allowedTools.includes(tool.name),
138
+ );
139
+ }
140
+ let headers = { ...target.server.headers };
141
+ if (target.server.connectionRef) {
142
+ const result = await buildConnectionTokenResolver(
143
+ input.db,
144
+ input.settings,
145
+ )({
146
+ workspaceId: input.workspaceId,
147
+ ...(target.connection?.subjectId ? { subjectId: input.grant.subjectId } : {}),
148
+ serverId: target.server.id,
149
+ toolName: "tools/list",
150
+ connectionRef: { ...target.server.connectionRef, connectionId: target.connectionId },
151
+ destinationUrl: target.server.url,
152
+ });
153
+ if (result.status !== "ok") throw new Error("Reconnect this connector to load its tools.");
154
+ headers = { ...headers, ...result.headers };
155
+ }
156
+ const pinned = createPinnedIntegrationTransport({ network: input.settings });
157
+ const deadline = AbortSignal.timeout(15_000);
158
+ const client = new Client(
159
+ { name: "opengeni-tool-permissions", version: "0.1.0" },
160
+ { capabilities: {} },
161
+ );
162
+ try {
163
+ const transport = new StreamableHTTPClientTransport(new URL(target.server.url), {
164
+ requestInit: { headers },
165
+ fetch: async (url, init) =>
166
+ boundMcpResponseBody(
167
+ await pinned.fetch(url.toString(), {
168
+ ...init,
169
+ signal: init?.signal ? AbortSignal.any([deadline, init.signal]) : deadline,
170
+ }),
171
+ MCP_MAX_RESPONSE_BYTES,
172
+ ),
173
+ });
174
+ await client.connect(transport as unknown as Transport, {
175
+ timeout: 15_000,
176
+ maxTotalTimeout: 15_000,
177
+ });
178
+ const tools: ListedTool[] = [];
179
+ const cursors = new Set<string>();
180
+ let cursor: string | undefined;
181
+ do {
182
+ const page = await client.listTools(cursor ? { cursor } : undefined, {
183
+ timeout: 15_000,
184
+ maxTotalTimeout: 15_000,
185
+ signal: deadline,
186
+ });
187
+ tools.push(...page.tools);
188
+ assertMcpToolListWithinBounds(tools);
189
+ if (new Set(tools.map((tool) => tool.name)).size !== tools.length)
190
+ throw new Error("The connector returned duplicate tool names.");
191
+ cursor = page.nextCursor;
192
+ if (tools.length > 2048 || (cursor && cursors.has(cursor)))
193
+ throw new Error("The connector returned an invalid or oversized tool catalog.");
194
+ if (cursor) cursors.add(cursor);
195
+ } while (cursor);
196
+ return tools.filter(
197
+ (tool) => !target.server.allowedTools || target.server.allowedTools.includes(tool.name),
198
+ );
199
+ } finally {
200
+ await client.close().catch(() => undefined);
201
+ }
202
+ }
203
+
204
+ export async function getConnectorToolPermissions(
205
+ input: Input,
206
+ ): Promise<ConnectorToolPermissionsResponse> {
207
+ const target = await resolveTarget(input);
208
+ const policies = await listConnectorToolPermissionPolicies(input.db, {
209
+ accountId: input.grant.accountId,
210
+ workspaceId: input.workspaceId,
211
+ connectionId: target.connectionId,
212
+ });
213
+ const permission = (
214
+ name: string,
215
+ ): { permission: ConnectorToolPermission; inherited: boolean } => {
216
+ const resolved = resolveConnectorActionPolicy(policies, {
217
+ connectionId: target.connectionId,
218
+ serverId: target.server.id,
219
+ toolName: name,
220
+ actionName: "*",
221
+ });
222
+ if (!resolved.managed) return { permission: "allow", inherited: true };
223
+ return {
224
+ permission: resolved.entry?.policy ?? "block",
225
+ inherited: resolved.entry?.toolName !== name,
226
+ };
227
+ };
228
+ const defaultPolicy = policies.find(
229
+ (row) => row.serverId === target.server.id && row.toolName === "*" && row.actionName === "*",
230
+ );
231
+ let tools: ListedTool[] = [];
232
+ let discoveryError: string | null = null;
233
+ try {
234
+ tools = await listTools(input, target);
235
+ if (tools.some((tool) => tool.name === "*" || tool.name !== tool.name.trim())) {
236
+ tools = tools.filter((tool) => tool.name !== "*" && tool.name === tool.name.trim());
237
+ discoveryError =
238
+ "The connector exposes a reserved or whitespace-padded tool name. Individual permissions for these names are unsupported, so they are omitted from tool groups. The connector default still applies.";
239
+ }
240
+ } catch {
241
+ discoveryError =
242
+ "Could not load the connector's tools. Try again or reconnect. Your saved permissions still apply.";
243
+ }
244
+ return {
245
+ connectionId: target.connectionId,
246
+ serverId: target.server.id,
247
+ defaultPermission: defaultPolicy?.policy ?? null,
248
+ tools: tools.map(
249
+ (tool): ConnectorToolPermissionEntry => ({
250
+ name: tool.name,
251
+ ...((tool.title ?? tool.annotations?.title)
252
+ ? { title: tool.title ?? tool.annotations?.title }
253
+ : {}),
254
+ ...(tool.description ? { description: tool.description } : {}),
255
+ group: connectorToolGroup(tool),
256
+ ...permission(tool.name),
257
+ approvalRequired:
258
+ target.server.requireApproval === true ||
259
+ (Array.isArray(target.server.requireApproval) &&
260
+ target.server.requireApproval.includes(tool.name)),
261
+ }),
262
+ ),
263
+ discoveryError,
264
+ canManage: canManageConnectorPermissions(input.grant),
265
+ };
266
+ }
267
+
268
+ function canManageConnectorPermissions(grant: AccessGrant): boolean {
269
+ return (
270
+ hasPermission(grant.permissions, "capabilities:manage") &&
271
+ grant.principalKind !== "agent_attempt" &&
272
+ grant.principalKind !== "service" &&
273
+ !grant.serviceInitiator &&
274
+ !grant.serviceInitiatorContext
275
+ );
276
+ }
277
+
278
+ export async function updateConnectorToolPermissions(
279
+ input: Input & { payload: UpdateConnectorToolPermissionsRequest },
280
+ ): Promise<void> {
281
+ if (!canManageConnectorPermissions(input.grant))
282
+ throw new HTTPException(403, { message: "Connector management permission required" });
283
+ if (
284
+ input.payload.target === "tools" &&
285
+ input.payload.toolNames.some((name) => name === "*" || name !== name.trim())
286
+ )
287
+ throw new HTTPException(400, {
288
+ message: "Tool names must be exact and cannot use the connector-default wildcard.",
289
+ });
290
+ const target = await resolveTarget(input);
291
+ // Prevent a reconnect while the sheet is open from applying old choices to a new account.
292
+ if (target.connectionId !== input.payload.connectionId)
293
+ throw new HTTPException(409, {
294
+ message: "The connector account changed. Reload its permissions.",
295
+ });
296
+ await updateConnectorToolPermissionPolicies(input.db, {
297
+ accountId: input.grant.accountId,
298
+ workspaceId: input.workspaceId,
299
+ subjectId: input.grant.subjectId,
300
+ connectionId: target.connectionId,
301
+ serverId: target.server.id,
302
+ toolNames: input.payload.target === "default" ? ["*"] : input.payload.toolNames,
303
+ policy: input.payload.permission,
304
+ });
305
+ }
@@ -76,6 +76,7 @@ export type EditableArtifactAgentContext = Readonly<{
76
76
 
77
77
  export type EditableArtifactAgentMetadata = Readonly<{
78
78
  id: string;
79
+ artifactReference: string;
79
80
  modality: EditableArtifactModality;
80
81
  title: string;
81
82
  lifecycle: EditableArtifact["lifecycle"];
@@ -174,6 +175,14 @@ export type EditableArtifactAgentApplicationDependencies = Readonly<{
174
175
  exports: EditableArtifactDurableExportService;
175
176
  associations: EditableArtifactAgentAssociationPort;
176
177
  inspector: EditableArtifactAgentInspectionKernelPort;
178
+ /** Production persists proof only after a successful native query. */
179
+ recordInspection?: (
180
+ input: EditableArtifactAgentContext & {
181
+ artifact: EditableArtifactAgentMetadata;
182
+ queryHash: string;
183
+ queryKind: string;
184
+ },
185
+ ) => Promise<string>;
177
186
  officeImports: EditableArtifactOfficeImportPort;
178
187
  workspaceFiles: EditableArtifactAgentWorkspaceFilePort;
179
188
  }>;
@@ -291,7 +300,13 @@ export class EditableArtifactAgentApplication {
291
300
  async inspect(
292
301
  input: EditableArtifactAgentContext &
293
302
  Readonly<{ artifactId: EditableArtifactId; request: EditableArtifactAgentQuery }>,
294
- ): Promise<Readonly<{ artifact: EditableArtifactAgentMetadata; projection: unknown }>> {
303
+ ): Promise<
304
+ Readonly<{
305
+ artifact: EditableArtifactAgentMetadata;
306
+ projection: unknown;
307
+ inspectionReceiptId?: string;
308
+ }>
309
+ > {
295
310
  const context = agentContext(input);
296
311
  const artifactId = editableArtifactId(input.artifactId);
297
312
  const state = await this.dependencies.domain.readCurrentKernelState({
@@ -304,8 +319,21 @@ export class EditableArtifactAgentApplication {
304
319
  const queryBytes = encodeQuery(input.request);
305
320
  const responseBytes = await this.dependencies.inspector.query({ state, queryBytes });
306
321
  const projection = decodeQueryResponse(input.request.modality, responseBytes);
322
+ const inspectionReceiptId =
323
+ state.modality === "document"
324
+ ? await this.dependencies.recordInspection?.({
325
+ ...context,
326
+ artifact: projectArtifact(state.artifact),
327
+ queryHash: createHash("sha256").update(queryBytes).digest("hex"),
328
+ queryKind: input.request.query.kind,
329
+ })
330
+ : undefined;
307
331
  await this.touch(context, artifactId);
308
- return Object.freeze({ artifact: projectArtifact(state.artifact), projection });
332
+ return Object.freeze({
333
+ artifact: projectArtifact(state.artifact),
334
+ projection,
335
+ ...(inspectionReceiptId ? { inspectionReceiptId } : {}),
336
+ });
309
337
  }
310
338
 
311
339
  async apply(
@@ -603,6 +631,7 @@ function commandProtocolVersion(modality: EditableArtifactModality): number {
603
631
  function projectArtifact(artifact: EditableArtifact): EditableArtifactAgentMetadata {
604
632
  return Object.freeze({
605
633
  id: artifact.id,
634
+ artifactReference: `[Open report](/workspaces/${artifact.scope.workspaceId}/artifacts/editable/${artifact.id})`,
606
635
  modality: artifact.modality,
607
636
  title: artifact.title,
608
637
  lifecycle: artifact.lifecycle,
@@ -60,10 +60,8 @@ export async function requireVariableSetForApi(
60
60
  }
61
61
 
62
62
  /**
63
- * Validates an variableSet attachment supplied in a request payload (session
64
- * create, scheduled task create/update, pack enable). Requires the
65
- * `variable-sets:use` permission unless the attachment was already authorized
66
- * (pack-installation-inherited attachments), and maps a missing or
63
+ * Validates a Variable Set attachment supplied in a session or task request.
64
+ * Requires attach and use permissions, and maps a missing or
67
65
  * cross-variable set to 422 because the id is payload, not the route
68
66
  * target. RLS plus the workspace_id clause make cross-workspace ids
69
67
  * indistinguishable from missing ones.
@@ -73,13 +71,10 @@ export async function validateVariableSetAttachment(
73
71
  grant: AccessGrant,
74
72
  workspaceId: string,
75
73
  variableSetId: string,
76
- options: { preauthorized?: boolean } = {},
77
74
  ): Promise<VariableSet> {
78
75
  requireVariableSetEncryption(deps.settings);
79
- if (!options.preauthorized) {
80
- requirePermission(grant, "variable-sets:attach");
81
- requirePermission(grant, "variable-sets:use");
82
- }
76
+ requirePermission(grant, "variable-sets:attach");
77
+ requirePermission(grant, "variable-sets:use");
83
78
  const variableSet = await getVariableSet(
84
79
  deps.db,
85
80
  { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
@@ -1,5 +1,9 @@
1
1
  import { requireLiveAgentAttemptAuthorization } from "../session-authorization";
2
- import { freezeAgentLearningPolicy, type SessionRlsActorContext } from "@opengeni/db";
2
+ import {
3
+ freezeAgentLearningPolicy,
4
+ getSessionAuthorityProjection,
5
+ type SessionRlsActorContext,
6
+ } from "@opengeni/db";
3
7
  import type { AccessGrant, Permission } from "@opengeni/contracts";
4
8
  import { requirePermission, type AccessGrantAuthorization } from "../access";
5
9
  import type { ApiRouteDeps } from "../dependencies";
@@ -44,7 +48,18 @@ export async function fileOwnerContextForAgent(
44
48
  executionGeneration: attempt.executionGeneration,
45
49
  },
46
50
  });
51
+ const sessionAuthority = await getSessionAuthorityProjection(
52
+ deps.db,
53
+ grant.workspaceId,
54
+ sessionId,
55
+ );
56
+ if (!sessionAuthority) throw new Error("File session authority unavailable");
47
57
  return {
58
+ sessionAttachmentReadAccess: {
59
+ sessionId,
60
+ authorityEpoch: sessionAuthority.authorityEpoch,
61
+ actor: attempt,
62
+ },
48
63
  subjectId: grant.subjectId,
49
64
  initiatingHumanSubjectId: attempt.initiatingHumanSubjectId,
50
65
  privateFileOwnerSubjectId: snapshot.defaultScope === "personal" ? snapshot.subjectId : null,
@@ -0,0 +1,95 @@
1
+ import { withDatabaseTimingObserver, type DatabaseTimingObservation } from "@opengeni/db";
2
+ import type { Observability } from "@opengeni/observability";
3
+
4
+ const phases = new Set([
5
+ "auth",
6
+ "require_workspace",
7
+ "model_bundle",
8
+ "usage_bundle",
9
+ "live_warm",
10
+ "scheduled_tasks",
11
+ "session_depth",
12
+ "floor_sessions",
13
+ "online_machines",
14
+ "attached_sessions",
15
+ "scheduled_fires",
16
+ ] as const);
17
+ export type InsightsPhase = typeof phases extends Set<infer T> ? T : never;
18
+ export type InsightsPhaseObservation = {
19
+ phase: InsightsPhase;
20
+ stage: "helper" | DatabaseTimingObservation["stage"];
21
+ outcome: "completed" | "failed";
22
+ durationMs: number;
23
+ };
24
+ export type InsightsPhaseObserver = (observation: InsightsPhaseObservation) => void;
25
+ const stages = new Set([
26
+ "helper",
27
+ "transaction_admission",
28
+ "savepoint_admission",
29
+ "rls_setup",
30
+ "scoped_callback",
31
+ ]);
32
+
33
+ function observeSafely(
34
+ observer: InsightsPhaseObserver,
35
+ observation: InsightsPhaseObservation,
36
+ ): void {
37
+ try {
38
+ void Promise.resolve(observer(observation)).catch(() => undefined);
39
+ } catch {
40
+ // Telemetry is never authoritative for results or authorization.
41
+ }
42
+ }
43
+
44
+ /** Helper durations overlap under Promise.all; they must not be summed as request latency. */
45
+ export async function measureInsightsPhase<T>(
46
+ observer: InsightsPhaseObserver | undefined,
47
+ phase: InsightsPhase,
48
+ work: () => Promise<T>,
49
+ ): Promise<T> {
50
+ if (!observer) return work();
51
+ const started = performance.now();
52
+ let outcome: InsightsPhaseObservation["outcome"] = "failed";
53
+ try {
54
+ const result = await withDatabaseTimingObserver(
55
+ (observation) => observeSafely(observer, { ...observation, phase }),
56
+ work,
57
+ );
58
+ outcome = "completed";
59
+ return result;
60
+ } finally {
61
+ observeSafely(observer, {
62
+ phase,
63
+ stage: "helper",
64
+ outcome,
65
+ durationMs: performance.now() - started,
66
+ });
67
+ }
68
+ }
69
+
70
+ /** Closed runtime label projection: caller input, SQL and identities never become labels. */
71
+ export function workspaceInsightsPhaseMetricObserver(
72
+ observability: Pick<Observability, "observeHistogram"> | null | undefined,
73
+ ): InsightsPhaseObserver | undefined {
74
+ if (!observability) return undefined;
75
+ return (observation) => {
76
+ try {
77
+ const result = observability.observeHistogram({
78
+ name: "opengeni_workspace_insights_phase_duration_seconds",
79
+ help: "Insights helper and scoped DB durations; admission includes BEGIN or SAVEPOINT, scoped callback is not pure SQL time. Concurrent phases overlap.",
80
+ buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 3, 5, 10, 30, 60],
81
+ labels: {
82
+ phase: phases.has(observation.phase) ? observation.phase : "unknown",
83
+ stage: stages.has(observation.stage) ? observation.stage : "unknown",
84
+ outcome: observation.outcome === "completed" ? "completed" : "failed",
85
+ },
86
+ value: Number.isFinite(observation.durationMs)
87
+ ? Math.max(0, observation.durationMs) / 1_000
88
+ : 0,
89
+ });
90
+ void Promise.resolve(result).catch(() => undefined);
91
+ } catch {
92
+ // Includes exporter and metric registration failures.
93
+ }
94
+ };
95
+ }
@@ -20,6 +20,9 @@ import {
20
20
  type Database,
21
21
  } from "@opengeni/db";
22
22
 
23
+ import { measureInsightsPhase, type InsightsPhaseObserver } from "./insights-timing";
24
+ export { measureInsightsPhase, workspaceInsightsPhaseMetricObserver } from "./insights-timing";
25
+ export type { InsightsPhaseObservation, InsightsPhaseObserver } from "./insights-timing";
23
26
  const MACHINE_HEARTBEAT_FRESH_MS = 120_000;
24
27
  export const WORKSPACE_INSIGHTS_PROVIDER_FILTER_MAX_UTF8_BYTES = 256;
25
28
  export const WORKSPACE_INSIGHTS_MODEL_FILTER_MAX_UTF8_BYTES = 512;
@@ -210,10 +213,13 @@ export async function getWorkspaceInsights(
210
213
  db: Database,
211
214
  settings: Settings,
212
215
  input: GetWorkspaceInsightsInput,
216
+ observePhase?: InsightsPhaseObserver,
213
217
  ): Promise<WorkspaceInsightsResponse> {
214
218
  const provider = normalizeWorkspaceInsightsFilter(input.provider, "provider");
215
219
  const model = normalizeWorkspaceInsightsFilter(input.model, "model");
216
- await requireWorkspace(db, input.workspaceId);
220
+ await measureInsightsPhase(observePhase, "require_workspace", () =>
221
+ requireWorkspace(db, input.workspaceId),
222
+ );
217
223
  const now = input.now ?? new Date();
218
224
  const window = resolveRangeWindow(input.range, now);
219
225
  const modelFilterActive = Boolean(provider || model);
@@ -221,31 +227,45 @@ export async function getWorkspaceInsights(
221
227
 
222
228
  const [modelBundle, usageBundle, liveWarm, tasks, depth, floorRows, machinesOnline] =
223
229
  await Promise.all([
224
- readWorkspaceInsightsModelBundle(db, {
225
- workspaceId: input.workspaceId,
226
- since: window.since,
227
- until: window.until,
228
- priorSince: window.priorSince,
229
- priorUntil: window.priorUntil,
230
- granularity: input.range === "today" ? "hour" : "day",
231
- ...filter,
232
- }),
233
- readWorkspaceInsightsUsageBundle(db, {
234
- workspaceId: input.workspaceId,
235
- since: window.since,
236
- until: window.until,
237
- priorSince: window.priorSince,
238
- priorUntil: window.priorUntil,
239
- monthSince: startOfUtcMonth(now),
240
- granularity: input.range === "today" ? "hour" : "day",
241
- warmGroupLimit: 24,
242
- }),
243
- listLiveWarmLeases(db, input.workspaceId),
244
- listScheduledTasks(db, input.workspaceId, 100),
245
- aggregateSessionDepth(db, input.workspaceId),
246
- listFloorSessions(db, input.workspaceId, 24),
230
+ measureInsightsPhase(observePhase, "model_bundle", () =>
231
+ readWorkspaceInsightsModelBundle(db, {
232
+ workspaceId: input.workspaceId,
233
+ since: window.since,
234
+ until: window.until,
235
+ priorSince: window.priorSince,
236
+ priorUntil: window.priorUntil,
237
+ granularity: input.range === "today" ? "hour" : "day",
238
+ ...filter,
239
+ }),
240
+ ),
241
+ measureInsightsPhase(observePhase, "usage_bundle", () =>
242
+ readWorkspaceInsightsUsageBundle(db, {
243
+ workspaceId: input.workspaceId,
244
+ since: window.since,
245
+ until: window.until,
246
+ priorSince: window.priorSince,
247
+ priorUntil: window.priorUntil,
248
+ monthSince: startOfUtcMonth(now),
249
+ granularity: input.range === "today" ? "hour" : "day",
250
+ warmGroupLimit: 24,
251
+ }),
252
+ ),
253
+ measureInsightsPhase(observePhase, "live_warm", () =>
254
+ listLiveWarmLeases(db, input.workspaceId),
255
+ ),
256
+ measureInsightsPhase(observePhase, "scheduled_tasks", () =>
257
+ listScheduledTasks(db, input.workspaceId, 100),
258
+ ),
259
+ measureInsightsPhase(observePhase, "session_depth", () =>
260
+ aggregateSessionDepth(db, input.workspaceId),
261
+ ),
262
+ measureInsightsPhase(observePhase, "floor_sessions", () =>
263
+ listFloorSessions(db, input.workspaceId, 24),
264
+ ),
247
265
  settings.sandboxSelfhostedEnabled
248
- ? countOnlineMachines(db, input.workspaceId, MACHINE_HEARTBEAT_FRESH_MS, now)
266
+ ? measureInsightsPhase(observePhase, "online_machines", () =>
267
+ countOnlineMachines(db, input.workspaceId, MACHINE_HEARTBEAT_FRESH_MS, now),
268
+ )
249
269
  : Promise.resolve(0),
250
270
  ]);
251
271
 
@@ -271,17 +291,21 @@ export async function getWorkspaceInsights(
271
291
  agentRunsUsed,
272
292
  } = usageBundle;
273
293
  const [attached, fireCounts] = await Promise.all([
274
- countSessionsAttachedToGroups(
275
- db,
276
- input.workspaceId,
277
- warmGroups.map((group) => group.groupId),
294
+ measureInsightsPhase(observePhase, "attached_sessions", () =>
295
+ countSessionsAttachedToGroups(
296
+ db,
297
+ input.workspaceId,
298
+ warmGroups.map((group) => group.groupId),
299
+ ),
300
+ ),
301
+ measureInsightsPhase(observePhase, "scheduled_fires", () =>
302
+ countScheduledTaskFires(db, {
303
+ workspaceId: input.workspaceId,
304
+ since: window.since,
305
+ until: window.until,
306
+ taskIds: tasks.map((task) => task.id),
307
+ }),
278
308
  ),
279
- countScheduledTaskFires(db, {
280
- workspaceId: input.workspaceId,
281
- since: window.since,
282
- until: window.until,
283
- taskIds: tasks.map((task) => task.id),
284
- }),
285
309
  ]);
286
310
  const backendByGroup = new Map(liveWarm.map((lease) => [lease.groupId, lease.backend]));
287
311
  const warmSecondsByGroup = new Map(warmGroups.map((group) => [group.groupId, group.warmSeconds]));