@opengeni/api-router 0.22.2 → 0.23.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.
Files changed (44) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth.d.ts +0 -30
  3. package/dist/{chunk-HWXJW5C7.js → chunk-T4T2PGU4.js} +3036 -1362
  4. package/dist/chunk-T4T2PGU4.js.map +1 -0
  5. package/dist/http/sse.d.ts +2 -0
  6. package/dist/index.js +29 -9
  7. package/dist/index.js.map +1 -1
  8. package/dist/integrations/oauth-client.d.ts +8 -0
  9. package/dist/integrations/slack-interactions.d.ts +7 -1
  10. package/dist/mcp/receipts.d.ts +28 -0
  11. package/dist/mcp/scheduled-task-view.d.ts +350 -0
  12. package/dist/mcp/toolspace.d.ts +9 -0
  13. package/dist/sandbox/auth-callout.d.ts +2 -0
  14. package/dist/sandbox/channel-a.d.ts +5 -1
  15. package/package.json +12 -12
  16. package/src/app.ts +3 -4
  17. package/src/auth/managed-auth.ts +0 -16
  18. package/src/http/sse.ts +101 -6
  19. package/src/index.ts +28 -3
  20. package/src/integrations/oauth-client.ts +36 -56
  21. package/src/integrations/slack-interactions.ts +123 -15
  22. package/src/mcp/documents.ts +42 -25
  23. package/src/mcp/receipts.ts +95 -0
  24. package/src/mcp/scheduled-task-view.ts +608 -0
  25. package/src/mcp/server.ts +812 -182
  26. package/src/mcp/toolspace.ts +75 -71
  27. package/src/observability.ts +3 -3
  28. package/src/routes/api-keys.ts +7 -1
  29. package/src/routes/codex.ts +7 -4
  30. package/src/routes/connections.ts +74 -3
  31. package/src/routes/enrollments.ts +54 -12
  32. package/src/routes/environments.ts +60 -11
  33. package/src/routes/files.ts +175 -65
  34. package/src/routes/install.ts +31 -1
  35. package/src/routes/machines.ts +1 -1
  36. package/src/routes/scheduled-tasks.ts +39 -14
  37. package/src/routes/sessions.ts +77 -12
  38. package/src/routes/transcription-recordings.ts +65 -33
  39. package/src/sandbox/auth-callout.ts +16 -4
  40. package/src/sandbox/channel-a.ts +124 -7
  41. package/src/sandbox/enrollment.ts +13 -3
  42. package/src/sandbox/machines.ts +1 -1
  43. package/src/sandbox/viewer.ts +29 -20
  44. package/dist/chunk-HWXJW5C7.js.map +0 -1
package/src/http/sse.ts CHANGED
@@ -12,6 +12,11 @@ const SESSION_REPLAY_PAGE_SIZE = 100;
12
12
  const WORKSPACE_CONTROL_REPLAY_PAGE_SIZE = 100;
13
13
  export const SSE_QUEUED_FRAME_MAX_COUNT = 1;
14
14
  export const SSE_WRITE_STALL_TIMEOUT_MS = 30_000;
15
+ export const SSE_HEARTBEAT_INTERVAL_MS = 15_000;
16
+ const activeSseStreams: Record<"session" | "workspace_control", number> = {
17
+ session: 0,
18
+ workspace_control: 0,
19
+ };
15
20
 
