@opengeni/api-router 0.5.3 → 0.5.5

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 (49) hide show
  1. package/dist/app.d.ts +9 -1
  2. package/dist/app.js +7 -1
  3. package/dist/{chunk-3HIA43CC.js → chunk-HBEJMWD3.js} +5470 -2223
  4. package/dist/chunk-HBEJMWD3.js.map +1 -0
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +279 -55
  7. package/dist/index.js.map +1 -1
  8. package/package.json +20 -20
  9. package/src/app.ts +583 -166
  10. package/src/auth/managed-auth.ts +32 -16
  11. package/src/http/auth.ts +8 -1
  12. package/src/http/common.ts +6 -2
  13. package/src/http/sse.ts +84 -8
  14. package/src/index.ts +178 -75
  15. package/src/integrations/oauth-client.ts +403 -120
  16. package/src/integrations/provider-domain.ts +4 -1
  17. package/src/mcp/documents.ts +173 -94
  18. package/src/mcp/server.ts +1600 -693
  19. package/src/mcp/session-view.ts +8 -2
  20. package/src/mcp/toolspace.ts +175 -84
  21. package/src/observability.ts +7 -1
  22. package/src/routes/api-keys.ts +39 -23
  23. package/src/routes/billing.ts +180 -65
  24. package/src/routes/capabilities.ts +17 -8
  25. package/src/routes/catalog-assets.ts +5 -2
  26. package/src/routes/codex.ts +244 -63
  27. package/src/routes/connections.ts +71 -33
  28. package/src/routes/documents.ts +242 -92
  29. package/src/routes/enrollments.ts +100 -70
  30. package/src/routes/environments.ts +205 -136
  31. package/src/routes/files.ts +164 -39
  32. package/src/routes/github.ts +123 -50
  33. package/src/routes/install.ts +9 -2
  34. package/src/routes/machines.ts +9 -8
  35. package/src/routes/packs.ts +141 -89
  36. package/src/routes/rigs.ts +189 -0
  37. package/src/routes/scheduled-tasks.ts +51 -9
  38. package/src/routes/sessions.ts +870 -328
  39. package/src/routes/social.ts +50 -38
  40. package/src/routes/workspace-capture.ts +238 -0
  41. package/src/routes/workspaces.ts +146 -13
  42. package/src/sandbox/access.ts +11 -3
  43. package/src/sandbox/auth-callout.ts +5 -1
  44. package/src/sandbox/channel-a.ts +104 -27
  45. package/src/sandbox/enrollment.ts +13 -3
  46. package/src/sandbox/machines.ts +68 -59
  47. package/src/sandbox/metrics-ingestion.ts +238 -17
  48. package/src/sandbox/viewer.ts +172 -46
  49. package/dist/chunk-3HIA43CC.js.map +0 -1
@@ -17,7 +17,13 @@
17
17
  // IMPORT DISCIPLINE: sandbox symbols come ONLY from @opengeni/runtime/sandbox
18
18
  // (the agent-loop-free leaf) — enforced by sandbox-access-import-guard.test.ts.
19
19
 
20
- import { applyGitAuthPointerEnvironment, hasGitHubRepositorySelection, stableSandboxEnvironmentForRun, type Settings } from "@opengeni/config";
20
+ import {
21
+ applyGitAuthPointerEnvironment,
22
+ hasGitCredentialRepositorySelection,
23
+ hasGitHubRepositorySelection,
24
+ stableSandboxEnvironmentForRun,
25
+ type Settings,
26
+ } from "@opengeni/config";
21
27
  import { githubAppBotIdentity } from "@opengeni/github";
22
28
  import type { Session } from "@opengeni/contracts";
