@opengeni/api-router 0.22.2 → 0.26.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 (124) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +0 -30
  4. package/dist/browser-controller-authority.d.ts +43 -0
  5. package/dist/browser-state-authority.d.ts +27 -0
  6. package/dist/{chunk-HWXJW5C7.js → chunk-JIKNR5YL.js} +27993 -14546
  7. package/dist/chunk-JIKNR5YL.js.map +1 -0
  8. package/dist/codemode.d.ts +23 -0
  9. package/dist/editable-artifact-live-hints.d.ts +11 -0
  10. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  11. package/dist/editable-artifact-office-import.d.ts +22 -0
  12. package/dist/editable-artifact-production.d.ts +29 -0
  13. package/dist/editable-artifact-websocket.d.ts +49 -0
  14. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  15. package/dist/github-browser-flow.d.ts +6 -0
  16. package/dist/http/cors.d.ts +1 -0
  17. package/dist/http/sse.d.ts +2 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.js +1824 -30
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/api-integrations.d.ts +24 -0
  22. package/dist/integrations/atlassian.d.ts +176 -0
  23. package/dist/integrations/github-skill-source.d.ts +5 -0
  24. package/dist/integrations/google-drive.d.ts +85 -0
  25. package/dist/integrations/oauth-client.d.ts +30 -1
  26. package/dist/integrations/provider-oauth.d.ts +19 -0
  27. package/dist/integrations/slack-bot.d.ts +4 -0
  28. package/dist/integrations/slack-interactions.d.ts +14 -2
  29. package/dist/integrations/social-api.d.ts +2 -1
  30. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  31. package/dist/mcp/editable-artifacts.d.ts +13 -0
  32. package/dist/mcp/receipts.d.ts +28 -0
  33. package/dist/mcp/scheduled-task-view.d.ts +518 -0
  34. package/dist/mcp/server.d.ts +14 -3
  35. package/dist/memory-slack-delivery.d.ts +9 -0
  36. package/dist/routes/api-integrations.d.ts +8 -0
  37. package/dist/routes/browser-identities.d.ts +5 -0
  38. package/dist/routes/browser-sessions.d.ts +6 -0
  39. package/dist/routes/company-profile.d.ts +3 -0
  40. package/dist/routes/computer-sessions.d.ts +6 -0
  41. package/dist/routes/editable-artifacts.d.ts +44 -0
  42. package/dist/routes/integration-features.d.ts +3 -0
  43. package/dist/routes/memory-slack-publications.d.ts +6 -0
  44. package/dist/routes/plugins.d.ts +8 -0
  45. package/dist/routes/sessions.d.ts +17 -2
  46. package/dist/routes/skills.d.ts +6 -0
  47. package/dist/routes/video-generation.d.ts +3 -0
  48. package/dist/sandbox/auth-callout.d.ts +2 -0
  49. package/dist/sandbox/channel-a.d.ts +59 -2
  50. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  51. package/dist/sandbox/viewer.d.ts +4 -2
  52. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  53. package/package.json +19 -14
  54. package/src/app.ts +277 -41
  55. package/src/auth/managed-auth.ts +0 -16
  56. package/src/browser-controller-authority.ts +137 -0
  57. package/src/browser-state-authority.ts +236 -0
  58. package/src/codemode.ts +186 -0
  59. package/src/editable-artifact-live-hints.ts +64 -0
  60. package/src/editable-artifact-native-kernel.ts +659 -0
  61. package/src/editable-artifact-office-import.ts +230 -0
  62. package/src/editable-artifact-production.ts +419 -0
  63. package/src/editable-artifact-websocket.ts +311 -0
  64. package/src/editable-artifact-workspace-files.ts +186 -0
  65. package/src/github-browser-flow.ts +35 -6
  66. package/src/http/auth.ts +2 -0
  67. package/src/http/cors.ts +3 -0
  68. package/src/http/sse.ts +101 -6
  69. package/src/index.ts +147 -23
  70. package/src/integrations/api-integrations.ts +350 -0
  71. package/src/integrations/atlassian.ts +1621 -0
  72. package/src/integrations/github-skill-source.ts +142 -0
  73. package/src/integrations/google-drive.ts +1000 -64
  74. package/src/integrations/oauth-client.ts +159 -89
  75. package/src/integrations/provider-oauth.ts +777 -0
  76. package/src/integrations/slack-bot.ts +31 -2
  77. package/src/integrations/slack-interactions.ts +610 -42
  78. package/src/integrations/social-api.ts +11 -0
  79. package/src/mcp/documents.ts +74 -26
  80. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  81. package/src/mcp/editable-artifacts.ts +448 -0
  82. package/src/mcp/receipts.ts +95 -0
  83. package/src/mcp/scheduled-task-view.ts +642 -0
  84. package/src/mcp/server.ts +1718 -310
  85. package/src/memory-slack-delivery.ts +209 -0
  86. package/src/observability.ts +3 -3
  87. package/src/routes/api-integrations.ts +407 -0
  88. package/src/routes/api-keys.ts +7 -1
  89. package/src/routes/browser-identities.ts +136 -0
  90. package/src/routes/browser-sessions.ts +2543 -0
  91. package/src/routes/codex.ts +7 -4
  92. package/src/routes/company-profile.ts +255 -0
  93. package/src/routes/computer-sessions.ts +1247 -0
  94. package/src/routes/connections.ts +358 -102
  95. package/src/routes/documents.ts +22 -3
  96. package/src/routes/editable-artifacts.ts +1159 -0
  97. package/src/routes/enrollments.ts +54 -12
  98. package/src/routes/environments.ts +60 -11
  99. package/src/routes/files.ts +277 -65
  100. package/src/routes/github.ts +18 -2
  101. package/src/routes/install.ts +38 -2
  102. package/src/routes/integration-features.ts +258 -0
  103. package/src/routes/machines.ts +1 -1
  104. package/src/routes/memory-slack-publications.ts +216 -0
  105. package/src/routes/packs.ts +437 -7
  106. package/src/routes/plugins.ts +751 -0
  107. package/src/routes/rigs.ts +77 -20
  108. package/src/routes/scheduled-tasks.ts +94 -42
  109. package/src/routes/sessions.ts +475 -234
  110. package/src/routes/skills.ts +174 -0
  111. package/src/routes/transcription-recordings.ts +65 -33
  112. package/src/routes/video-generation.ts +132 -0
  113. package/src/routes/workspaces.ts +46 -24
  114. package/src/sandbox/auth-callout.ts +16 -4
  115. package/src/sandbox/channel-a.ts +809 -85
  116. package/src/sandbox/enrollment.ts +13 -3
  117. package/src/sandbox/machines.ts +1 -1
  118. package/src/sandbox/metrics-ingestion.ts +121 -3
  119. package/src/sandbox/rematerialize.ts +35 -47
  120. package/src/sandbox/viewer.ts +58 -29
  121. package/src/temporal-schedule-cleanup.ts +135 -0
  122. package/dist/chunk-HWXJW5C7.js.map +0 -1
  123. package/dist/mcp/toolspace.d.ts +0 -62
  124. package/src/mcp/toolspace.ts +0 -1186
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
@@ -21,6 +21,8 @@ import {
21
21
  } from "@opengeni/db";