16
21
  export type SseDeliveryBoundObservation = {
17
22
  reason: "desired_size_non_positive" | "stall_timeout" | "frame_too_large";
@@ -251,6 +256,7 @@ export async function sseSessionStream(
251
256
  signal: AbortSignal,
252
257
  options: SessionSseDeliveryOptions = {},
253
258
  ): Promise<Response> {
259
+ const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
254
260
  if (
255
261
  options.reauthorize &&
256
262
  options.reauthorizeAfterMs !== undefined &&
@@ -266,13 +272,20 @@ export async function sseSessionStream(
266
272
  let unsubscribe: (() => void) | null = null;
267
273
  let delivery: LatestWinsDelivery<SessionEvent> | null = null;
268
274
  let reauthorizationTimer: ReturnType<typeof setTimeout> | null = null;
275
+ let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
269
276
  let detachAbortListener = () => {};
277
+ let closeMetrics = () => {};
270
278
  const stopUpstream = () => {
279
+ closeMetrics();
271
280
  detachAbortListener();
272
281
  if (reauthorizationTimer) {
273
282
  clearTimeout(reauthorizationTimer);
274
283
  reauthorizationTimer = null;
275
284
  }
285
+ if (heartbeatTimer) {
286
+ clearTimeout(heartbeatTimer);
287
+ heartbeatTimer = null;
288
+ }
276
289
  delivery?.stop();
277
290
  const release = unsubscribe;
278
291
  unsubscribe = null;
@@ -284,6 +297,7 @@ export async function sseSessionStream(
284
297
  onObservation: sseObservationReporter("session", options),
285
298
  onStop: stopUpstream,
286
299
  });
300
+ closeMetrics = observeSseConnection("session", after, options.observability);
287
301
 
288
302
  const fail = (error: unknown) => {
289
303
  channel.fail(retryableSseFailure("session event stream delivery failed", error));
@@ -299,10 +313,24 @@ export async function sseSessionStream(
299
313
  }, interval);
300
314
  };
301
315
  scheduleReauthorization();
302
- const writeFrame = async (frame: string) => {
303
- if (!(await channel.write(frame))) {
304
- throw new SseStreamStoppedError();
305
- }
316
+ let writeTail = Promise.resolve();
317
+ const writeFrame = (frame: string): Promise<void> => {
318
+ const write = writeTail.then(async () => {
319
+ if (!(await channel.write(frame))) throw new SseStreamStoppedError();
320
+ });
321
+ writeTail = write.catch(() => {});
322
+ return write;
323
+ };
324
+ const scheduleHeartbeat = () => {
325
+ if (channel.stopped()) return;
326
+ heartbeatTimer = setTimeout(() => {
327
+ heartbeatTimer = null;
328
+ void writeFrame(": heartbeat\n\n")
329
+ .then(scheduleHeartbeat)
330
+ .catch((error) => {
331
+ if (!(error instanceof SseStreamStoppedError)) fail(error);
332
+ });
333
+ }, heartbeatIntervalMs);
306
334
  };
307
335
  const send = async (event: SessionEvent) => {
308
336
  if (event.sequence <= lastSent) return;
@@ -362,6 +390,7 @@ export async function sseSessionStream(
362
390
  SESSION_REPLAY_PAGE_SIZE,
363
391
  );
364
392
  await writeFrame(": connected\n\n");
393
+ scheduleHeartbeat();
365
394
  bootstrapping = false;
366
395
  const buffered = newestBuffered;
367
396
  newestBuffered = null;
@@ -425,14 +454,22 @@ export async function sseWorkspaceControlStream(
425
454
  signal: AbortSignal,
426
455
  options: SseDeliveryOptions = {},
427
456
  ): Promise<Response> {
457
+ const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
428
458
  let lastSent = after;
429
459
  let bootstrapping = true;
430
460
  let newestBuffered: WorkspaceControlEvent | null = null;
431
461
  let unsubscribe: (() => void) | null = null;
432
462
  let delivery: LatestWinsDelivery<WorkspaceControlEvent> | null = null;
463
+ let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
433
464
  let detachAbortListener = () => {};
465
+ let closeMetrics = () => {};
434
466
  const stopUpstream = () => {
467
+ closeMetrics();
435
468
  detachAbortListener();
469
+ if (heartbeatTimer) {
470
+ clearTimeout(heartbeatTimer);
471
+ heartbeatTimer = null;
472
+ }
436
473
  delivery?.stop();
437
474
  const release = unsubscribe;
438
475
  unsubscribe = null;
@@ -444,12 +481,29 @@ export async function sseWorkspaceControlStream(
444
481
  onObservation: sseObservationReporter("workspace_control", options),
445
482
  onStop: stopUpstream,
446
483
  });
484
+ closeMetrics = observeSseConnection("workspace_control", after, options.observability);
447
485
 
448
486
  const fail = (error: unknown) => {
449
487
  channel.fail(retryableSseFailure("workspace control stream delivery failed", error));
450
488
  };
451
- const writeFrame = async (frame: string) => {
452
- if (!(await channel.write(frame))) throw new SseStreamStoppedError();
489
+ let writeTail = Promise.resolve();
490
+ const writeFrame = (frame: string): Promise<void> => {
491
+ const write = writeTail.then(async () => {
492
+ if (!(await channel.write(frame))) throw new SseStreamStoppedError();
493
+ });
494
+ writeTail = write.catch(() => {});
495
+ return write;
496
+ };
497
+ const scheduleHeartbeat = () => {
498
+ if (channel.stopped()) return;
499
+ heartbeatTimer = setTimeout(() => {
500
+ heartbeatTimer = null;
501
+ void writeFrame(": heartbeat\n\n")
502
+ .then(scheduleHeartbeat)
503
+ .catch((error) => {
504
+ if (!(error instanceof SseStreamStoppedError)) fail(error);
505
+ });
506
+ }, heartbeatIntervalMs);
453
507
  };
454
508
  const send = async (event: WorkspaceControlEvent) => {
455
509
  if (event.sequence <= lastSent) return;
@@ -505,6 +559,7 @@ export async function sseWorkspaceControlStream(
505
559
  WORKSPACE_CONTROL_REPLAY_PAGE_SIZE,
506
560
  );
507
561
  await writeFrame(": connected\n\n");
562
+ scheduleHeartbeat();
508
563
  bootstrapping = false;
509
564
  const buffered = newestBuffered;
510
565
  newestBuffered = null;
@@ -531,6 +586,37 @@ export async function sseWorkspaceControlStream(
531
586
  });
532
587
  }
533
588
 
589
+ function observeSseConnection(
590
+ stream: "session" | "workspace_control",
591
+ after: number,
592
+ observability: Observability | undefined,
593
+ ): () => void {
594
+ activeSseStreams[stream] += 1;
595
+ observability?.incrementCounter({
596
+ name: "opengeni_sse_connections_total",
597
+ help: "SSE connections opened at the API boundary.",
598
+ labels: { stream, resume: after > 0 ? "resumed" : "fresh" },
599
+ });
600
+ observability?.setGauge?.({
601
+ name: "opengeni_sse_connections_active",
602
+ help: "Currently active SSE connections at the API boundary.",
603
+ labels: { stream },
604
+ value: activeSseStreams[stream],
605
+ });
606
+ let closed = false;
607
+ return () => {
608
+ if (closed) return;
609
+ closed = true;
610
+ activeSseStreams[stream] = Math.max(0, activeSseStreams[stream] - 1);
611
+ observability?.setGauge?.({
612
+ name: "opengeni_sse_connections_active",
613
+ help: "Currently active SSE connections at the API boundary.",
614
+ labels: { stream },
615
+ value: activeSseStreams[stream],
616
+ });
617
+ };
618
+ }
619
+
534
620
  async function replayWorkspaceControlEvents(
535
621
  loadPage: (after: number, limit: number) => Promise<WorkspaceControlEvent[]>,
536
622
  send: (event: WorkspaceControlEvent) => Promise<void>,
@@ -561,6 +647,7 @@ class SseStreamStoppedError extends Error {}
561
647
  export type SseDeliveryOptions = {
562
648
  maxQueuedBytes?: number;
563
649
  stallTimeoutMs?: number;
650
+ heartbeatIntervalMs?: number;
564
651
  observability?: Observability | undefined;
565
652
  onObservation?: ((observation: SseDeliveryBoundObservation) => void) | undefined;
566
653
  };
@@ -597,3 +684,11 @@ function sseObservationReporter(
597
684
  function retryableSseFailure(message: string, error: unknown): TypeError {
598
685
  return error instanceof TypeError ? error : new TypeError(message, { cause: error });
599
686
  }
687
+
688
+ function resolveHeartbeatInterval(value: number | undefined): number {
689
+ const interval = value ?? SSE_HEARTBEAT_INTERVAL_MS;
690
+ if (!Number.isSafeInteger(interval) || interval < 1_000 || interval > 60_000) {
691
+ throw new RangeError("SSE heartbeat interval must be between 1000 and 60000ms");
692
+ }
693
+ return interval;
694
+ }
package/src/index.ts CHANGED
@@ -366,11 +366,13 @@ export async function startApi() {
366
366
  { db: dbClient.db, settings, callout, observability },
367
367
  settings.natsUrl,
368
368
  );
369
- } catch (error) {
369
+ } catch {
370
370
  // A responder start failure must not crash the API (other planes work); log
371
371
  // loudly — selfhosted agents will fail to connect until it is up.
372
372
  observability.error("OpenGeni NATS auth-callout responder failed to start", {
373
- error: error instanceof Error ? error.message : String(error),
373
+ errorClass: "NatsAuthCalloutOperationError",
374
+ errorCode: "nats_auth_callout_start_failed",
375
+ origin: "api",
374
376
  });
375
377
  }
376
378
  } else {
@@ -422,7 +424,7 @@ export function shouldCreateScheduleAfterUpdateError(error: unknown): boolean {
422
424
  export function temporalScheduleSpec(schedule: ScheduledTaskScheduleSpec): ScheduleSpec {
423
425
  if (schedule.type === "interval") {
424
426
  return {
425
- intervals: [{ every: `${schedule.everySeconds}s` }],
427
+ intervals: [temporalIntervalSpec(schedule)],
426
428
  ...(schedule.startAt ? { startAt: new Date(schedule.startAt) } : {}),
427
429
  ...(schedule.endAt ? { endAt: new Date(schedule.endAt) } : {}),
428
430
  };
@@ -456,6 +458,29 @@ export function temporalScheduleSpec(schedule: ScheduledTaskScheduleSpec): Sched
456
458
  };
457
459
  }
458
460
 
461
+ function temporalIntervalSpec(
462
+ schedule: Extract<ScheduledTaskScheduleSpec, { type: "interval" }>,
463
+ ): NonNullable<ScheduleSpec["intervals"]>[number] {
464
+ const every = `${schedule.everySeconds}s` as `${number}s`;
465
+ if (!schedule.startAt) {
466
+ return { every };
467
+ }
468
+
469
+ // Temporal interval schedules match Epoch + (n * every) + offset. Its
470
+ // top-level startAt only filters matching times before that boundary, so it
471
+ // does not itself anchor the cadence. Derive the phase from startAt to make
472
+ // the stored OpenGeni timestamp the first interval boundary rather than the
473
+ // next epoch-aligned match.
474
+ const everyMilliseconds = BigInt(schedule.everySeconds) * 1_000n;
475
+ const startMilliseconds = BigInt(new Date(schedule.startAt).getTime());
476
+ const offsetMilliseconds =
477
+ ((startMilliseconds % everyMilliseconds) + everyMilliseconds) % everyMilliseconds;
478
+ return {
479
+ every,
480
+ ...(offsetMilliseconds === 0n ? {} : { offset: `${offsetMilliseconds}ms` as `${number}ms` }),
481
+ };
482
+ }
483
+
459
484
  function temporalMonth(monthIndex: number) {
460
485
  return TEMPORAL_MONTHS[monthIndex]!;
461
486
  }
@@ -282,8 +282,7 @@ export async function startMcpOAuth(
282
282
  error instanceof OAuthStartStageError
283
283
  ? error
284
284
  : new OAuthStartStageError("connection_lookup", oauthStartFailureReason(error), error);
285
- const providerDomain = safeRequestedProviderDomain(context.payload);
286
- logOAuthStartFailure(deps.observability, staged, providerDomain);
285
+ logOAuthStartFailure(deps.observability, staged);
287
286
  throw oauthStartApiError(staged);
288
287
  } finally {
289
288
  deadline.dispose();
@@ -1474,31 +1473,16 @@ function throwIfCallbackAborted(signal: AbortSignal, stage: OAuthCallbackStage):
1474
1473
  function logOAuthCallbackFailure(
1475
1474
  observability: Observability | undefined,
1476
1475
  error: OAuthCallbackStageError,
1477
- state: OAuthStatePayload | null,
1476
+ _state: OAuthStatePayload | null,
1478
1477
  ): void {
1479
- observability?.error("MCP OAuth callback failed", {
1480
- "opengeni.oauth.stage": error.stage,
1481
- "opengeni.oauth.reason": error.reason,
1482
- "opengeni.oauth.provider_domain": state?.providerDomain,
1483
- "opengeni.oauth.resource_host": state ? safeHost(state.resource) : undefined,
1484
- "opengeni.oauth.authorization_server": state?.authorizationServer,
1485
- "opengeni.oauth.issuer": state?.issuer,
1486
- "opengeni.oauth.client_registration_method": state?.clientRegistrationMethod,
1487
- error: sanitizedError(error.cause),
1488
- });
1478
+ observability?.error("MCP OAuth callback failed", oauthPublicErrorFields(error.cause));
1489
1479
  }
1490
1480
 
1491
1481
  function logOAuthStartFailure(
1492
1482
  observability: Observability | undefined,
1493
1483
  error: OAuthStartStageError,
1494
- providerDomain: string | undefined,
1495
1484
  ): void {
1496
- observability?.warn("MCP OAuth setup failed", {
1497
- "opengeni.oauth.stage": error.stage,
1498
- "opengeni.oauth.reason": error.reason,
1499
- "opengeni.oauth.provider_domain": providerDomain,
1500
- error: sanitizedError(error.cause),
1501
- });
1485
+ observability?.warn("MCP OAuth setup failed", oauthPublicErrorFields(error.cause));
1502
1486
  }
1503
1487
 
1504
1488
  function oauthStartFailureReason(error: unknown): string {
@@ -1581,51 +1565,47 @@ function oauthStartStageLabel(stage: OAuthStartStage): string {
1581
1565
  function logOAuthVerificationWarning(
1582
1566
  observability: Observability | undefined,
1583
1567
  error: OAuthCallbackStageError,
1584
- state: OAuthStatePayload,
1568
+ _state: OAuthStatePayload,
1585
1569
  ): void {
1586
- observability?.warn("MCP OAuth tools/list verification failed after token exchange", {
1587
- "opengeni.oauth.stage": error.stage,
1588
- "opengeni.oauth.reason": error.reason,
1589
- "opengeni.oauth.provider_domain": state.providerDomain,
1590
- "opengeni.oauth.resource_host": safeHost(state.resource),
1591
- "opengeni.oauth.mcp_host": safeHost(state.mcpUrl),
1592
- "opengeni.oauth.authorization_server": state.authorizationServer,
1593
- "opengeni.oauth.issuer": state.issuer,
1594
- "opengeni.oauth.client_registration_method": state.clientRegistrationMethod,
1595
- error: sanitizedError(error.cause),
1596
- });
1597
- }
1598
-
1599
- function sanitizedError(error: unknown): string {
1600
- if (error instanceof HTTPException) {
1601
- return `HTTPException ${error.status}: ${error.message}`;
1602
- }
1603
- if (error instanceof Error) {
1604
- return `${error.name}: ${error.message}`;
1605
- }
1606
- return String(error);
1570
+ observability?.warn(
1571
+ "MCP OAuth tools/list verification failed after token exchange",
1572
+ oauthPublicErrorFields(error.cause),
1573
+ );
1607
1574
  }
1608
1575
 
1609
- function errorMessage(error: unknown): string {
1610
- return error instanceof Error ? error.message : String(error);
1611
- }
1576
+ export type OAuthPublicErrorFields = {
1577
+ errorClass: "OAuthOperationError";
1578
+ errorCode: "oauth_operation_failed";
1579
+ status?: number;
1580
+ origin: "oauth";
1581
+ };
1612
1582
 
1613
- function safeHost(rawUrl: string): string | undefined {
1583
+ /** Allowlisted projection for public telemetry; canonical OAuth errors stay exact. */
1584
+ export function oauthPublicErrorFields(error: unknown): OAuthPublicErrorFields {
1585
+ const fields: OAuthPublicErrorFields = {
1586
+ errorClass: "OAuthOperationError",
1587
+ errorCode: "oauth_operation_failed",
1588
+ origin: "oauth",
1589
+ };
1614
1590
  try {
1615
- return new URL(rawUrl).host;
1591
+ const rawStatus =
1592
+ error instanceof HTTPException
1593
+ ? error.status
1594
+ : error && typeof error === "object"
1595
+ ? ((error as { status?: unknown; statusCode?: unknown }).status ??
1596
+ (error as { statusCode?: unknown }).statusCode)
1597
+ : undefined;
1598
+ const status = Number(rawStatus);
1599
+ if (Number.isInteger(status) && status >= 100 && status <= 599) fields.status = status;
1616
1600
  } catch {
1617
- return undefined;
1601
+ // Public telemetry is best-effort. A hostile getter/proxy must never
1602
+ // replace the exact OAuth failure with a projection failure.
1618
1603
  }
1604
+ return fields;
1619
1605
  }
1620
1606
 
1621
- function safeRequestedProviderDomain(payload: OAuthStartRequest): string | undefined {
1622
- try {
1623
- const resource = payload.mcpUrl ?? payload.resource;
1624
- if (!resource) return undefined;
1625
- return canonicalProviderDomain(payload.providerDomain ?? new URL(resource).hostname);
1626
- } catch {
1627
- return undefined;
1628
- }
1607
+ function errorMessage(error: unknown): string {
1608
+ return error instanceof Error ? error.message : String(error);
1629
1609
  }
1630
1610
 
1631
1611
  async function oauthErrorFromResponse(
@@ -120,6 +120,31 @@ export type NormalizedSlackInteraction = {
120
120
  text: string;
121
121
  };
122
122
 
123
+ export function slackInteractionRoutePolicy(
124
+ entry: Pick<
125
+ SlackInteractionInboxEntry,
126
+ "triggerKind" | "slackChannelId" | "slackThreadTs" | "slackMessageTs" | "slackUserId"
127
+ >,
128
+ ) {
129
+ const directMessageShortcut = isDirectMessageShortcut(entry);
130
+ const source = slackRouteKey(entry.slackChannelId, entry.slackThreadTs ?? entry.slackMessageTs);
131
+ return {
132
+ directMessageShortcut,
133
+ requiresChannelAccess: !directMessageShortcut,
134
+ visibility:
135
+ entry.triggerKind === "dm" || directMessageShortcut
136
+ ? ("private" as const)
137
+ : ("workspace" as const),
138
+ // A human-to-human DM may be shared by multiple linked workspace users. The
139
+ // signed shortcut authorizes only the invoking user, so the pre-ack route
140
+ // must keep each user's private reservation distinct until it is rekeyed to
141
+ // that user's OpenGeni bot-DM thread.
142
+ initialRouteKey: directMessageShortcut
143
+ ? `${source}:shortcut-user:${entry.slackUserId}`
144
+ : source,
145
+ };
146
+ }
147
+
123
148
  export function verifySlackRequestSignature(
124
149
  input: {
125
150
  timestamp: string | null;
@@ -566,7 +591,8 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
566
591
  await processSlackReactionInboxEntry(deps, entry);
567
592
  return;
568
593
  }
569
- const routeKey = slackRouteKey(entry.slackChannelId, entry.slackThreadTs ?? entry.slackMessageTs);
594
+ const routePolicy = slackInteractionRoutePolicy(entry);
595
+ const routeKey = routePolicy.initialRouteKey;
570
596
  const existing = await getSlackInteractionByRoute(
571
597
  deps.db,
572
598
  entry.workspaceId,
@@ -588,7 +614,9 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
588
614
  subjectId: link?.subjectId ?? "service:slack-interaction",
589
615
  ...(existing?.sessionId ? { sessionId: existing.sessionId } : {}),
590
616
  });
591
- await client.verifyChannelAccess(entry.slackChannelId);
617
+ if (routePolicy.requiresChannelAccess) {
618
+ await client.verifyChannelAccess(entry.slackChannelId);
619
+ }
592
620
  if (!link) {
593
621
  await client.postMessage({
594
622
  operationId: deterministicUuid(`slack-link:${entry.id}`),
@@ -604,6 +632,47 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
604
632
  throw new SlackInteractionPermanentError("identity_access_revoked");
605
633
  }
606
634
 
635
+ const alreadyDurable = await getSlackInteractionByClientEventId(
636
+ deps.db,
637
+ entry.workspaceId,
638
+ entry.connectionId,
639
+ `slack:${entry.providerEventId}`,
640
+ );
641
+ if (alreadyDurable) {
642
+ const { interaction, eventSessionId } = alreadyDurable;
643
+ if (interaction.visibility === "private" && interaction.owningSubjectId !== grant.subjectId) {
644
+ throw new SlackInteractionPermanentError("session_owner_mismatch");
645
+ }
646
+ if (interaction.sessionId !== null && interaction.sessionId !== eventSessionId) {
647
+ throw new SlackInteractionPermanentError("slack_interaction_event_conflict");
648
+ }
649
+ const boundInteraction =
650
+ interaction.sessionId !== null
651
+ ? interaction
652
+ : await bindSlackInteractionSession(deps.db, {
653
+ ...interaction,
654
+ owningSubjectId: grant.subjectId,
655
+ sessionId: eventSessionId,
656
+ });
657
+ if (!boundInteraction) {
658
+ throw new Error("Durable Slack interaction could not bind its reserved session");
659
+ }
660
+ const shouldRepairAcknowledgement =
661
+ interaction.triggeringProviderEventId === entry.providerEventId ||
662
+ (isDirectMessageShortcut(entry) && boundInteraction.ackSlackMessageTs === null);
663
+ if (shouldRepairAcknowledgement) {
664
+ const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
665
+ accountId: entry.accountId,
666
+ workspaceId: entry.workspaceId,
667
+ connectionId: entry.connectionId,
668
+ subjectId: grant.subjectId,
669
+ sessionId: eventSessionId,
670
+ });
671
+ await acknowledgeSlackSession(deps, boundClient, boundInteraction, entry);
672
+ }
673
+ return;
674
+ }
675
+
607
676
  if (existing?.sessionId) {
608
677
  await continueSlackSession(deps, grant, existing, entry);
609
678
  return;
@@ -621,7 +690,7 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
621
690
  routeKey,
622
691
  triggeringProviderEventId: entry.providerEventId,
623
692
  owningSubjectId: grant.subjectId,
624
- visibility: entry.triggerKind === "dm" ? "private" : "workspace",
693
+ visibility: routePolicy.visibility,
625
694
  });
626
695
  if (interaction.sessionId) {
627
696
  await continueSlackSession(deps, grant, interaction, entry);
@@ -647,10 +716,14 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
647
716
  if (error instanceof HTTPException) {
648
717
  await client.postMessage({
649
718
  operationId: deterministicUuid(`slack-admission-failed:${interaction.id}`),
650
- channelId: entry.slackChannelId,
651
- ...(entry.triggerKind === "slash_command"
652
- ? {}
653
- : { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
719
+ ...(isDirectMessageShortcut(entry)
720
+ ? { userId: entry.slackUserId }
721
+ : {
722
+ channelId: entry.slackChannelId,
723
+ ...(entry.triggerKind === "slash_command"
724
+ ? {}
725
+ : { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
726
+ }),
654
727
  text: slackAdmissionFailureText(error),
655
728
  });
656
729
  }
@@ -662,21 +735,50 @@ async function processSlackInboxEntry(deps: ApiRouteDeps, entry: SlackInteractio
662
735
  sessionId: session.id,
663
736
  });
664
737
  if (!bound) throw new Error("Slack route could not bind its durable session");
738
+ const boundClient = await createOpenGeniSlackBotInteractionClient(deps, {
739
+ accountId: entry.accountId,
740
+ workspaceId: entry.workspaceId,
741
+ connectionId: entry.connectionId,
742
+ subjectId: grant.subjectId,
743
+ sessionId: session.id,
744
+ });
745
+ await acknowledgeSlackSession(deps, boundClient, bound, entry);
746
+ }
747
+
748
+ async function acknowledgeSlackSession(
749
+ deps: ApiRouteDeps,
750
+ client: OpenGeniSlackBotClient,
751
+ interaction: SlackInteraction,
752
+ entry: SlackInteractionInboxEntry,
753
+ ) {
754
+ if (!interaction.sessionId) {
755
+ throw new Error("Slack acknowledgement requires a bound session");
756
+ }
757
+ const directMessageShortcut = isDirectMessageShortcut(entry);
665
758
  const ack = await client.postMessage({
666
759
  operationId: deterministicUuid(`slack-ack:${interaction.id}`),
667
- channelId: entry.slackChannelId,
668
- ...(entry.triggerKind === "slash_command"
669
- ? {}
670
- : { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
671
- text: `OpenGeni started this task. ${openSessionText(deps, entry.workspaceId, session.id)} Reply in this thread to continue, or reply \`stop\` to stop. Start a new top-level DM or invoke /opengeni again for a new session.`,
760
+ ...(directMessageShortcut
761
+ ? { userId: entry.slackUserId }
762
+ : {
763
+ channelId: entry.slackChannelId,
764
+ ...(entry.triggerKind === "slash_command"
765
+ ? {}
766
+ : { threadTimestamp: entry.slackThreadTs ?? entry.slackMessageTs }),
767
+ }),
768
+ text: directMessageShortcut
769
+ ? `OpenGeni started a private task from the selected DM message. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this bot-DM thread to continue, or reply \`stop\` to stop. The source DM was not opened to the bot or made workspace-visible.`
770
+ : `OpenGeni started this task. ${openSessionText(deps, entry.workspaceId, interaction.sessionId)} Reply in this thread to continue, or reply \`stop\` to stop. Start a new top-level DM or invoke /opengeni again for a new session.`,
672
771
  });
673
- if (entry.triggerKind === "slash_command") {
674
- await rekeySlackInteractionRoute(deps.db, {
772
+ if (entry.triggerKind === "slash_command" || directMessageShortcut) {
773
+ const rekeyed = await rekeySlackInteractionRoute(deps.db, {
675
774
  ...interaction,
676
- routeKey: slackRouteKey(entry.slackChannelId, ack.timestamp),
775
+ routeKey: slackRouteKey(ack.channelId, ack.timestamp),
776
+ slackChannelId: ack.channelId,
677
777
  slackThreadTs: ack.timestamp,
678
778
  ackSlackMessageTs: ack.timestamp,
779
+ repairUnacknowledgedPrivateShortcutDelivery: directMessageShortcut,
679
780
  });
781
+ if (!rekeyed) throw new Error("Slack acknowledgement could not rekey its durable route");
680
782
  }
681
783
  }
682
784
 
@@ -1407,6 +1509,12 @@ function slackRouteKey(channelId: string, threadTs: string) {
1407
1509
  return `${channelId}:${threadTs}`;
1408
1510
  }
1409
1511
 
1512
+ function isDirectMessageShortcut(
1513
+ entry: Pick<SlackInteractionInboxEntry, "triggerKind" | "slackChannelId">,
1514
+ ) {
1515
+ return entry.triggerKind === "message_shortcut" && entry.slackChannelId.startsWith("D");
1516
+ }
1517
+
1410
1518
  function deterministicUuid(value: string) {
1411
1519
  const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
1412
1520
  bytes[6] = (bytes[6]! & 0x0f) | 0x50;
@@ -9,6 +9,7 @@ import {
9
9
  import { createKnowledgeMemory, listKnowledgeMemories, type Database } from "@opengeni/db";
10
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import * as z from "zod/v4";
12
+ import { mcpMutationReceipt } from "./receipts";
12
13
 
13
14
  const SearchInputSchema = {
14
15
  query: z.string().min(1),
@@ -192,31 +193,47 @@ export function buildDocumentsMcpServer(
192
193
  metadata: z.record(z.string(), z.unknown()).optional(),
193
194
  },
194
195
  },
195
- async ({ text, kind, scope, sourceRefs, confidence, metadata }) => ({
196
- content: [
197
- {
198
- type: "text",
199
- text: JSON.stringify(
200
- await createKnowledgeMemory(db, {
201
- accountId,
202
- workspaceId,
203
- status: "proposed",
204
- kind: kind ?? "semantic",
205
- scope: scope ?? "workspace",
206
- text,
207
- sourceRefs:
208
- sourceRefs?.map((sourceRef) => ({
209
- ...sourceRef,
210
- metadata: sourceRef.metadata ?? {},
211
- })) ?? [],
212
- confidence: confidence ?? 0.5,
213
- metadata: metadata ?? {},
214
- createdBySessionId: options.createdBySessionId,
215
- }),
216
- ),
217
- },
218
- ],
219
- }),
196
+ async ({ text, kind, scope, sourceRefs, confidence, metadata }) => {
197
+ const memory = await createKnowledgeMemory(db, {
198
+ accountId,
199
+ workspaceId,
200
+ status: "proposed",
201
+ kind: kind ?? "semantic",
202
+ scope: scope ?? "workspace",
203
+ text,
204
+ sourceRefs:
205
+ sourceRefs?.map((sourceRef) => ({
206
+ ...sourceRef,
207
+ metadata: sourceRef.metadata ?? {},
208
+ })) ?? [],
209
+ confidence: confidence ?? 0.5,
210
+ metadata: metadata ?? {},
211
+ createdBySessionId: options.createdBySessionId,
212
+ });
213
+ return {
214
+ content: [
215
+ {
216
+ type: "text",
217
+ text: JSON.stringify(
218
+ mcpMutationReceipt({
219
+ operation: "memory_propose",
220
+ committed: true,
221
+ outcome: "created",
222
+ changed: true,
223
+ resource: {
224
+ type: "knowledge_memory",
225
+ id: memory.id,
226
+ state: memory.status,
227
+ },
228
+ timestamp: memory.updatedAt,
229
+ idempotency: { status: "not_supported" },
230
+ nextAction: { tool: "memory_search", arguments: {} },
231
+ }),
232
+ ),
233
+ },
234
+ ],
235
+ };
236
+ },
220
237
  );
221
238
 
222
239
  return server;