@opengeni/api-router 2.5.0 → 2.6.4-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/app.d.ts +2 -1
  2. package/dist/app.js +3 -1
  3. package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
  4. package/dist/auth/managed-auth.d.ts +24 -1
  5. package/dist/{chunk-QESX7HDK.js → chunk-UOI7KAP3.js} +3240 -791
  6. package/dist/chunk-UOI7KAP3.js.map +1 -0
  7. package/dist/http/sse.d.ts +9 -0
  8. package/dist/index.js +117 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/interaction-metrics.d.ts +2 -0
  11. package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
  12. package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
  13. package/dist/mcp/remember.d.ts +2 -2
  14. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  15. package/dist/mcp/server.d.ts +1 -0
  16. package/dist/mcp/session-view.d.ts +1 -0
  17. package/dist/mcp/session-wait.d.ts +19 -0
  18. package/dist/model-catalog.d.ts +5 -31
  19. package/dist/routes/api-keys.d.ts +2 -0
  20. package/dist/routes/browser-sessions.d.ts +1 -0
  21. package/dist/routes/computer-sessions.d.ts +12 -0
  22. package/dist/routes/managed-auth-session-sets.d.ts +7 -0
  23. package/dist/routes/workspaces.d.ts +1 -1
  24. package/dist/sandbox/metrics-ingestion.d.ts +16 -0
  25. package/dist/workspace-delete-observability.d.ts +10 -0
  26. package/package.json +19 -19
  27. package/src/app.ts +206 -29
  28. package/src/auth/managed-auth-attempt-context.ts +40 -3
  29. package/src/auth/managed-auth-session-adapter.ts +1 -0
  30. package/src/auth/managed-auth.ts +164 -4
  31. package/src/http/sse.ts +279 -45
  32. package/src/index.ts +13 -1
  33. package/src/integrations/oauth-client.ts +8 -1
  34. package/src/integrations/provider-oauth.ts +12 -2
  35. package/src/integrations/slack-interactions.ts +77 -37
  36. package/src/interaction-metrics.ts +30 -0
  37. package/src/mcp/company-brain-governed-writes.ts +38 -23
  38. package/src/mcp/company-profile-agent-admin.ts +7 -7
  39. package/src/mcp/remember.ts +19 -8
  40. package/src/mcp/server.ts +328 -27
  41. package/src/mcp/session-wait.ts +56 -8
  42. package/src/model-catalog.ts +45 -337
  43. package/src/routes/api-integrations.ts +2 -2
  44. package/src/routes/api-keys.ts +149 -7
  45. package/src/routes/automations.ts +186 -19
  46. package/src/routes/browser-sessions.ts +10 -2
  47. package/src/routes/capabilities.ts +3 -3
  48. package/src/routes/codex.ts +483 -74
  49. package/src/routes/company-profile.ts +64 -0
  50. package/src/routes/computer-sessions.ts +103 -1
  51. package/src/routes/connections.ts +271 -16
  52. package/src/routes/github.ts +0 -8
  53. package/src/routes/integration-facets.ts +8 -5
  54. package/src/routes/interaction-resources.ts +7 -1
  55. package/src/routes/managed-auth-session-sets.ts +199 -2
  56. package/src/routes/organization-memberships.ts +28 -8
  57. package/src/routes/packs.ts +5 -5
  58. package/src/routes/plugins.ts +2 -2
  59. package/src/routes/pr-review.ts +80 -4
  60. package/src/routes/scheduled-tasks.ts +47 -11
  61. package/src/routes/sessions.ts +75 -53
  62. package/src/routes/skills.ts +3 -3
  63. package/src/routes/supergrok.ts +3 -2
  64. package/src/routes/workspaces.ts +665 -56
  65. package/src/sandbox/channel-a.ts +10 -4
  66. package/src/sandbox/machines.ts +13 -6
  67. package/src/sandbox/metrics-ingestion.ts +157 -3
  68. package/src/sandbox/viewer.ts +20 -1
  69. package/src/workspace-delete-observability.ts +75 -0
  70. package/dist/chunk-QESX7HDK.js.map +0 -1
@@ -86,6 +86,8 @@ import {
86
86
  type RoutingSandboxSession,
87
87
  } from "@opengeni/runtime/sandbox";
