@opengeni/api-router 0.15.5 → 0.16.1

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.
@@ -627,6 +627,12 @@ async function startMcpOAuth(deps, context) {
627
627
  const providerDomain = officialSlackResource ? "slack.com" : canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
628
628
  const personalSlack = officialSlackResource || providerDomain === "slack.com";
629
629
  assertPersonalSlackOAuthStart(settings, context.payload, mcpUrl, personalSlack);
630
+ if (personalSlack && context.payload.ownership === "workspace") {
631
+ throw new HTTPException2(422, {
632
+ message: "Slack's hosted MCP connection is personal; use the OpenGeni Slack bot installation for workspace access"
633
+ });
634
+ }
635
+ const requestedOwnership = personalSlack ? "personal" : context.payload.ownership ?? "workspace";
630
636
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
631
637
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
632
638
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -637,11 +643,14 @@ async function startMcpOAuth(deps, context) {
637
643
  providerDomain,
638
644
  mcpUrl,
639
645
  personalSlack,
640
- connectionId: context.payload.connectionId
646
+ connectionId: context.payload.connectionId,
647
+ requestedOwnership: context.payload.ownership,
648
+ newConnectionOwnership: requestedOwnership
641
649
  });
642
650
  if (context.payload.connectionId && !existing) {
643
651
  throw new HTTPException2(404, { message: "connection not found" });
644
652
  }
653
+ const ownership = existing ? ownershipForConnection(existing.subjectId, context.subjectId) : requestedOwnership;
645
654
  const discovery = await discoverMcpOAuth(mcpUrl, settings);
646
655
  if (personalSlack && !isLocalTestEnvironment(settings.environment)) {
647
656
  assertSlackAuthorizationServer(discovery.as);
@@ -667,6 +676,7 @@ async function startMcpOAuth(deps, context) {
667
676
  accountId: context.accountId,
668
677
  workspaceId: context.workspaceId,
669
678
  subjectId: context.subjectId,
679
+ ownership,
670
680
  providerDomain,
671
681
  mcpUrl,
672
682
  resource,
@@ -739,6 +749,7 @@ async function completeMcpOAuthCallback(deps, input) {
739
749
  })
740
750
  };
741
751
  }
752
+ const ownerSubjectId = state.ownership === "personal" ? state.subjectId : null;
742
753
  try {
743
754
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, input.requestUrl);
744
755
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -781,7 +792,7 @@ async function completeMcpOAuthCallback(deps, input) {
781
792
  connectionId: state.connectionId,
782
793
  visibleToSubjectId: state.subjectId,
783
794
  expectedVersion: state.connectionVersion,
784
- subjectId: state.subjectId,
795
+ subjectId: ownerSubjectId,
785
796
  providerDomain: state.providerDomain,
786
797
  kind: "oauth2",
787
798
  status: "active",
@@ -793,7 +804,7 @@ async function completeMcpOAuthCallback(deps, input) {
793
804
  }) : createConnection(db, {
794
805
  accountId: state.accountId,
795
806
  workspaceId: state.workspaceId,
796
- subjectId: state.subjectId,
807
+ subjectId: ownerSubjectId,
797
808
  providerDomain: state.providerDomain,
798
809
  kind: "oauth2",
799
810
  credentialEncrypted,
@@ -812,6 +823,7 @@ async function completeMcpOAuthCallback(deps, input) {
812
823
  redirectTo: callbackReturnPath(state.returnPath, "success", {
813
824
  connectionId: connection.id,
814
825
  providerDomain: connection.providerDomain,
826
+ ownership: state.ownership,
815
827
  ...verification.metadata.status === "failed" ? { verification: "failed" } : {}
816
828
  })
817
829
  };
@@ -1213,17 +1225,32 @@ async function existingOAuthConnectionForStart(db, input) {
1213
1225
  input.connectionId,
1214
1226
  input.subjectId
1215
1227
  );
1216
- return connection?.subjectId === input.subjectId && connection.kind === "oauth2" && connection.providerDomain === input.providerDomain && (!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl) ? connection : null;
1228
+ if (!connection || connection.kind !== "oauth2") {
1229
+ return null;
1230
+ }
1231
+ const ownership = ownershipForConnection(connection.subjectId, input.subjectId);
1232
+ if (input.requestedOwnership && input.requestedOwnership !== ownership) {
1233
+ throw new HTTPException2(409, {
1234
+ message: "connection ownership cannot be changed during OAuth reconnect"
1235
+ });
1236
+ }
1237
+ return connection.providerDomain === input.providerDomain && (!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl) ? connection : null;
1217
1238
  }
1218
1239
  const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
1240
+ const ownerSubjectId = input.newConnectionOwnership === "personal" ? input.subjectId : null;
1219
1241
  const matching = visible.filter(
1220
- (connection) => connection.subjectId === input.subjectId && connection.kind === "oauth2" && connection.providerDomain === input.providerDomain && (!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl)
1242
+ (connection) => connection.subjectId === ownerSubjectId && connection.kind === "oauth2" && connection.providerDomain === input.providerDomain && (!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl)
1221
1243
  );
1222
1244
  if (input.personalSlack) {
1223
1245
  return selectCanonicalPersonalSlackConnection(matching);
1224
1246
  }
1225
1247
  return matching.find((connection) => connection.status === "active") ?? null;
1226
1248
  }