23
29
  import {
@@ -26,9 +32,9 @@ import {
26
32
  failWarmingToCold,
27
33
  getSandboxSessionEnvelope,
28
34
  loadWorkspaceEnvironmentForRun,
35
+ markWarmLeaseInstanceLost,
29
36
  readLease,
30
37
  releaseLeaseHolder,
31
- SandboxLeaseSupersededError,
32
38
  type Database,
33
39
  type LeaseSnapshot,
34
40
  } from "@opengeni/db";
@@ -37,6 +43,8 @@ import { HTTPException } from "hono/http-exception";
37
43
 
38
44
  import {
39
45
  establishSandboxSessionFromEnvelope,
46
+ isProviderSandboxNotFoundError,
47
+ SandboxResumeStateUnavailableError,
40
48
  serializeEstablishedSandboxEnvelope,
41
49
  SandboxChannelAService,
42
50
  ChannelAConflictError,
@@ -84,7 +92,7 @@ export async function withChannelA<T>(
84
92
  fn: (handle: ChannelAHandle) => Promise<T>,
85
93
  ): Promise<T> {
86
94
  const { db, settings, bus } = services;
87
- const { accountId, workspaceId, session, subjectId } = ctx;
95
+ const { accountId, workspaceId, session } = ctx;
88
96
 
89
97
  if (session.sandboxBackend === "none") {
90
98
  throw new HTTPException(409, { message: "sandbox not available" });
@@ -116,11 +124,14 @@ export async function withChannelA<T>(
116
124
  backend: session.sandboxBackend,
117
125
  os: session.sandboxOs,
118
126
  leaseTtlMs,
127
+ warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
119
128
  });
120
129
 
121
130
  if (acquired.role === "fenced") {
122
131
  await release();
123
- throw new HTTPException(409, { message: `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); retry` });
132
+ throw new HTTPException(409, {
133
+ message: `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); retry`,
134
+ });
124
135
  }
125
136
 
126
137
  let established: EstablishedSandboxSession | undefined;
@@ -136,13 +147,26 @@ export async function withChannelA<T>(
136
147
  // the SESSION's backend (the establish below passes backendOverride:
137
148
  // session.sandboxBackend, and HOME/token-file/askpass are backend-derived),
138
149
  // NOT the deployment default — mirrors sessionAttachEnvironment.
139
- const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(db, settings, workspaceId, session.environmentId);
140
- const settingsForSession = session.sandboxBackend !== settings.sandboxBackend
141
- ? { ...settings, sandboxBackend: session.sandboxBackend }
142
- : settings;
143
- const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {}, { workspaceId });
144
- if (hasGitHubRepositorySelection(session.resources)) {
145
- applyGitAuthPointerEnvironment(environment, githubAppBotIdentity(settings));
150
+ const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
151
+ db,
152
+ settings,
153
+ workspaceId,
154
+ session.environmentId,
155
+ );
156
+ const settingsForSession =
157
+ session.sandboxBackend !== settings.sandboxBackend
158
+ ? { ...settings, sandboxBackend: session.sandboxBackend }
159
+ : settings;
160
+ const environment = stableSandboxEnvironmentForRun(
161
+ settingsForSession,
162
+ workspaceEnvironment?.values ?? {},
163
+ { workspaceId },
164
+ );
165
+ if (hasGitCredentialRepositorySelection(session.resources)) {
166
+ applyGitAuthPointerEnvironment(
167
+ environment,
168
+ hasGitHubRepositorySelection(session.resources) ? githubAppBotIdentity(settings) : null,
169
+ );
146
170
  }
147
171
 
148
172
  if (acquired.role === "spawner") {
@@ -161,18 +185,22 @@ export async function withChannelA<T>(
161
185
  try {
162
186
  established = await establishSandboxSessionFromEnvelope(settings, spawnEnvelope, {
163
187
  sessionId: session.id,
188
+ recovery: "create-or-restore",
164
189
  backendOverride: session.sandboxBackend,
165
190
  environment,
166
191
  });
167
192
  } catch (error) {
168
193
  await failWarmingToCold(db, { accountId, workspaceId, sandboxGroupId, expectedEpoch });
169
- throw new HTTPException(409, { message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})` });
194
+ throw new HTTPException(409, {
195
+ message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})`,
196
+ });
170
197
  }
171
198
  // Persist the LIVE box as the lease's resume_state so the NEXT op resumes
172
199
  // this box by id rather than cold-creating a rival (the box-churn the
173
200
  // prove-it surfaced). Fall back to the session envelope when serialize is
174
201
  // unavailable.
175
- const resumeEnvelope = (await serializeEstablishedSandboxEnvelope(established)) ?? envelope ?? null;
202
+ const resumeEnvelope =
203
+ (await serializeEstablishedSandboxEnvelope(established)) ?? envelope ?? null;
176
204
  const committed = await commitWarmingToWarm(db, {
177
205
  accountId,
178
206
  workspaceId,
@@ -185,20 +213,59 @@ export async function withChannelA<T>(
185
213
  leaseTtlMs,
186
214
  });
187
215
  if (!committed.committed || !committed.lease) {
188
- throw new HTTPException(409, { message: `sandbox lease superseded (epoch ${expectedEpoch}); retry` });
216
+ throw new HTTPException(409, {
217
+ message: `sandbox lease superseded (epoch ${expectedEpoch}); retry`,
218
+ });
189
219
  }
190
220
  leaseSnapshot = committed.lease;
191
221
  } else {
192
222
  // ATTACHED / REARMED: the box is live. Read the lease to get the
193
223
  // authoritative resume_state, then resume by id for this op.
194
224
  const live = await readLease(db, workspaceId, sandboxGroupId);
195
- if (live) leaseSnapshot = live;
196
- const resumeEnvelope = leaseSnapshot.resumeState ?? envelope;
197
- established = await establishSandboxSessionFromEnvelope(settings, resumeEnvelope, {
198
- sessionId: session.id,
199
- backendOverride: session.sandboxBackend,
200
- environment,
201
- });
225
+ if (
226
+ !live ||
227
+ live.liveness !== "warm" ||
228
+ live.leaseEpoch !== acquired.lease.leaseEpoch ||
229
+ live.instanceId === null
230
+ ) {
231
+ throw new HTTPException(409, {
232
+ message: `sandbox lease is not attachable; retry`,
233
+ });
234
+ }
235
+ leaseSnapshot = live;
236
+ try {
237
+ established = await establishSandboxSessionFromEnvelope(settings, live.resumeState, {
238
+ sessionId: session.id,
239
+ recovery: "resume-only",
240
+ backendOverride: session.sandboxBackend,
241
+ environment,
242
+ });
243
+ } catch (error) {
244
+ if (
245
+ !(error instanceof SandboxResumeStateUnavailableError) &&
246
+ !isProviderSandboxNotFoundError(session.sandboxBackend, error)
247
+ ) {
248
+ throw error;
249
+ }
250
+ const marked = await markWarmLeaseInstanceLost(db, {
251
+ accountId,
252
+ workspaceId,
253
+ sandboxGroupId,
254
+ expectedEpoch: live.leaseEpoch,
255
+ expectedInstanceId: live.instanceId,
256
+ });
257
+ if (marked.status === "marked") {
258
+ await appendAndPublishEvents(db, bus, workspaceId, session.id, [
259
+ {
260
+ type: "sandbox.box.lost",
261
+ payload: { sandboxId: live.instanceId },
262
+ },
263
+ ]);
264
+ }
265
+ throw new HTTPException(409, {
266
+ message: `sandbox instance was lost; retry to restore it`,
267
+ });
268
+ }
202
269
  }
203
270
 
204
271
  const emit = async (events: { type: string; payload: unknown }[]): Promise<void> => {
@@ -220,7 +287,11 @@ export async function withChannelA<T>(
220
287
  // default, or a swapped-to selfhosted machine). With the flag off the
221
288
  // established group session is used unchanged.
222
289
  const routedSession = routingEnabled(settings)
223
- ? wrapChannelABoxWithRouting({ db, settings, bus }, { workspaceId, sessionId: session.id }, established).session
290
+ ? wrapChannelABoxWithRouting(
291
+ { db, settings, bus },
292
+ { workspaceId, sessionId: session.id },
293
+ established,
294
+ ).session
224
295
  : established.session;
225
296
 
226
297
  const service = new SandboxChannelAService({
@@ -242,10 +313,14 @@ export async function withChannelA<T>(
242
313
  * already-HTTPException unchanged. */
243
314
  export function mapChannelAError(error: unknown): unknown {
244
315
  if (error instanceof HTTPException) return error;
245
- if (error instanceof ChannelAValidationError) return new HTTPException(400, { message: error.message });
246
- if (error instanceof ChannelANotFoundError) return new HTTPException(404, { message: error.message });
247
- if (error instanceof ChannelAConflictError) return new HTTPException(409, { message: error.message });
248
- if (error instanceof ChannelAUnsupportedError) return new HTTPException(409, { message: error.message });
316
+ if (error instanceof ChannelAValidationError)
317
+ return new HTTPException(400, { message: error.message });
318
+ if (error instanceof ChannelANotFoundError)
319
+ return new HTTPException(404, { message: error.message });
320
+ if (error instanceof ChannelAConflictError)
321
+ return new HTTPException(409, { message: error.message });
322
+ if (error instanceof ChannelAUnsupportedError)
323
+ return new HTTPException(409, { message: error.message });
249
324
  return error;
250
325
  }
251
326
 
@@ -258,7 +333,9 @@ export function mapChannelAError(error: unknown): unknown {
258
333
  // destroyed the box mid-flight, so a subsequent fs.read/git/exec hit a different
259
334
  // (cold-restored) box and 404'd. We DO NOT close the session; only the reaper
260
335
  // (provider stop at refcount 0) terminates a box.
261
- async function dropEstablishedHandle(established: EstablishedSandboxSession | undefined): Promise<void> {
336
+ async function dropEstablishedHandle(
337
+ established: EstablishedSandboxSession | undefined,
338
+ ): Promise<void> {
262
339
  // No-op beyond dropping the reference: the lease owns lifecycle, the reaper
263
340
  // owns teardown. Never session.close()/terminate() a non-owned handle here.
264
341
  void established;
@@ -199,7 +199,11 @@ export async function approveDeviceEnrollment(
199
199
  },
200
200
  ): Promise<{ enrollmentId: string; sandboxId: string; allowScreenControl: boolean } | null> {
201
201
  const { db } = services;
202
- const pending = await getPendingDeviceEnrollmentRequestByUserCode(db, input.workspaceId, input.userCode);
202
+ const pending = await getPendingDeviceEnrollmentRequestByUserCode(
203
+ db,
204
+ input.workspaceId,
205
+ input.userCode,
206
+ );
203
207
  if (!pending) {
204
208
  return null;
205
209
  }
@@ -240,7 +244,9 @@ export async function lookupDeviceEnrollment(
240
244
 
241
245
  /** Project a resolved pending record to the presentational lookup response (no
242
246
  * secrets, no device_code) the approve screen (EnrollmentConsent) renders. */
243
- export function toLookupResponse(record: DeviceEnrollmentRequestRecord): DeviceEnrollmentLookupResponse {
247
+ export function toLookupResponse(
248
+ record: DeviceEnrollmentRequestRecord,
249
+ ): DeviceEnrollmentLookupResponse {
244
250
  return {
245
251
  workspaceId: record.workspaceId,
246
252
  userCode: record.userCode,
@@ -263,7 +269,11 @@ export async function denyDeviceEnrollment(
263
269
  input: { accountId: string; workspaceId: string; userCode: string },
264
270
  ): Promise<{ denied: boolean }> {
265
271
  const { db } = services;
266
- const pending = await getPendingDeviceEnrollmentRequestByUserCode(db, input.workspaceId, input.userCode);
272
+ const pending = await getPendingDeviceEnrollmentRequestByUserCode(
273
+ db,
274
+ input.workspaceId,
275
+ input.userCode,
276
+ );
267
277
  if (!pending) {
268
278
  return { denied: false };
269
279
  }
@@ -31,11 +31,7 @@ import {
31
31
  type MachineMetricsRow,
32
32
  } from "@opengeni/db";
33
33
  import type { EventBus } from "@opengeni/events";
34
- import {
35
- MachineView,
36
- MetricSample,
37
- type MachinesResponse,
38
- } from "@opengeni/contracts";
34
+ import { MachineView, MetricSample, type MachinesResponse } from "@opengeni/contracts";
39
35
  import {
40
36
  NatsControlRpc,
41
37
  selfhostedLiveness,
@@ -95,7 +91,11 @@ async function probeEnrollment(
95
91
  services: MachinesServices,
96
92
  workspaceId: string,
97
93
  enrollment: EnrollmentRecord,
98
- ): Promise<{ state: "online" | "reconnecting" | "offline"; consented: boolean; hasDisplay: boolean }> {
94
+ ): Promise<{
95
+ state: "online" | "reconnecting" | "offline";
96
+ consented: boolean;
97
+ hasDisplay: boolean;
98
+ }> {
99
99
  const { settings, bus } = services;
100
100
  let probeResponded = false;
101
101
  if (enrollment.status === "active") {
@@ -119,6 +119,8 @@ async function probeEnrollment(
119
119
  allowScreenControl: enrollment.allowScreenControl,
120
120
  hasDisplay: enrollment.hasDisplay,
121
121
  lastSeenAt: enrollment.lastSeenAt,
122
+ wentOfflineAt: enrollment.wentOfflineAt,
123
+ wentOfflineReason: enrollment.wentOfflineReason,
122
124
  },
123
125
  probeResponded,
124
126
  });
@@ -186,25 +188,27 @@ export async function listMachines(
186
188
  // null active pointer routes to. Only present in an in-session view.
187
189
  if (session) {
188
190
  const groupActive = activeSandboxId === null;
189
- machines.push(MachineView.parse({
190
- sandboxId: session.sandboxGroupId,
191
- enrollmentId: null,
192
- name: "session sandbox",
193
- kind: session.sandboxBackend === "selfhosted" ? "selfhosted" : "modal",
194
- state: "online",
195
- active: groupActive,
196
- isSessionGroup: true,
197
- // The Modal group box is a cloud Linux box; its precise OS/arch is not
198
- // surfaced as a metric, so the dashboard shows the canonical linux/x86_64.
199
- os: "linux",
200
- arch: "x86_64",
201
- hasDisplay: false,
202
- desktopUnavailableReason: null,
203
- allowScreenControl: false,
204
- sharedSessionCount: 1,
205
- lastSeenAt: null,
206
- metrics: null,
207
- }));
191
+ machines.push(
192
+ MachineView.parse({
193
+ sandboxId: session.sandboxGroupId,
194
+ enrollmentId: null,
195
+ name: "session sandbox",
196
+ kind: session.sandboxBackend === "selfhosted" ? "selfhosted" : "modal",
197
+ state: "online",
198
+ active: groupActive,
199
+ isSessionGroup: true,
200
+ // The Modal group box is a cloud Linux box; its precise OS/arch is not
201
+ // surfaced as a metric, so the dashboard shows the canonical linux/x86_64.
202
+ os: "linux",
203
+ arch: "x86_64",
204
+ hasDisplay: false,
205
+ desktopUnavailableReason: null,
206
+ allowScreenControl: false,
207
+ sharedSessionCount: 1,
208
+ lastSeenAt: null,
209
+ metrics: null,
210
+ }),
211
+ );
208
212
  }
209
213
 
210
214
  // The workspace's enrolled selfhosted machines. One bulk metrics read joined
@@ -216,42 +220,47 @@ export async function listMachines(
216
220
  ]);
217
221
  const enrollmentById = new Map(enrollments.map((e) => [e.id, e]));
218
222
 
219
- for (const sandbox of sandboxes) {
220
- if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
221
- continue;
222
- }
223
- const enrollment = enrollmentById.get(sandbox.enrollmentId) ?? null;
224
- if (!enrollment) {
225
- continue;
226
- }
227
- const probe = await probeEnrollment(services, workspaceId, enrollment);
228
- const state = machineStateFor(probe.state, probe.hasDisplay);
223
+ const machineViews = await Promise.all(
224
+ sandboxes.map(async (sandbox): Promise<MachineView | null> => {
225
+ if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
226
+ return null;
227
+ }
228
+ const enrollment = enrollmentById.get(sandbox.enrollmentId) ?? null;
229
+ if (!enrollment) {
230
+ return null;
231
+ }
232
+ const [probe, lease] = await Promise.all([
233
+ probeEnrollment(services, workspaceId, enrollment),
234
+ readLease(db, workspaceId, sandbox.id),
235
+ ]);
236
+ const state = machineStateFor(probe.state, probe.hasDisplay);
229
237
 
230
- // sharedSessionCount = the lease refcount for this machine's group. The
231
- // selfhosted sandbox id IS the lease group key (maxSandboxes:1, N sessions
232
- // share via refcount). No lease yet → 0 sessions sharing.
233
- const lease = await readLease(db, workspaceId, sandbox.id);
234
- const sharedSessionCount = lease?.refcount ?? 0;
238
+ // sharedSessionCount = the lease refcount for this machine's group. The
239
+ // selfhosted sandbox id IS the lease group key (maxSandboxes:1, N sessions
240
+ // share via refcount). No lease yet → 0 sessions sharing.
241
+ const sharedSessionCount = lease?.refcount ?? 0;
235
242
 
236
- const metricsRow = metricsByEnrollment.get(enrollment.id) ?? null;
237
- machines.push(MachineView.parse({
238
- sandboxId: sandbox.id,
239
- enrollmentId: enrollment.id,
240
- name: sandbox.name,
241
- kind: "selfhosted",
242
- state,
243
- active: activeSandboxId === sandbox.id,
244
- isSessionGroup: false,
245
- os: enrollment.os,
246
- arch: enrollment.arch,
247
- hasDisplay: enrollment.hasDisplay,
248
- desktopUnavailableReason: enrollment.desktopUnavailableReason,
249
- allowScreenControl: enrollment.allowScreenControl,
250
- sharedSessionCount,
251
- lastSeenAt: enrollment.lastSeenAt,
252
- metrics: metricsRow ? metricRowToSample(metricsRow) : null,
253
- }));
254
- }
243
+ const metricsRow = metricsByEnrollment.get(enrollment.id) ?? null;
244
+ return MachineView.parse({
245
+ sandboxId: sandbox.id,
246
+ enrollmentId: enrollment.id,
247
+ name: sandbox.name,
248
+ kind: "selfhosted",
249
+ state,
250
+ active: activeSandboxId === sandbox.id,
251
+ isSessionGroup: false,
252
+ os: enrollment.os,
253
+ arch: enrollment.arch,
254
+ hasDisplay: enrollment.hasDisplay,
255
+ desktopUnavailableReason: enrollment.desktopUnavailableReason,
256
+ allowScreenControl: enrollment.allowScreenControl,
257
+ sharedSessionCount,
258
+ lastSeenAt: enrollment.lastSeenAt,
259
+ metrics: metricsRow ? metricRowToSample(metricsRow) : null,
260
+ });
261
+ }),
262
+ );
263
+ machines.push(...machineViews.filter((machine): machine is MachineView => machine !== null));
255
264
 
256
265
  return { activeSandboxId, activeEpoch, machines };
257
266
  }