88
88
  import {
89
+ managedSessionGroupBackend,
90
+ managedSessionGroupOs,
89
91
  providerSettingsForSessionSandboxRuntime,
90
92
  relayConfigFromSettings,
91
93
  resolveSessionSandboxRuntime,
@@ -643,15 +645,19 @@ async function withChannelAOperation<T>(
643
645
  // A machine-home session with no selected machine uses the deployment's
644
646
  // managed group box, exactly like the worker turn path. Keep the durable
645
647
  // home label honest; only this request's effective backend changes.
646
- if (settings.sandboxBackend !== "none" && settings.sandboxBackend !== "selfhosted") {
648
+ const groupBackend = managedSessionGroupBackend(
649
+ settings.sandboxBackend,
650
+ session.sandboxBackend,
651
+ );
652
+ if (groupBackend) {
647
653
  return await withChannelAOperation(
648
654
  services,
649
655
  {
650
656
  ...ctx,
651
657
  session: {
652
658
  ...session,
653
- sandboxBackend: settings.sandboxBackend,
654
- sandboxOs: "linux",
659
+ sandboxBackend: groupBackend,
660
+ sandboxOs: managedSessionGroupOs(session.sandboxBackend, session.sandboxOs),
655
661
  },
656
662
  },
657
663
  readOnly,
@@ -659,7 +665,7 @@ async function withChannelAOperation<T>(
659
665
  );
660
666
  }
661
667
  throw new HTTPException(409, {
662
- message: "machine-home session has no active Connected Machine",
668
+ message: "machine-home session has no active Connected Machine or managed sandbox",
663
669
  });
664
670
  }
665
671
  const sandbox = await getSandbox(
@@ -9,8 +9,9 @@
9
9
  // heartbeat), projected to the contract's MetricSample;
10
10
  // * sharedSessionCount — the lease refcount (how many sessions share this one
11
11
  // whole machine, the maxSandboxes:1 disclosure).
12
- // PLUS, when a session context is supplied, the session's synthetic Modal group
13
- // box (isSessionGroup:true) + the active-sandbox pointer (activeSandboxId/Epoch).
12
+ // PLUS, when a session context is supplied, the session's synthetic managed
13
+ // group box (isSessionGroup:true) + the active-sandbox pointer
14
+ // (activeSandboxId/Epoch).
14
15
  //
15
16
  // This is workspace-scoped (perm enrollments:read) and flag-gated upstream
16
17
  // (sandboxSelfhostedEnabled). It deliberately does NOT depend on a FleetContext
@@ -32,6 +33,7 @@ import {
32
33
  type MachineMetricsRow,
33
34
  } from "@opengeni/db";
34
35
  import { MachineView, MetricSample, type MachinesResponse } from "@opengeni/contracts";
36
+ import { managedSessionGroupBackend } from "@opengeni/core";
35
37
  import { selfhostedHeartbeatLiveness } from "@opengeni/runtime/sandbox";
36
38
 
37
39
  export type MachinesServices = {
@@ -244,10 +246,15 @@ export async function listMachines(
244
246
  }
245
247
 
246
248
  const machines: MachineView[] = [];
249
+ const groupBackend = session
250
+ ? managedSessionGroupBackend(services.settings.sandboxBackend, session.sandboxBackend)
251
+ : null;
247
252
 
248
- // The session's own group box (synthetic): the default/home sandbox a null
249
- // active pointer routes to. A backend:none session has no home box.
250
- if (session && session.sandboxBackend !== "none") {
253
+ // The session's own managed group box (synthetic): the sandbox a null active
254
+ // pointer routes to. A machine-home session uses the deployment's managed
255
+ // backend only after an explicit switch; none/selfhosted-only deployments do
256
+ // not invent a group they cannot establish.
257
+ if (session && groupBackend) {
251
258
  const groupActive = activeSandboxId === null;
252
259
  const groupLease = await readLease(db, workspaceId, session.sandboxGroupId);
253
260
  machines.push(
@@ -255,7 +262,7 @@ export async function listMachines(
255
262
  sandboxId: session.sandboxGroupId,
256
263
  enrollmentId: null,
257
264
  name: "session sandbox",
258
- kind: session.sandboxBackend === "selfhosted" ? "selfhosted" : "modal",
265
+ kind: groupBackend === "opensandbox" ? "opensandbox" : "modal",
259
266
  state: "online",
260
267
  active: groupActive,
261
268
  isSessionGroup: true,
@@ -28,7 +28,11 @@
28
28
  // Both consumers are BEST-EFFORT and fail-soft: a decode/DB error for one message
29
29
  // is logged + swallowed (the bus subscription already swallows handler throws) so
30
30
  // a metrics blip / a display-refresh write failure never tears down the consumer,
31
- // back-pressures the agent, or breaks its connect.
31
+ // back-pressures the agent, or breaks its connect. Event ingestion drains the NATS
32
+ // subscription immediately into exact-subject queues: different runner connections
33
+ // progress concurrently, one runner's ordering is preserved, and consecutive queued
34
+ // heartbeats collapse latest-wins. A delayed heartbeat backlog therefore cannot keep
35
+ // renewing a dead runner's short connection lease one stale message at a time.
32
36
 
33
37
  import {
34
38
  clearEnrollmentWentOffline,
@@ -74,6 +78,141 @@ export const AGENT_EVENTS_SUBJECT = "agent.*.*.connection.*.events";
74
78
  /** The wildcard subject the agent publishes its connect Hello on. */
75
79
  export const AGENT_HELLO_SUBJECT = "agent.*.*.connection.*.hello";
76
80
 
81
+ type QueuedAgentEvent = {
82
+ payload: Uint8Array;
83
+ subject: string;
84
+ heartbeat: boolean;
85
+ };
86
+
87
+ type AgentEventQueueState = {
88
+ scheduled: boolean;
89
+ pending: QueuedAgentEvent[];
90
+ };
91
+
92
+ type ScheduledAgentEventQueue = {
93
+ subject: string;
94
+ state: AgentEventQueueState;
95
+ };
96
+
97
+ export type AgentEventIngestionScheduler = {
98
+ enqueue: (payload: Uint8Array, subject: string) => void;
99
+ whenIdle: () => Promise<void>;
100
+ close: () => void;
101
+ };
102
+
103
+ function payloadIsHeartbeat(payload: Uint8Array): boolean {
104
+ try {
105
+ return AgentEvent.decode(payload).event?.$case === "heartbeat";
106
+ } catch {
107
+ // Preserve the normal decoder/logging path for malformed events. They are
108
+ // barriers rather than heartbeat-coalescing candidates.
109
+ return false;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Drain agent events without making every Connected Machine wait behind one
115
+ * deployment-wide serial DB queue. Each exact process subject retains FIFO order;
116
+ * separate subjects drain concurrently. Consecutive pending heartbeats for one
117
+ * subject are latest-wins because they are point-in-time liveness/metrics samples,
118
+ * while GoingOffline and update-progress events remain ordered barriers.
119
+ */
120
+ export function createAgentEventIngestionScheduler(
121
+ handler: (payload: Uint8Array, subject: string) => void | Promise<void>,
122
+ options: { maxConcurrentSubjects?: number; onHeartbeatCoalesced?: () => void } = {},
123
+ ): AgentEventIngestionScheduler {
124
+ const maxConcurrentSubjects = options.maxConcurrentSubjects ?? 32;
125
+ if (!Number.isSafeInteger(maxConcurrentSubjects) || maxConcurrentSubjects <= 0) {
126
+ throw new RangeError("maxConcurrentSubjects must be a positive safe integer");
127
+ }
128
+
129
+ const states = new Map<string, AgentEventQueueState>();
130
+ const readySubjects: ScheduledAgentEventQueue[] = [];
131
+ const idleWaiters = new Set<() => void>();
132
+ let accepting = true;
133
+ let activeSubjects = 0;
134
+
135
+ const resolveIdleWaiters = (): void => {
136
+ if (states.size !== 0) return;
137
+ for (const resolve of idleWaiters) resolve();
138
+ idleWaiters.clear();
139
+ };
140
+
141
+ async function drain(subject: string, state: AgentEventQueueState): Promise<void> {
142
+ while (true) {
143
+ const event = state.pending.shift();
144
+ if (!event) break;
145
+ try {
146
+ await handler(event.payload, event.subject);
147
+ } catch {
148
+ // Agent-event ingestion is best-effort. One bad event must not strand the
149
+ // exact-subject queue or prevent later heartbeats from restoring liveness.
150
+ }
151
+ }
152
+
153
+ activeSubjects -= 1;
154
+ state.scheduled = false;
155
+ if (state.pending.length > 0) {
156
+ schedule(subject, state);
157
+ } else {
158
+ if (states.get(subject) === state) states.delete(subject);
159
+ resolveIdleWaiters();
160
+ }
161
+ pump();
162
+ }
163
+
164
+ function pump(): void {
165
+ while (activeSubjects < maxConcurrentSubjects) {
166
+ const next = readySubjects.shift();
167
+ if (!next) return;
168
+ if (states.get(next.subject) !== next.state || !next.state.scheduled) continue;
169
+ activeSubjects += 1;
170
+ void drain(next.subject, next.state);
171
+ }
172
+ }
173
+
174
+ function schedule(subject: string, state: AgentEventQueueState): void {
175
+ if (state.scheduled) return;
176
+ state.scheduled = true;
177
+ readySubjects.push({ subject, state });
178
+ pump();
179
+ }
180
+
181
+ return {
182
+ enqueue(payload, subject) {
183
+ if (!accepting) return;
184
+ const heartbeat = payloadIsHeartbeat(payload);
185
+ let state = states.get(subject);
186
+ if (!state) {
187
+ state = { scheduled: false, pending: [] };
188
+ states.set(subject, state);
189
+ }
190
+
191
+ const queued = { payload, subject, heartbeat };
192
+ const lastIndex = state.pending.length - 1;
193
+ if (heartbeat && lastIndex >= 0 && state.pending[lastIndex]!.heartbeat) {
194
+ state.pending[lastIndex] = queued;
195
+ options.onHeartbeatCoalesced?.();
196
+ } else {
197
+ state.pending.push(queued);
198
+ }
199
+
200
+ schedule(subject, state);
201
+ },
202
+ async whenIdle() {
203
+ if (states.size === 0) return;
204
+ await new Promise<void>((resolve) => idleWaiters.add(resolve));
205
+ },
206
+ close() {
207
+ accepting = false;
208
+ for (const state of states.values()) {
209
+ state.pending.length = 0;
210
+ }
211
+ resolveIdleWaiters();
212
+ },
213
+ };
214
+ }
215
+
77
216
  /**
78
217
  * Parse `agent.<ws>.<id>.connection.<instance>.<tail>` into its exact authority,
79
218
  * expected tail token. Returns null for a subject that does not match the shape
@@ -521,9 +660,24 @@ export function startMetricsIngestion(deps: {
521
660
  bus: EventBus;
522
661
  observability?: Observability;
523
662
  }): () => void {
524
- return deps.bus.subscribeAgentEvents(AGENT_EVENTS_SUBJECT, (payload, subject) =>
525
- handleAgentEventPayload(deps.db, deps.observability, payload, subject, deps.bus),
663
+ const scheduler = createAgentEventIngestionScheduler(
664
+ (payload, subject) =>
665
+ handleAgentEventPayload(deps.db, deps.observability, payload, subject, deps.bus),
666
+ {
667
+ onHeartbeatCoalesced: () =>
668
+ deps.observability?.incrementCounter({
669
+ name: "opengeni_machine_heartbeat_coalesced_total",
670
+ help: "Connected Machine heartbeats collapsed while an exact runner event queue was busy.",
671
+ }),
672
+ },
526
673
  );
674
+ const unsubscribe = deps.bus.subscribeAgentEvents(AGENT_EVENTS_SUBJECT, (payload, subject) => {
675
+ scheduler.enqueue(payload, subject);
676
+ });
677
+ return () => {
678
+ unsubscribe();
679
+ scheduler.close();
680
+ };
527
681
  }
528
682
 
529
683
  // ── Connect-Hello display refresh ─────────────────────────────────────────────
@@ -81,6 +81,8 @@ import {
81
81
  type NatsRequestConnection,
82
82
  } from "@opengeni/runtime/sandbox";
83
83
  import {
84
+ managedSessionGroupBackend,
85
+ managedSessionGroupOs,
84
86
  providerSettingsForSessionSandboxRuntime,
85
87
  relayConfigFromSettings,
86
88
  resolveSessionSandboxRuntime,
@@ -246,7 +248,24 @@ export async function attachViewer(
246
248
  },
247
249
  ): Promise<ViewerAttachResult> {
248
250
  const { db, settings } = services;
249
- const { accountId, workspaceId, session } = input;
251
+ const { accountId, workspaceId } = input;
252
+ const groupBackend = managedSessionGroupBackend(
253
+ settings.sandboxBackend,
254
+ input.session.sandboxBackend,
255
+ );
256
+ if (!groupBackend) {
257
+ throw new HTTPException(409, {
258
+ message: "session has no managed sandbox group to attach",
259
+ });
260
+ }
261
+ const session =
262
+ groupBackend === input.session.sandboxBackend
263
+ ? input.session
264
+ : {
265
+ ...input.session,
266
+ sandboxBackend: groupBackend,
267
+ sandboxOs: managedSessionGroupOs(input.session.sandboxBackend, input.session.sandboxOs),
268
+ };
250
269
  const viewerId = input.viewerId ?? crypto.randomUUID();
251
270
  const attachSubjectId = claimableSubjectId(input.viewerSubjectId ?? null);
252
271
  const attachAuthorityEpoch = attachSubjectId
@@ -0,0 +1,75 @@
1
+ import type { WorkspaceDeleteObservation, WorkspaceDeleteObserver } from "@opengeni/db";
2
+ import type { Observability } from "@opengeni/observability";
3
+
4
+ const DURATION_BUCKETS = [
5
+ 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600, 1_200, 2_400,
6
+ 3_600,
7
+ ];
8
+ const INVENTORY_BUCKETS = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1_000, 5_000, 10_000];
9
+
10
+ function recordWorkspaceDeleteObservation(
11
+ observability: Observability,
12
+ identity: { accountId: string; workspaceId: string },
13
+ observation: WorkspaceDeleteObservation,
14
+ ): void {
15
+ const labels = { phase: observation.phase, outcome: observation.outcome };
16
+ observability.observeHistogram({
17
+ name: "opengeni_workspace_delete_phase_seconds",
18
+ help: "Workspace deletion transaction and bounded internal phase duration in seconds.",
19
+ labels,
20
+ buckets: DURATION_BUCKETS,
21
+ value: Math.max(0, observation.durationSeconds),
22
+ });
23
+ if (observation.phase === "transaction") {
24
+ observability.incrementCounter({
25
+ name: "opengeni_workspace_delete_attempts_total",
26
+ help: "Workspace deletion attempts by terminal database outcome.",
27
+ labels: { outcome: observation.outcome },
28
+ });
29
+ }
30
+ for (const [kind, count] of Object.entries(observation.inventory ?? {})) {
31
+ observability.observeHistogram({
32
+ name: "opengeni_workspace_delete_inventory_rows",
33
+ help: "Rows or live owners observed by workspace deletion preflight inventory class.",
34
+ labels: { kind },
35
+ buckets: INVENTORY_BUCKETS,
36
+ value: Math.max(0, Math.floor(count ?? 0)),
37
+ });
38
+ }
39
+ observability.info("Workspace deletion phase", {
40
+ accountId: identity.accountId,
41
+ workspaceId: identity.workspaceId,
42
+ phase: observation.phase,
43
+ outcome: observation.outcome,
44
+ durationSeconds: observation.durationSeconds,
45
+ inventoryJson: JSON.stringify(observation.inventory ?? {}),
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Retain exact workspace deletion timing and inventory without putting tenant
51
+ * identifiers in metric labels. Observer failures never change deletion truth.
52
+ */
53
+ export function workspaceDeleteObserver(
54
+ observability: Observability | null | undefined,
55
+ identity: { accountId: string; workspaceId: string },
56
+ ): WorkspaceDeleteObserver | undefined {
57
+ if (!observability) return undefined;
58
+ return {
59
+ onPhase: (observation) => {
60
+ try {
61
+ recordWorkspaceDeleteObservation(observability, identity, observation);
62
+ } catch {
63
+ try {
64
+ observability.incrementCounter({
65
+ name: "opengeni_observability_observer_errors_total",
66
+ help: "Observability observer failures isolated from product execution.",
67
+ labels: { observer: "workspace_delete" },
68
+ });
69
+ } catch {
70
+ // The metrics registry itself is unhealthy. Deletion remains authoritative.
71
+ }
72
+ }
73
+ },
74
+ };
75
+ }