@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.
- package/README.md +1 -1
- package/dist/application/connect-operation.d.ts +3 -0
- package/dist/application/sandbox-recovery.d.ts +49 -0
- package/dist/{chunk-H7X52RI2.js → chunk-TFHSE7XR.js} +13 -2
- package/dist/chunk-TFHSE7XR.js.map +1 -0
- package/dist/dependencies.d.ts +1 -1
- package/dist/domain/capabilities.d.ts +2 -2
- package/dist/domain/connection-account-selection-error.d.ts +6 -0
- package/dist/domain/connector-tool-permissions.d.ts +27 -0
- package/dist/domain/editable-artifacts/agent-application.d.ts +8 -0
- package/dist/domain/environments.d.ts +3 -7
- package/dist/domain/insights-timing.d.ts +16 -0
- package/dist/domain/insights.d.ts +4 -1
- package/dist/domain/knowledge-files.d.ts +1 -1
- package/dist/domain/knowledge-preparation.d.ts +9 -0
- package/dist/domain/knowledge-preparation.test.d.ts +1 -0
- package/dist/domain/mcp-account-bindings.d.ts +19 -0
- package/dist/domain/native-mcp-connection-admission.d.ts +3 -0
- package/dist/domain/organization-integration-catalog.d.ts +15 -0
- package/dist/domain/personal-connection-delegations.d.ts +19 -10
- package/dist/domain/pr-review.d.ts +56 -4
- package/dist/domain/scheduled-tasks.d.ts +8 -5
- package/dist/domain/session-file-access.d.ts +4 -0
- package/dist/domain/sessions.d.ts +8 -6
- package/dist/domain/skills.d.ts +29 -1
- package/dist/editable-artifacts.js +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +4868 -4803
- package/dist/index.js.map +1 -1
- package/dist/sandbox/routing.d.ts +2 -1
- package/dist/sandbox/runtime-settings.d.ts +1 -15
- package/package.json +11 -19
- package/src/application/composer-submit.ts +2 -1
- package/src/application/connect-operation.ts +32 -3
- package/src/application/sandbox-recovery.ts +55 -0
- package/src/application/user-resource-grants.ts +1 -16
- package/src/dependencies.ts +5 -1
- package/src/domain/capabilities.ts +169 -122
- package/src/domain/connection-account-selection-error.ts +9 -0
- package/src/domain/connector-tool-permissions.ts +305 -0
- package/src/domain/editable-artifacts/agent-application.ts +31 -2
- package/src/domain/environments.ts +4 -9
- package/src/domain/file-owner.ts +16 -1
- package/src/domain/insights-timing.ts +95 -0
- package/src/domain/insights.ts +59 -35
- package/src/domain/knowledge-files.ts +10 -3
- package/src/domain/knowledge-messages.ts +1 -0
- package/src/domain/knowledge-preparation.test.ts +251 -0
- package/src/domain/knowledge-preparation.ts +126 -0
- package/src/domain/mcp-account-bindings.ts +152 -0
- package/src/domain/native-mcp-connection-admission.ts +14 -0
- package/src/domain/organization-integration-catalog.ts +83 -0
- package/src/domain/personal-connection-delegations.ts +226 -177
- package/src/domain/pr-review.ts +22 -6
- package/src/domain/resources.ts +9 -2
- package/src/domain/scheduled-tasks.ts +200 -155
- package/src/domain/session-file-access.ts +47 -0
- package/src/domain/session-tool-policy.ts +12 -4
- package/src/domain/sessions.ts +390 -261
- package/src/domain/skills.ts +9 -0
- package/src/index.ts +10 -6
- package/src/rigs/index.ts +17 -17
- package/src/sandbox/routing.ts +18 -7
- package/src/sandbox/runtime-settings.ts +7 -91
- package/dist/application/host-mcp-owner.d.ts +0 -6
- package/dist/application/host-mcp-resolvers.d.ts +0 -13
- package/dist/chunk-5WZYFL45.js +0 -266
- package/dist/chunk-5WZYFL45.js.map +0 -1
- package/dist/chunk-H7X52RI2.js.map +0 -1
- package/dist/domain/connection-authority.d.ts +0 -97
- package/dist/domain/connection-authority.js +0 -169
- package/dist/domain/connection-authority.js.map +0 -1
- package/dist/domain/host-mcp-authority-source-admission.d.ts +0 -10
- package/dist/domain/host-mcp-task-admission.d.ts +0 -18
- package/dist/domain/packs.d.ts +0 -59
- package/dist/domain/product-integration-pack.d.ts +0 -44
- package/dist/domain/product-integration-skill.gen.d.ts +0 -5
- package/dist/remote-mcp-credentials.d.ts +0 -23
- package/dist/remote-mcp-credentials.js +0 -11
- package/dist/remote-mcp-credentials.js.map +0 -1
- package/src/application/host-mcp-owner.ts +0 -41
- package/src/application/host-mcp-resolvers.ts +0 -84
- package/src/domain/connection-authority.ts +0 -329
- package/src/domain/host-mcp-authority-source-admission.ts +0 -25
- package/src/domain/host-mcp-task-admission.ts +0 -121
- package/src/domain/packs.ts +0 -699
- package/src/domain/product-integration-pack.ts +0 -42
- package/src/domain/product-integration-skill.gen.ts +0 -52
- 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<
|
|
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({
|
|
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
|
|
64
|
-
*
|
|
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
|
-
|
|
80
|
-
|
|
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 },
|
package/src/domain/file-owner.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { requireLiveAgentAttemptAuthorization } from "../session-authorization";
|
|
2
|
-
import {
|
|
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
|
+
}
|
package/src/domain/insights.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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]));
|