1249
+ function ownershipForConnection(subjectId, authenticatingSubjectId) {
1250
+ if (subjectId === null) return "workspace";
1251
+ if (subjectId === authenticatingSubjectId) return "personal";
1252
+ throw new HTTPException2(404, { message: "connection not found" });
1253
+ }
1227
1254
  function buildAuthorizationUrl(input) {
1228
1255
  const endpoint = oauthEndpointUrl(input.endpoint, input.settings, "OAuth authorization endpoint");
1229
1256
  const url = new URL(endpoint);
@@ -1254,6 +1281,9 @@ function readOAuthState(state, settings) {
1254
1281
  accountId: requiredString(payload.accountId, "state.accountId"),
1255
1282
  workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
1256
1283
  subjectId: requiredString(payload.subjectId, "state.subjectId"),
1284
+ // OAuth states minted before ownership was explicit were always personal.
1285
+ // Preserve that meaning for in-flight reconnects during a rolling deploy.
1286
+ ownership: connectionOwnership(payload.ownership) ?? "personal",
1257
1287
  providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
1258
1288
  mcpUrl: stringValue(payload.mcpUrl) ?? resource,
1259
1289
  resource,
@@ -1297,6 +1327,9 @@ function readOAuthState(state, settings) {
1297
1327
  ...connectionVersion !== void 0 ? { connectionVersion } : {}
1298
1328
  };
1299
1329
  }
1330
+ function connectionOwnership(value) {
1331
+ return value === "workspace" || value === "personal" ? value : void 0;
1332
+ }
1300
1333
  async function clientForState(db, settings, state) {
1301
1334
  if (state.clientRegistrationMethod === "cimd") {
1302
1335
  return {
@@ -2669,6 +2702,7 @@ import {
2669
2702
  requireVariableSetEncryption
2670
2703
  } from "@opengeni/core";
2671
2704
  import {
2705
+ captureScheduledTaskRestoreState,
2672
2706
  createValidatedScheduledTask,
2673
2707
  manualScheduledTaskTriggerUsageKey,
2674
2708
  manualScheduledTaskTriggerWorkflowId,
@@ -5998,6 +6032,7 @@ function buildOpenGeniMcpServer(deps, grant, options = {}) {
5998
6032
  },
5999
6033
  async ({ id, ...raw }) => {
6000
6034
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
6035
+ const previous = await captureScheduledTaskRestoreState(deps.db, existing);
6001
6036
  const payload = UpdateScheduledTaskRequest.parse(raw);
6002
6037
  requireVariableSetsUseForMcpAttachment(grant, payload.variableSetId);
6003
6038
  const update = await validatedScheduledTaskUpdate({
@@ -6013,7 +6048,7 @@ function buildOpenGeniMcpServer(deps, grant, options = {}) {
6013
6048
  await syncUpdatedScheduledTask({
6014
6049
  db: deps.db,
6015
6050
  workflowClient: deps.workflowClient,
6016
- previous: existing,
6051
+ previous,
6017
6052
  task
6018
6053
  });
6019
6054
  return json(task);
@@ -6027,13 +6062,14 @@ function buildOpenGeniMcpServer(deps, grant, options = {}) {
6027
6062
  },
6028
6063
  async ({ id }) => {
6029
6064
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
6065
+ const previous = await captureScheduledTaskRestoreState(deps.db, existing);
6030
6066
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
6031
6067
  status: "paused"
6032
6068
  });
6033
6069
  await syncUpdatedScheduledTask({
6034
6070
  db: deps.db,
6035
6071
  workflowClient: deps.workflowClient,
6036
- previous: existing,
6072
+ previous,
6037
6073
  task
6038
6074
  });
6039
6075
  return json(task);
@@ -6047,13 +6083,14 @@ function buildOpenGeniMcpServer(deps, grant, options = {}) {
6047
6083
  },
6048
6084
  async ({ id }) => {
6049
6085
  const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
6086
+ const previous = await captureScheduledTaskRestoreState(deps.db, existing);
6050
6087
  const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
6051
6088
  status: "active"
6052
6089
  });
6053
6090
  await syncUpdatedScheduledTask({
6054
6091
  db: deps.db,
6055
6092
  workflowClient: deps.workflowClient,
6056
- previous: existing,
6093
+ previous,
6057
6094
  task
6058
6095
  });
6059
6096
  return json(task);
@@ -8136,6 +8173,7 @@ import {
8136
8173
  } from "@opengeni/contracts";
8137
8174
  import {
8138
8175
  hasPermission as hasPermission5,
8176
+ withFrozenPersonalConnectionDelegations,
8139
8177
  settingsWithEnabledCapabilityMcpServers
8140
8178
  } from "@opengeni/core";
8141
8179
  import {
@@ -8144,6 +8182,7 @@ import {
8144
8182
  clearPendingSessionToolspaceCall,
8145
8183
  getActiveSessionTurnForExecution,
8146
8184
  getSessionRootId,
8185
+ getWorkspaceGrant as getWorkspaceGrant3,
8147
8186
  listSessionMcpServerMetadata,
8148
8187
  listSessionMcpServersForRun,
8149
8188
  registerPendingSessionToolCall,
@@ -8266,6 +8305,7 @@ async function prepareToolspaceMcpSurface(input) {
8266
8305
  executionGeneration: activeTurn.executionGeneration
8267
8306
  };
8268
8307
  const session = await requireSession2(deps.db, grant.workspaceId, sessionId);
8308
+ const personalConnectionDelegations = activeTurn.personalConnectionDelegations;
8269
8309
  let rootSessionId = sessionId;
8270
8310
  if (deps.connectionCredentials?.mcpCredentials) {
8271
8311
  const resolvedRootSessionId = await getSessionRootId(deps.db, grant.workspaceId, sessionId);
@@ -8300,6 +8340,7 @@ async function prepareToolspaceMcpSurface(input) {
8300
8340
  rootSessionId,
8301
8341
  proxyableIds,
8302
8342
  activeTurn,
8343
+ personalConnectionDelegations,
8303
8344
  getRegistry: () => getRegistry(attemptAuthority.attemptId)
8304
8345
  });
8305
8346
  const tools = listing.map(
@@ -8309,6 +8350,7 @@ async function prepareToolspaceMcpSurface(input) {
8309
8350
  authority: attemptAuthority,
8310
8351
  rootSessionId,
8311
8352
  entry,
8353
+ personalConnectionDelegations,
8312
8354
  getRegistry
8313
8355
  })
8314
8356
  );
@@ -8342,7 +8384,16 @@ async function buildToolspaceRegistry(deps, workspaceId, sessionId, attemptId) {
8342
8384
  return new Map(withSessionServers.mcpServers.map((server) => [server.id, server]));
8343
8385
  }
8344
8386
  async function resolveToolListing(input) {
8345
- const { deps, grant, sessionId, rootSessionId, proxyableIds, activeTurn, getRegistry } = input;
8387
+ const {
8388
+ deps,
8389
+ grant,
8390
+ sessionId,
8391
+ rootSessionId,
8392
+ proxyableIds,
8393
+ activeTurn,
8394
+ personalConnectionDelegations,
8395
+ getRegistry
8396
+ } = input;
8346
8397
  const cacheKey = await toolListCacheKey(
8347
8398
  deps,
8348
8399
  grant.workspaceId,
@@ -8373,7 +8424,8 @@ async function resolveToolListing(input) {
8373
8424
  config,
8374
8425
  sessionId,
8375
8426
  rootSessionId,
8376
- turn: activeTurn
8427
+ turn: activeTurn,
8428
+ personalConnectionDelegations
8377
8429
  }).catch((error) => {
8378
8430
  deps.observability?.warn("toolspace upstream connection failed", {
8379
8431
  serverId,
@@ -8510,7 +8562,15 @@ async function connectToolspaceServer(input) {
8510
8562
  };
8511
8563
  }
8512
8564
  function toolspaceToolFor(input) {
8513
- const { deps, grant, authority, rootSessionId, entry, getRegistry } = input;
8565
+ const {
8566
+ deps,
8567
+ grant,
8568
+ authority,
8569
+ rootSessionId,
8570
+ entry,
8571
+ personalConnectionDelegations,
8572
+ getRegistry
8573
+ } = input;
8514
8574
  const { sessionId } = authority;
8515
8575
  const { serverId, tool } = entry;
8516
8576
  const name = prefixedMcpToolName(serverId, tool.name);
@@ -8545,7 +8605,8 @@ function toolspaceToolFor(input) {
8545
8605
  config,
8546
8606
  sessionId,
8547
8607
  rootSessionId,
8548
- turn: reservation.turn
8608
+ turn: reservation.turn,
8609
+ personalConnectionDelegations
8549
8610
  }).catch(() => null);
8550
8611
  if (!connection) {
8551
8612
  return mcpError(`upstream tool failed: ${name}`);
@@ -8763,15 +8824,8 @@ function connectionBrokerFetch(baseFetch, input) {
8763
8824
  if (!connectionRef) {
8764
8825
  return baseFetch;
8765
8826
  }
8766
- const credentialSubjectId = input.turn.initiator.kind === "subject" ? input.turn.initiator.subjectId : void 0;
8767
- if (connectionRef.subjectScope === "subject" && !credentialSubjectId) {
8768
- throw new Error(
8769
- `subject-owned connection for MCP server ${input.config.id} requires a human turn initiator`
8770
- );
8771
- }
8772
8827
  const hostCredentialPort = input.deps.connectionCredentials?.mcpCredentials;
8773
- const resolverSubjectId = hostCredentialPort ? input.grant.subjectId : credentialSubjectId;
8774
- const resolveCredential = hostCredentialPort ? buildHostConnectionTokenResolver(hostCredentialPort, {
8828
+ const rawResolveCredential = hostCredentialPort ? buildHostConnectionTokenResolver(hostCredentialPort, {
8775
8829
  accountId: input.grant.accountId,
8776
8830
  workspaceId: input.grant.workspaceId,
8777
8831
  sessionId: input.sessionId,
@@ -8783,18 +8837,40 @@ function connectionBrokerFetch(baseFetch, input) {
8783
8837
  initiatorContext: input.turn.initiatorContext,
8784
8838
  surface: "toolspace"
8785
8839
  }) : buildConnectionTokenResolver2(input.deps.db, input.deps.settings);
8840
+ const personalDelegations = input.personalConnectionDelegations ?? [];
8841
+ const delegatedMembershipChecks = /* @__PURE__ */ new Map();
8842
+ const delegatedOwnerHasMembership = async (subjectId) => {
8843
+ const existing = delegatedMembershipChecks.get(subjectId);
8844
+ if (existing) return await existing;
8845
+ const check = getWorkspaceGrant3(input.deps.db, subjectId, input.grant.workspaceId).then(
8846
+ Boolean
8847
+ );
8848
+ delegatedMembershipChecks.set(subjectId, check);
8849
+ return await check;
8850
+ };
8851
+ const resolveCredential = withFrozenPersonalConnectionDelegations({
8852
+ resolveCredential: rawResolveCredential,
8853
+ settings: { mcpServers: [input.config] },
8854
+ personalConnectionDelegations: personalDelegations,
8855
+ ownerHasWorkspaceMembership: delegatedOwnerHasMembership
8856
+ });
8786
8857
  return async (requestInput, init) => {
8787
8858
  const request = await mcpRequestInfo(requestInput, init);
8788
8859
  const destinationUrl = mcpRequestDestinationUrl(requestInput);
8789
- const first = await resolveCredential({
8790
- workspaceId: input.grant.workspaceId,
8791
- serverId: input.config.id,
8792
- connectionRef,
8793
- destinationUrl,
8794
- forceRefresh: false,
8795
- ...request.toolName ? { toolName: request.toolName } : {},
8796
- ...resolverSubjectId ? { subjectId: resolverSubjectId } : {}
8797
- });
8860
+ const resolverSubjectId = connectionRef.subjectScope !== "subject" && hostCredentialPort ? input.grant.subjectId : void 0;
8861
+ const resolve = async (forceRefresh) => {
8862
+ const result = await resolveCredential({
8863
+ workspaceId: input.grant.workspaceId,
8864
+ serverId: input.config.id,
8865
+ connectionRef,
8866
+ destinationUrl,
8867
+ forceRefresh,
8868
+ ...request.toolName ? { toolName: request.toolName } : {},
8869
+ ...resolverSubjectId ? { subjectId: resolverSubjectId } : {}
8870
+ });
8871
+ return result;
8872
+ };
8873
+ const first = await resolve(false);
8798
8874
  if (first.status === "auth_needed") {
8799
8875
  return await authNeededFetchResponse(input, request, first);
8800
8876
  }
@@ -8804,15 +8880,7 @@ function connectionBrokerFetch(baseFetch, input) {
8804
8880
  );
8805
8881
  if (response.status === 401) {
8806
8882
  await cancelMcpResponseBody(response);
8807
- const refreshed = await resolveCredential({
8808
- workspaceId: input.grant.workspaceId,
8809
- serverId: input.config.id,
8810
- connectionRef,
8811
- destinationUrl,
8812
- forceRefresh: true,
8813
- ...request.toolName ? { toolName: request.toolName } : {},
8814
- ...resolverSubjectId ? { subjectId: resolverSubjectId } : {}
8815
- });
8883
+ const refreshed = await resolve(true);
8816
8884
  if (refreshed.status === "auth_needed") {
8817
8885
  return await authNeededFetchResponse(input, request, refreshed);
8818
8886
  }
@@ -8856,7 +8924,7 @@ function authNeededFromStatus(config, first, reason) {
8856
8924
  reason,
8857
8925
  providerDomain: connectionRef.providerDomain,
8858
8926
  ...connectionRef.provider ? { provider: connectionRef.provider } : {},
8859
- connectionId: first.connectionId,
8927
+ ...connectionRef.subjectScope === "subject" ? {} : { connectionId: first.connectionId },
8860
8928
  ...connectionRef.scopes ? { scopes: connectionRef.scopes } : {},
8861
8929
  ...connectionRef.resource ? { resource: connectionRef.resource } : {},
8862
8930
  ...connectionRef.selectedResources ? { selectedResources: connectionRef.selectedResources } : {}
@@ -10552,7 +10620,7 @@ import {
10552
10620
  createConnectionWithSlackBotSuccessAudit,
10553
10621
  encryptEnvironmentValue as encryptEnvironmentValue5,
10554
10622
  getConnectionMetadata as getConnectionMetadata3,
10555
- getWorkspaceGrant as getWorkspaceGrant4,
10623
+ getWorkspaceGrant as getWorkspaceGrant5,
10556
10624
  listConnectionsMetadata as listConnectionsMetadata3,
10557
10625
  recordSlackBotInstallCallbackFailure,
10558
10626
  revokeConnection,
@@ -10585,7 +10653,7 @@ import {
10585
10653
  decryptEnvironmentValue as decryptEnvironmentValue3,
10586
10654
  encryptEnvironmentValue as encryptEnvironmentValue4,
10587
10655
  getConnectionMetadata as getConnectionMetadata2,
10588
- getWorkspaceGrant as getWorkspaceGrant3,
10656
+ getWorkspaceGrant as getWorkspaceGrant4,
10589
10657
  loadConnectionCredentialForBroker,
10590
10658
  updateConnection as updateConnection2
10591
10659
  } from "@opengeni/db";
@@ -11026,7 +11094,7 @@ function readGoogleDriveOAuthState(raw, settings) {
11026
11094
  };
11027
11095
  }
11028
11096
  async function requireGoogleDriveCallbackGrant(deps, state) {
11029
- const grant = await getWorkspaceGrant3(deps.db, state.subjectId, state.workspaceId);
11097
+ const grant = await getWorkspaceGrant4(deps.db, state.subjectId, state.workspaceId);
11030
11098
  if (!grant || grant.accountId !== state.accountId || !hasPermission7(grant.permissions, "connections:write")) {
11031
11099
  throw new HTTPException10(403, {
11032
11100
  message: "Google Drive OAuth subject no longer has permission for this workspace"
@@ -11269,7 +11337,7 @@ function registerConnectionRoutes(app, deps) {
11269
11337
  const payload = CreateConnectionRequest.parse(await c.req.json());
11270
11338
  assertNotReservedSlackBotMetadata(payload.metadata);
11271
11339
  const key = requireEnvironmentEncryption4(settings);
11272
- const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
11340
+ const subjectId = createConnectionSubjectId(payload, grant.subjectId);
11273
11341
  const providerDomain = canonicalProviderDomain(payload.providerDomain);
11274
11342
  assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
11275
11343
  assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
@@ -11838,7 +11906,7 @@ function slackInstallErrorReason(error) {
11838
11906
  return "installation_failed";
11839
11907
  }
11840
11908
  async function requireSlackInstallCallbackGrant(db, state) {
11841
- const grant = await getWorkspaceGrant4(db, state.subjectId, state.workspaceId);
11909
+ const grant = await getWorkspaceGrant5(db, state.subjectId, state.workspaceId);
11842
11910
  if (!grant || grant.accountId !== state.accountId || !hasPermission8(grant.permissions, "connections:write")) {
11843
11911
  throw new SlackInstallCallbackError(
11844
11912
  403,
@@ -11877,6 +11945,18 @@ function writableSubjectId(requested, grantSubjectId) {
11877
11945
  }
11878
11946
  return requested;
11879
11947
  }
11948
+ function createConnectionSubjectId(payload, grantSubjectId) {
11949
+ if (payload.ownership === void 0) {
11950
+ return writableSubjectId(payload.subjectId, grantSubjectId);
11951
+ }
11952
+ const subjectId = payload.ownership === "personal" ? grantSubjectId : null;
11953
+ if (payload.subjectId !== void 0 && payload.subjectId !== subjectId) {
11954
+ throw new HTTPException11(422, {
11955
+ message: "ownership and subjectId describe different connection owners"
11956
+ });
11957
+ }
11958
+ return subjectId;
11959
+ }
11880
11960
  function encryptCredentialBundle(key, credential) {
11881
11961
  return encryptEnvironmentValue5(key, JSON.stringify(credential));
11882
11962
  }
@@ -15824,6 +15904,7 @@ import {
15824
15904
  import { requireAccessGrant as requireAccessGrant13 } from "@opengeni/core";
15825
15905
  import { recordWorkspaceUsage as recordWorkspaceUsage4, requireLimit as requireLimit6 } from "@opengeni/core";
15826
15906
  import {
15907
+ captureScheduledTaskRestoreState as captureScheduledTaskRestoreState2,
15827
15908
  createValidatedScheduledTask as createValidatedScheduledTask3,
15828
15909
  manualScheduledTaskTriggerUsageKey as manualScheduledTaskTriggerUsageKey2,
15829
15910
  manualScheduledTaskTriggerWorkflowId as manualScheduledTaskTriggerWorkflowId2,
@@ -15873,6 +15954,7 @@ function registerScheduledTaskRoutes(app, deps) {
15873
15954
  const grant = await requireAccessGrant13(c, deps, workspaceId, "scheduled_tasks:manage");
15874
15955
  const taskId = c.req.param("taskId");
15875
15956
  const existing = await requireScheduledTaskForApi(db, workspaceId, taskId);
15957
+ const previous = await captureScheduledTaskRestoreState2(db, existing);
15876
15958
  const rawPayload = await c.req.json();
15877
15959
  const payload = UpdateScheduledTaskRequest2.parse(rawPayload);
15878
15960
  const update = await validatedScheduledTaskUpdate2({
@@ -15885,23 +15967,25 @@ function registerScheduledTaskRoutes(app, deps) {
15885
15967
  toolsProvided: scheduledTaskToolsProvided2(rawPayload)
15886
15968
  });
15887
15969
  const task = await updateScheduledTask2(db, workspaceId, taskId, update);
15888
- await syncUpdatedScheduledTask2({ db, workflowClient, previous: existing, task });
15970
+ await syncUpdatedScheduledTask2({ db, workflowClient, previous, task });
15889
15971
  return c.json(task);
15890
15972
  });
15891
15973
  app.post("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/pause", async (c) => {
15892
15974
  const workspaceId = c.req.param("workspaceId");
15893
15975
  await requireAccessGrant13(c, deps, workspaceId, "scheduled_tasks:manage");
15894
15976
  const existing = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
15977
+ const previous = await captureScheduledTaskRestoreState2(db, existing);
15895
15978
  const task = await updateScheduledTask2(db, workspaceId, existing.id, { status: "paused" });
15896
- await syncUpdatedScheduledTask2({ db, workflowClient, previous: existing, task });
15979
+ await syncUpdatedScheduledTask2({ db, workflowClient, previous, task });
15897
15980
  return c.json(task);
15898
15981
  });
15899
15982
  app.post("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/resume", async (c) => {
15900
15983
  const workspaceId = c.req.param("workspaceId");
15901
15984
  await requireAccessGrant13(c, deps, workspaceId, "scheduled_tasks:manage");
15902
15985
  const existing = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
15986
+ const previous = await captureScheduledTaskRestoreState2(db, existing);
15903
15987
  const task = await updateScheduledTask2(db, workspaceId, existing.id, { status: "active" });
15904
- await syncUpdatedScheduledTask2({ db, workflowClient, previous: existing, task });
15988
+ await syncUpdatedScheduledTask2({ db, workflowClient, previous, task });
15905
15989
  return c.json(task);
15906
15990
  });
15907
15991
  app.post("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/trigger", async (c) => {
@@ -16092,6 +16176,7 @@ import {
16092
16176
  releaseLeaseHolder as releaseLeaseHolder2
16093
16177
  } from "@opengeni/db";
16094
16178
  import { appendAndPublishEvents as appendAndPublishEvents4 } from "@opengeni/events";
16179
+ import { sandboxOperationMetricObserver } from "@opengeni/observability";
16095
16180
  import { HTTPException as HTTPException22 } from "hono/http-exception";
16096
16181
  import {
16097
16182
  buildSelfhostedBackendSession,
@@ -16111,6 +16196,7 @@ import {
16111
16196
  import { relayConfigFromSettings as relayConfigFromSettings3, wrapChannelABoxWithRouting } from "@opengeni/core";
16112
16197
  async function withChannelA(services, ctx, fn) {
16113
16198
  const { db, settings, bus } = services;
16199
+ const onSandboxOperation = services.observability ? sandboxOperationMetricObserver(services.observability) : void 0;
16114
16200
  const { accountId, workspaceId, session } = ctx;
16115
16201
  if (session.sandboxBackend === "none") {
16116
16202
  throw new HTTPException22(409, { message: "sandbox not available" });
@@ -16194,7 +16280,7 @@ async function withChannelA(services, ctx, fn) {
16194
16280
  backendId: "selfhosted"
16195
16281
  };
16196
16282
  const routed = wrapChannelABoxWithRouting(
16197
- { db, settings, bus },
16283
+ { db, settings, bus, ...onSandboxOperation ? { onSandboxOperation } : {} },
16198
16284
  {
16199
16285
  accountId,
16200
16286
  workspaceId,
@@ -16317,7 +16403,7 @@ async function withChannelA(services, ctx, fn) {
16317
16403
  }
16318
16404
  }
16319
16405
  const routed = wrapChannelABoxWithRouting(
16320
- { db, settings, bus },
16406
+ { db, settings, bus, ...onSandboxOperation ? { onSandboxOperation } : {} },
16321
16407
  {
16322
16408
  accountId,
16323
16409
  workspaceId,
@@ -17089,6 +17175,7 @@ async function serveWorkspaceCaptureFile(row, path, storage) {
17089
17175
  // src/routes/sessions.ts
17090
17176
  function registerSessionRoutes(app, deps) {
17091
17177
  const { settings, db, bus, workflowClient, objectStorage } = deps;
17178
+ const channelAServices = { db, settings, bus, observability: deps.observability };
17092
17179
  const workspaceCaptureManifestCache = new WorkspaceCaptureManifestCache();
17093
17180
  const ptyIdentity = (pty) => ({
17094
17181
  leaseId: pty.leaseId,
@@ -18599,101 +18686,61 @@ function registerSessionRoutes(app, deps) {
18599
18686
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/list", async (c) => {
18600
18687
  const ctx = await channelAPreamble(c, "files:read");
18601
18688
  const req = await parseChannelABody(c, FsListRequest);
18602
- const out = await withChannelA(
18603
- { db, settings, bus },
18604
- ctx,
18605
- ({ service }) => service.fsList(req)
18606
- );
18689
+ const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsList(req));
18607
18690
  return c.json(out);
18608
18691
  });
18609
18692
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/read", async (c) => {
18610
18693
  const ctx = await channelAPreamble(c, "files:read");
18611
18694
  const req = await parseChannelABody(c, FsReadRequest);
18612
- const out = await withChannelA(
18613
- { db, settings, bus },
18614
- ctx,
18615
- ({ service }) => service.fsRead(req)
18616
- );
18695
+ const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsRead(req));
18617
18696
  return c.json(out);
18618
18697
  });
18619
18698
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/write", async (c) => {
18620
18699
  const ctx = await channelAPreamble(c, "files:write");
18621
18700
  const req = await parseChannelABody(c, FsWriteRequest);
18622
- const out = await withChannelA(
18623
- { db, settings, bus },
18624
- ctx,
18625
- ({ service }) => service.fsWrite(req)
18626
- );
18701
+ const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsWrite(req));
18627
18702
  return c.json(out);
18628
18703
  });
18629
18704
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/delete", async (c) => {
18630
18705
  const ctx = await channelAPreamble(c, "files:write");
18631
18706
  const req = await parseChannelABody(c, FsDeleteRequest);
18632
- const out = await withChannelA(
18633
- { db, settings, bus },
18634
- ctx,
18635
- ({ service }) => service.fsDelete(req)
18636
- );
18707
+ const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsDelete(req));
18637
18708
  return c.json(out);
18638
18709
  });
18639
18710
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/move", async (c) => {
18640
18711
  const ctx = await channelAPreamble(c, "files:write");
18641
18712
  const req = await parseChannelABody(c, FsMoveRequest);
18642
- const out = await withChannelA(
18643
- { db, settings, bus },
18644
- ctx,
18645
- ({ service }) => service.fsMove(req)
18646
- );
18713
+ const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMove(req));
18647
18714
  return c.json(out);
18648
18715
  });
18649
18716
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/fs/mkdir", async (c) => {
18650
18717
  const ctx = await channelAPreamble(c, "files:write");
18651
18718
  const req = await parseChannelABody(c, FsMkdirRequest);
18652
- const out = await withChannelA(
18653
- { db, settings, bus },
18654
- ctx,
18655
- ({ service }) => service.fsMkdir(req)
18656
- );
18719
+ const out = await withChannelA(channelAServices, ctx, ({ service }) => service.fsMkdir(req));
18657
18720
  return c.json(out);
18658
18721
  });
18659
18722
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/status", async (c) => {
18660
18723
  const ctx = await channelAPreamble(c, "files:read");
18661
18724
  const req = await parseChannelABody(c, GitStatusRequest);
18662
- const out = await withChannelA(
18663
- { db, settings, bus },
18664
- ctx,
18665
- ({ service }) => service.gitStatus(req)
18666
- );
18725
+ const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitStatus(req));
18667
18726
  return c.json(out);
18668
18727
  });
18669
18728
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/diff", async (c) => {
18670
18729
  const ctx = await channelAPreamble(c, "files:read");
18671
18730
  const req = await parseChannelABody(c, GitDiffRequest);
18672
- const out = await withChannelA(
18673
- { db, settings, bus },
18674
- ctx,
18675
- ({ service }) => service.gitDiff(req)
18676
- );
18731
+ const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitDiff(req));
18677
18732
  return c.json(out);
18678
18733
  });
18679
18734
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/log", async (c) => {
18680
18735
  const ctx = await channelAPreamble(c, "files:read");
18681
18736
  const req = await parseChannelABody(c, GitLogRequest);
18682
- const out = await withChannelA(
18683
- { db, settings, bus },
18684
- ctx,
18685
- ({ service }) => service.gitLog(req)
18686
- );
18737
+ const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitLog(req));
18687
18738
  return c.json(out);
18688
18739
  });
18689
18740
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/git/show", async (c) => {
18690
18741
  const ctx = await channelAPreamble(c, "files:read");
18691
18742
  const req = await parseChannelABody(c, GitShowRequest);
18692
- const out = await withChannelA(
18693
- { db, settings, bus },
18694
- ctx,
18695
- ({ service }) => service.gitShow(req)
18696
- );
18743
+ const out = await withChannelA(channelAServices, ctx, ({ service }) => service.gitShow(req));
18697
18744
  return c.json(out);
18698
18745
  });
18699
18746
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/workspace/capture", async (c) => {
@@ -18747,7 +18794,7 @@ function registerSessionRoutes(app, deps) {
18747
18794
  const ctx = await channelAPreamble(c, "terminal:attach");
18748
18795
  const req = await parseChannelABody(c, TerminalExecRequest);
18749
18796
  const out = await withChannelA(
18750
- { db, settings, bus },
18797
+ channelAServices,
18751
18798
  ctx,
18752
18799
  ({ service }) => service.terminalExec(req)
18753
18800
  );
@@ -18762,7 +18809,7 @@ function registerSessionRoutes(app, deps) {
18762
18809
  });
18763
18810
  }
18764
18811
  const ptyId = crypto.randomUUID();
18765
- const out = await withChannelA({ db, settings, bus }, ctx, async (handle) => {
18812
+ const out = await withChannelA(channelAServices, ctx, async (handle) => {
18766
18813
  if (!handle.lease) {
18767
18814
  throw new HTTPException24(409, {
18768
18815
  message: "durable interactive terminals require a session-home provider lease"
@@ -18853,7 +18900,7 @@ function registerSessionRoutes(app, deps) {
18853
18900
  throw new HTTPException24(404, { message: "pty not found or closed" });
18854
18901
  }
18855
18902
  let seq = 1;
18856
- await withChannelA({ db, settings, bus }, ctx, async (handle) => {
18903
+ await withChannelA(channelAServices, ctx, async (handle) => {
18857
18904
  await adoptPtyProcess(ctx, handle, pty);
18858
18905
  let output;
18859
18906
  try {
@@ -18915,7 +18962,7 @@ function registerSessionRoutes(app, deps) {
18915
18962
  if (!pty) {
18916
18963
  throw new HTTPException24(404, { message: "pty not found or closed" });
18917
18964
  }
18918
- await withChannelA({ db, settings, bus }, ctx, async (handle) => {
18965
+ await withChannelA(channelAServices, ctx, async (handle) => {
18919
18966
  await adoptPtyProcess(ctx, handle, pty);
18920
18967
  await handle.service.ptyResize(req, pty.execSessionId);
18921
18968
  const updated = await updatePtySessionActivity(db, {
@@ -18944,7 +18991,7 @@ function registerSessionRoutes(app, deps) {
18944
18991
  ptyId: req.ptyId
18945
18992
  });
18946
18993
  if (pty) {
18947
- await withChannelA({ db, settings, bus }, ctx, async (handle) => {
18994
+ await withChannelA(channelAServices, ctx, async (handle) => {
18948
18995
  await adoptPtyProcess(ctx, handle, pty);
18949
18996
  await handle.service.ptyClose(req, pty.execSessionId);
18950
18997
  const terminal = await getRetainedProcess(db, {
@@ -21597,9 +21644,10 @@ import {
21597
21644
  deleteSlackBotUserLink,
21598
21645
  enqueueSlackInteractionInbox,
21599
21646
  getOrCreateSlackInteraction,
21647
+ getLatestSessionModelForSubject,
21600
21648
  getSlackBotUserLink,
21601
21649
  getSlackInteractionByRoute,
21602
- getWorkspaceGrant as getWorkspaceGrant5,
21650
+ getWorkspaceGrant as getWorkspaceGrant6,
21603
21651
  listSessionEventPage as listSessionEventPage3,
21604
21652
  listSessionHumanInputRequests as listSessionHumanInputRequests2,
21605
21653
  rekeySlackInteractionRoute,
@@ -21862,6 +21910,14 @@ async function drainSlackInteractionsOnce(deps) {
21862
21910
  });
21863
21911
  } catch (error) {
21864
21912
  const code = safeErrorCode(error);
21913
+ console.error("[slack-interactions] inbox processing failed", {
21914
+ workspaceId: entry.workspaceId,
21915
+ connectionId: entry.connectionId,
21916
+ providerEventId: entry.providerEventId,
21917
+ triggerKind: entry.triggerKind,
21918
+ attemptCount: entry.attemptCount,
21919
+ errorCode: code
21920
+ });
21865
21921
  if (entry.attemptCount >= 5 || permanentSlackInteractionError(error) || permanentSlackDeliveryError(error)) {
21866
21922
  await settleSlackInteractionInbox(deps.db, {
21867
21923
  entry,
@@ -21966,7 +22022,7 @@ async function processSlackInboxEntry(deps, entry) {
21966
22022
  });
21967
22023
  return;
21968
22024
  }
21969
- const grant = await getWorkspaceGrant5(deps.db, link.subjectId, entry.workspaceId, {
22025
+ const grant = await getWorkspaceGrant6(deps.db, link.subjectId, entry.workspaceId, {
21970
22026
  principalKind: "human_session"
21971
22027
  });
21972
22028
  if (!grant || grant.accountId !== entry.accountId) {
@@ -21995,14 +22051,33 @@ async function processSlackInboxEntry(deps, entry) {
21995
22051
  await continueSlackSession(deps, grant, interaction, entry);
21996
22052
  return;
21997
22053
  }
21998
- const session = await createSessionForRequest3(deps, grant, entry.workspaceId, {
21999
- requestedSessionId: interaction.sessionReservationId,
22000
- initialMessage: entry.text,
22001
- turnInstructions: SLACK_TASK_INSTRUCTIONS,
22002
- firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
22003
- idempotencyKey: `slack:${entry.connectionId}:${entry.providerEventId}`,
22004
- clientEventId: `slack:${entry.providerEventId}`
22005
- });
22054
+ const preferredModel = await getLatestSessionModelForSubject(
22055
+ deps.db,
22056
+ entry.workspaceId,
22057
+ grant.subjectId
22058
+ );
22059
+ let session;
22060
+ try {
22061
+ session = await createSessionForRequest3(deps, grant, entry.workspaceId, {
22062
+ requestedSessionId: interaction.sessionReservationId,
22063
+ initialMessage: entry.text,
22064
+ turnInstructions: SLACK_TASK_INSTRUCTIONS,
22065
+ firstPartyMcpTools: [...SLACK_TASK_FIRST_PARTY_MCP_TOOLS],
22066
+ ...preferredModel ? { model: preferredModel } : {},
22067
+ idempotencyKey: `slack:${entry.connectionId}:${entry.providerEventId}`,
22068
+ clientEventId: `slack:${entry.providerEventId}`
22069
+ });
22070
+ } catch (error) {
22071
+ if (error instanceof HTTPException32) {
22072
+ await client.postMessage({
22073
+ operationId: deterministicUuid(`slack-admission-failed:${interaction.id}`),
22074
+ channelId: entry.slackChannelId,
22075
+ ...entry.triggerKind === "slash_command" ? {} : { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs },
22076
+ text: slackAdmissionFailureText(error)
22077
+ });
22078
+ }
22079
+ throw error;
22080
+ }
22006
22081
  const bound = await bindSlackInteractionSession(deps.db, {
22007
22082
  ...interaction,
22008
22083
  owningSubjectId: grant.subjectId,
@@ -22393,9 +22468,19 @@ function safePayloadText(payload, field) {
22393
22468
  }
22394
22469
  function safeErrorCode(error) {
22395
22470
  if (error instanceof SlackBotProviderError) return error.code.slice(0, 128);
22471
+ if (error instanceof HTTPException32) return `http_${error.status}`;
22396
22472
  const raw = error instanceof Error ? error.name : "slack_interaction_error";
22397
22473
  return raw.toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 128) || "error";
22398
22474
  }
22475
+ function slackAdmissionFailureText(error) {
22476
+ if (error.status === 402) {
22477
+ return "OpenGeni could not start this task because the selected model has no available billing source. Open OpenGeni, select a connected subscription model, and try again.";
22478
+ }
22479
+ if (error.status === 429) {
22480
+ return "OpenGeni could not start this task because this workspace has reached a usage limit. Try again later or review the workspace limits in OpenGeni.";
22481
+ }
22482
+ return "OpenGeni could not start this task because the workspace rejected the session settings. Open OpenGeni, select an available model, and try again.";
22483
+ }
22399
22484
  var SlackInteractionPermanentError = class extends Error {
22400
22485
  };
22401
22486
  function permanentSlackInteractionError(error) {
@@ -23455,4 +23540,4 @@ export {
23455
23540
  withDefaultEnabledCapabilityMcpTools,
23456
23541
  workflowIdForSession2 as workflowIdForSession
23457
23542
  };
23458
- //# sourceMappingURL=chunk-ICRZC3JH.js.map
23543
+ //# sourceMappingURL=chunk-ZD5YBUNA.js.map