22
22
  import { createNatsEventBus, type ResponderConnection } from "@opengeni/events";
23
23
  import { createObservability, logStartupDependencyRetry } from "@opengeni/observability";
24
+ import { createObjectStorage } from "@opengeni/storage";
25
+ import { isArtifactRuntimeConfigured } from "@opengeni/artifact-tool/runtime/development";
24
26
  import { SESSION_WORKFLOW_WAKE_DISPATCHER_SCHEDULE_ID } from "@opengeni/core";
25
27
  import {
26
28
  Connection,
@@ -35,6 +37,17 @@ import { observabilityEventLogger } from "./observability";
35
37
  import { startAuthCalloutResponder } from "./sandbox/auth-callout";
36
38
  import { startHelloIngestion, startMetricsIngestion } from "./sandbox/metrics-ingestion";
37
39
  import { startSlackInteractionPump } from "./integrations/slack-interactions";
40
+ import { startMemorySlackPublicationPump } from "./memory-slack-delivery";
41
+ import { startTemporalScheduleCleanupPump } from "./temporal-schedule-cleanup";
42
+ import {
43
+ EDITABLE_ARTIFACT_LIVE_WEBSOCKET_MAX_MESSAGE_BYTES,
44
+ EditableArtifactWebSocketTransport,
45
+ type EditableArtifactWebSocketConnection,
46
+ } from "./editable-artifact-websocket";
47
+ import {
48
+ createStandaloneEditableArtifactApplication,
49
+ type StandaloneEditableArtifactApplication,
50
+ } from "./editable-artifact-production";
38
51
 
39
52
  /**
40
53
  * A REJECT_DUPLICATE start collides on the deterministic workflowId when the
@@ -156,6 +169,14 @@ export async function createTemporalWorkflowClient(
156
169
  },
157
170
  syncScheduledTask: async ({ task }) => {
158
171
  const schedule = temporal.schedule.getHandle(task.temporalScheduleId);
172
+ if (task.schedule.type === "manual") {
173
+ try {
174
+ await schedule.delete();
175
+ } catch (error) {
176
+ if (!(error instanceof ScheduleNotFoundError)) throw error;
177
+ }
178
+ return;
179
+ }
159
180
  const options = temporalScheduleOptions(task, settings.temporalTaskQueue);
160
181
  try {
161
182
  await schedule.update(() => temporalScheduleUpdateOptions(options));
@@ -167,16 +188,21 @@ export async function createTemporalWorkflowClient(
167
188
  }
168
189
  },
169
190
  deleteScheduledTaskSchedule: async ({ temporalScheduleId }) => {
170
- await temporal.schedule
171
- .getHandle(temporalScheduleId)
172
- .delete()
173
- .catch(() => undefined);
191
+ try {
192
+ await temporal.withDeadline(Date.now() + 5_000, async () => {
193
+ await temporal.schedule.getHandle(temporalScheduleId).delete();
194
+ });
195
+ } catch (error) {
196
+ if (error instanceof ScheduleNotFoundError) return;
197
+ throw error;
198
+ }
174
199
  },
175
200
  triggerScheduledTask: async ({
176
201
  task,
177
202
  agentRunUsageIdempotencyKey,
178
203
  triggerWorkflowId,
179
204
  initiator,
205
+ triggerType = "manual",
180
206
  }) => {
181
207
  // Deterministic workflowId (derived from the trigger token by the
182
208
  // caller) + REJECT_DUPLICATE makes a retried manual trigger idempotent:
@@ -193,7 +219,7 @@ export async function createTemporalWorkflowClient(
193
219
  accountId: task.accountId,
194
220
  workspaceId: task.workspaceId,
195
221
  taskId: task.id,
196
- triggerType: "manual",
222
+ triggerType,
197
223
  agentRunUsageIdempotencyKey,
198
224
  initiator,
199
225
  },
@@ -213,18 +239,25 @@ export async function createTemporalWorkflowClient(
213
239
  if (!targetId) {
214
240
  throw new Error("rig verification requires changeId or versionId");
215
241
  }
216
- await temporal.workflow.start("rigVerificationWorkflow", {
217
- taskQueue: settings.temporalTaskQueue,
218
- workflowId: workflowId ?? `rig-verification-${targetId}-${crypto.randomUUID()}`,
219
- workflowIdReusePolicy: "ALLOW_DUPLICATE",
220
- args: [
221
- {
222
- workspaceId,
223
- ...(changeId ? { changeId } : {}),
224
- ...(versionId ? { versionId } : {}),
225
- },
226
- ],
227
- });
242
+ try {
243
+ await temporal.workflow.start("rigVerificationWorkflow", {
244
+ taskQueue: settings.temporalTaskQueue,
245
+ workflowId: workflowId ?? `rig-verification-${targetId}-${crypto.randomUUID()}`,
246
+ workflowIdReusePolicy: "REJECT_DUPLICATE",
247
+ args: [
248
+ {
249
+ workspaceId,
250
+ ...(changeId ? { changeId } : {}),
251
+ ...(versionId ? { versionId } : {}),
252
+ },
253
+ ],
254
+ });
255
+ } catch (error) {
256
+ // A lost start acknowledgement is indistinguishable from a retry. The
257
+ // caller-owned workflow id makes both cases the same successful start.
258
+ if (isWorkflowAlreadyStarted(error)) return;
259
+ throw error;
260
+ }
228
261
  },
229
262
  check: async () => {
230
263
  await connection.workflowService.getSystemInfo({});
@@ -312,26 +345,77 @@ export async function startApi() {
312
345
  await dbClient.close();
313
346
  throw new Error("OpenGeni API startup dependencies were not initialized");
314
347
  }
348
+ const objectStorage = createObjectStorage(settings);
349
+ let editableArtifactComposition: StandaloneEditableArtifactApplication | undefined;
350
+ if (objectStorage && isArtifactRuntimeConfigured()) {
351
+ try {
352
+ editableArtifactComposition = await createStandaloneEditableArtifactApplication({
353
+ db: dbClient.db,
354
+ bus,
355
+ objectStorage,
356
+ });
357
+ } catch (error) {
358
+ await Promise.allSettled([bus.close(), workflowClient.close(), dbClient.close()]);
359
+ throw error;
360
+ }
361
+ }
315
362
  const { app, routeDeps } = createAppComposition({
316
363
  settings,
317
364
  db: dbClient.db,
318
365
  bus,
319
366
  workflowClient: workflowClient.client,
320
367
  documentIndexer: workflowClient.documentIndexer,
368
+ objectStorage,
369
+ ...(editableArtifactComposition
370
+ ? {
371
+ editableArtifacts: editableArtifactComposition.application,
372
+ editableArtifactExports: editableArtifactComposition.durableExports,
373
+ editableArtifactAgent: editableArtifactComposition.agent,
374
+ editableArtifactOfficeImports: editableArtifactComposition.officeImports,
375
+ }
376
+ : {}),
321
377
  observability,
322
378
  readinessChecks: {
323
379
  db: runtimeDatabaseReadyCheck(dbClient.db, databasePosture),
324
380
  },
325
381
  });
326
- const server = Bun.serve({
382
+ if (!routeDeps.editableArtifacts) {
383
+ observability.warn("editable artifact engine is not composed", {
384
+ subsystem: "editable_artifacts",
385
+ behavior: "http_and_websocket_fail_closed",
386
+ });
387
+ }
388
+ const artifactWebSockets = new EditableArtifactWebSocketTransport(routeDeps.editableArtifacts);
389
+ const server = Bun.serve<EditableArtifactWebSocketConnection>({
327
390
  hostname: settings.apiHost,
328
391
  port: settings.apiPort,
329
392
  idleTimeout: 255,
330
- fetch: app.fetch,
393
+ fetch: (request, bunServer) => {
394
+ if (artifactWebSockets.handles(request)) {
395
+ return artifactWebSockets.upgrade(request, bunServer);
396
+ }
397
+ return app.fetch(request);
398
+ },
399
+ websocket: {
400
+ maxPayloadLength: EDITABLE_ARTIFACT_LIVE_WEBSOCKET_MAX_MESSAGE_BYTES,
401
+ backpressureLimit: 16 * 1024 * 1024,
402
+ closeOnBackpressureLimit: true,
403
+ open: (socket) => artifactWebSockets.websocket.open(socket),
404
+ message: (socket, message) => artifactWebSockets.websocket.message(socket, message),
405
+ close: (socket) => artifactWebSockets.websocket.close(socket),
406
+ },
331
407
  });
332
408
  const stopSlackInteractionPump = settings.slackSigningSecret
333
409
  ? startSlackInteractionPump(routeDeps)
334
410
  : undefined;
411
+ const stopMemorySlackPublicationPump = startMemorySlackPublicationPump(routeDeps);
412
+ const stopTemporalScheduleCleanupPump = startTemporalScheduleCleanupPump({
413
+ db: dbClient.db,
414
+ deleteSchedule: async (temporalScheduleId) => {
415
+ await workflowClient.client.deleteScheduledTaskSchedule({ temporalScheduleId });
416
+ },
417
+ observability,
418
+ });
335
419
  // M10 — start the metrics-ingestion consumer (agent heartbeats → DB last-sample
336
420
  // + downsampled series), gated on the selfhosted flag. A no-op when disabled.
337
421
  let stopMetricsIngestion: (() => void) | undefined;
@@ -366,11 +450,13 @@ export async function startApi() {
366
450
  { db: dbClient.db, settings, callout, observability },
367
451
  settings.natsUrl,
368
452
  );
369
- } catch (error) {
453
+ } catch {
370
454
  // A responder start failure must not crash the API (other planes work); log
371
455
  // loudly — selfhosted agents will fail to connect until it is up.
372
456
  observability.error("OpenGeni NATS auth-callout responder failed to start", {
373
- error: error instanceof Error ? error.message : String(error),
457
+ errorClass: "NatsAuthCalloutOperationError",
458
+ errorCode: "nats_auth_callout_start_failed",
459
+ origin: "api",
374
460
  });
375
461
  }
376
462
  } else {
@@ -389,9 +475,12 @@ export async function startApi() {
389
475
  close: async () => {
390
476
  server.stop(true);
391
477
  stopSlackInteractionPump?.();
478
+ await stopMemorySlackPublicationPump();
392
479
  stopMetricsIngestion?.();
393
480
  stopHelloIngestion?.();
481
+ await stopTemporalScheduleCleanupPump();
394
482
  await Promise.allSettled([
483
+ Promise.resolve(editableArtifactComposition?.close()),
395
484
  authCalloutResponder?.close(),
396
485
  bus.close(),
397
486
  workflowClient.close(),
@@ -420,9 +509,12 @@ export function shouldCreateScheduleAfterUpdateError(error: unknown): boolean {
420
509
  }
421
510
 
422
511
  export function temporalScheduleSpec(schedule: ScheduledTaskScheduleSpec): ScheduleSpec {
512
+ if (schedule.type === "manual") {
513
+ throw new Error("manual scheduled tasks do not have a Temporal Schedule spec");
514
+ }
423
515
  if (schedule.type === "interval") {
424
516
  return {
425
- intervals: [{ every: `${schedule.everySeconds}s` }],
517
+ intervals: [temporalIntervalSpec(schedule)],
426
518
  ...(schedule.startAt ? { startAt: new Date(schedule.startAt) } : {}),
427
519
  ...(schedule.endAt ? { endAt: new Date(schedule.endAt) } : {}),
428
520
  };
@@ -456,6 +548,29 @@ export function temporalScheduleSpec(schedule: ScheduledTaskScheduleSpec): Sched
456
548
  };
457
549
  }
458
550
 
551
+ function temporalIntervalSpec(
552
+ schedule: Extract<ScheduledTaskScheduleSpec, { type: "interval" }>,
553
+ ): NonNullable<ScheduleSpec["intervals"]>[number] {
554
+ const every = `${schedule.everySeconds}s` as `${number}s`;
555
+ if (!schedule.startAt) {
556
+ return { every };
557
+ }
558
+
559
+ // Temporal interval schedules match Epoch + (n * every) + offset. Its
560
+ // top-level startAt only filters matching times before that boundary, so it
561
+ // does not itself anchor the cadence. Derive the phase from startAt to make
562
+ // the stored OpenGeni timestamp the first interval boundary rather than the
563
+ // next epoch-aligned match.
564
+ const everyMilliseconds = BigInt(schedule.everySeconds) * 1_000n;
565
+ const startMilliseconds = BigInt(new Date(schedule.startAt).getTime());
566
+ const offsetMilliseconds =
567
+ ((startMilliseconds % everyMilliseconds) + everyMilliseconds) % everyMilliseconds;
568
+ return {
569
+ every,
570
+ ...(offsetMilliseconds === 0n ? {} : { offset: `${offsetMilliseconds}ms` as `${number}ms` }),
571
+ };
572
+ }
573
+
459
574
  function temporalMonth(monthIndex: number) {
460
575
  return TEMPORAL_MONTHS[monthIndex]!;
461
576
  }
@@ -483,7 +598,7 @@ function temporalScheduleOptions(task: ScheduledTask, taskQueue: string): Schedu
483
598
  pauseOnFailure: false,
484
599
  },
485
600
  state: {
486
- paused: task.status === "paused",
601
+ paused: scheduledTaskEffectivelyPaused(task),
487
602
  ...(task.schedule.type === "once" ? { remainingActions: 1 } : {}),
488
603
  },
489
604
  memo: {
@@ -495,6 +610,15 @@ function temporalScheduleOptions(task: ScheduledTask, taskQueue: string): Schedu
495
610
  };
496
611
  }
497
612
 
613
+ function scheduledTaskEffectivelyPaused(task: ScheduledTask): boolean {
614
+ if (task.status === "paused") return true;
615
+ if (task.action.kind !== "knowledge_source_sync") return false;
616
+ const control = task.metadata.knowledgeSourceSync;
617
+ if (!control || typeof control !== "object") return false;
618
+ const record = control as Record<string, unknown>;
619
+ return record.sourceEnabled === false || record.connectionPaused === true;
620
+ }
621
+
498
622
  function temporalScheduleUpdateOptions(options: ScheduleOptions): ScheduleUpdateOptions {
499
623
  return {
500
624
  spec: options.spec,