@opengeni/api-router 0.2.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 (41) hide show
  1. package/dist/app.d.ts +16 -0
  2. package/dist/app.js +35 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/chunk-XSYUDIX3.js +6331 -0
  5. package/dist/chunk-XSYUDIX3.js.map +1 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.js +567 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +74 -0
  10. package/src/app.ts +351 -0
  11. package/src/auth/managed-auth.ts +237 -0
  12. package/src/http/auth.ts +92 -0
  13. package/src/http/common.ts +16 -0
  14. package/src/http/sse.ts +89 -0
  15. package/src/index.ts +362 -0
  16. package/src/mcp/documents.ts +57 -0
  17. package/src/mcp/server.ts +961 -0
  18. package/src/mcp/session-view.ts +281 -0
  19. package/src/routes/api-keys.ts +65 -0
  20. package/src/routes/billing.ts +495 -0
  21. package/src/routes/capabilities.ts +80 -0
  22. package/src/routes/codex.ts +393 -0
  23. package/src/routes/documents.ts +185 -0
  24. package/src/routes/enrollments.ts +357 -0
  25. package/src/routes/environments.ts +175 -0
  26. package/src/routes/files.ts +148 -0
  27. package/src/routes/github.ts +341 -0
  28. package/src/routes/install.ts +218 -0
  29. package/src/routes/machines.ts +107 -0
  30. package/src/routes/packs.ts +241 -0
  31. package/src/routes/scheduled-tasks.ts +126 -0
  32. package/src/routes/sessions.ts +1083 -0
  33. package/src/routes/social.ts +119 -0
  34. package/src/routes/workspaces.ts +206 -0
  35. package/src/sandbox/access.ts +89 -0
  36. package/src/sandbox/auth-callout.ts +178 -0
  37. package/src/sandbox/channel-a.ts +265 -0
  38. package/src/sandbox/enrollment.ts +498 -0
  39. package/src/sandbox/machines.ts +255 -0
  40. package/src/sandbox/metrics-ingestion.ts +289 -0
  41. package/src/sandbox/viewer.ts +993 -0
package/src/index.ts ADDED
@@ -0,0 +1,362 @@
1
+ import { dbSearchPath, getSettings, resolveNatsCalloutConfig, resolveNatsControlPlaneAuth, retryStartupDependency, startupRetryOptions } from "@opengeni/config";
2
+ import type { ScheduledTask, ScheduledTaskOverlapPolicy, ScheduledTaskScheduleSpec } from "@opengeni/contracts";
3
+ import { createDb } from "@opengeni/db";
4
+ import { createNatsEventBus, type ResponderConnection } from "@opengeni/events";
5
+ import { createObservability, logStartupDependencyRetry } from "@opengeni/observability";
6
+ import { Connection, Client as TemporalClient, ScheduleNotFoundError, ScheduleOverlapPolicy, WorkflowExecutionAlreadyStartedError } from "@temporalio/client";
7
+ import type { ScheduleOptions, ScheduleSpec, ScheduleUpdateOptions } from "@temporalio/client";
8
+ import { createApp, type DocumentIndexClient, type SessionWorkflowClient } from "./app";
9
+ import { startAuthCalloutResponder } from "./sandbox/auth-callout";
10
+ import { startHelloIngestion, startMetricsIngestion } from "./sandbox/metrics-ingestion";
11
+
12
+ /**
13
+ * A REJECT_DUPLICATE start collides on the deterministic workflowId when the
14
+ * same manual trigger token fires twice. Temporal surfaces that as
15
+ * WorkflowExecutionAlreadyStartedError; the caller treats it as an idempotent
16
+ * no-op rather than a failure.
17
+ */
18
+ function isWorkflowAlreadyStarted(error: unknown): boolean {
19
+ return error instanceof WorkflowExecutionAlreadyStartedError;
20
+ }
21
+
22
+ const TEMPORAL_MONTHS = [
23
+ "JANUARY",
24
+ "FEBRUARY",
25
+ "MARCH",
26
+ "APRIL",
27
+ "MAY",
28
+ "JUNE",
29
+ "JULY",
30
+ "AUGUST",
31
+ "SEPTEMBER",
32
+ "OCTOBER",
33
+ "NOVEMBER",
34
+ "DECEMBER",
35
+ ] as const;
36
+
37
+ export async function createTemporalWorkflowClient(settings: ReturnType<typeof getSettings>): Promise<{
38
+ client: SessionWorkflowClient;
39
+ documentIndexer: DocumentIndexClient;
40
+ close: () => Promise<void>;
41
+ }> {
42
+ const connection = await Connection.connect({ address: settings.temporalHost });
43
+ const temporal = new TemporalClient({
44
+ connection,
45
+ namespace: settings.temporalNamespace,
46
+ });
47
+ const client: SessionWorkflowClient = {
48
+ signalUserMessage: async ({ eventId, workflowId }) => {
49
+ await temporal.workflow.getHandle(workflowId).signal("userMessage", eventId);
50
+ },
51
+ wakeSessionWorkflow: async ({ accountId, workspaceId, sessionId, workflowId }) => {
52
+ await temporal.workflow.signalWithStart("sessionWorkflow", {
53
+ taskQueue: settings.temporalTaskQueue,
54
+ workflowId,
55
+ workflowIdReusePolicy: "ALLOW_DUPLICATE",
56
+ args: [{ accountId, workspaceId, sessionId }],
57
+ signal: "queueChanged",
58
+ });
59
+ },
60
+ signalApprovalDecision: async ({ eventId, workflowId }) => {
61
+ await temporal.workflow.getHandle(workflowId).signal("approvalDecision", eventId);
62
+ },
63
+ signalInterrupt: async ({ accountId, workspaceId, sessionId, eventId, workflowId }) => {
64
+ // Start-or-signal: an interrupt POSTed while the session is idle has no
65
+ // running workflow execution to signal, and getHandle().signal() would
66
+ // throw WorkflowNotFoundError -> a 500 (the operator-can't-stop bug). Like
67
+ // wakeSessionWorkflow, signalWithStart delivers the signal to a live run
68
+ // when one exists and otherwise starts a fresh sessionWorkflow that picks
69
+ // the buffered `interrupt` up immediately. ALLOW_DUPLICATE matches the
70
+ // wake path so a running execution is reused rather than rejected.
71
+ await temporal.workflow.signalWithStart("sessionWorkflow", {
72
+ taskQueue: settings.temporalTaskQueue,
73
+ workflowId,
74
+ workflowIdReusePolicy: "ALLOW_DUPLICATE",
75
+ args: [{ accountId, workspaceId, sessionId }],
76
+ signal: "interrupt",
77
+ signalArgs: [eventId],
78
+ });
79
+ },
80
+ syncScheduledTask: async ({ task }) => {
81
+ const schedule = temporal.schedule.getHandle(task.temporalScheduleId);
82
+ const options = temporalScheduleOptions(task, settings.temporalTaskQueue);
83
+ try {
84
+ await schedule.update(() => temporalScheduleUpdateOptions(options));
85
+ } catch (error) {
86
+ if (!shouldCreateScheduleAfterUpdateError(error)) {
87
+ throw error;
88
+ }
89
+ await temporal.schedule.create(options);
90
+ }
91
+ },
92
+ deleteScheduledTaskSchedule: async ({ temporalScheduleId }) => {
93
+ await temporal.schedule.getHandle(temporalScheduleId).delete().catch(() => undefined);
94
+ },
95
+ triggerScheduledTask: async ({ task, agentRunUsageIdempotencyKey, triggerWorkflowId }) => {
96
+ // Deterministic workflowId (derived from the trigger token by the
97
+ // caller) + REJECT_DUPLICATE makes a retried manual trigger idempotent:
98
+ // the second start collides on the id and is rejected instead of
99
+ // spawning a second run. The shared idempotency key dedupes the charge.
100
+ const workflowId = triggerWorkflowId ?? `scheduled-task-${task.id}-manual-${crypto.randomUUID()}`;
101
+ try {
102
+ await temporal.workflow.start("scheduledTaskFireWorkflow", {
103
+ taskQueue: settings.temporalTaskQueue,
104
+ workflowId,
105
+ workflowIdReusePolicy: "REJECT_DUPLICATE",
106
+ args: [{
107
+ accountId: task.accountId,
108
+ workspaceId: task.workspaceId,
109
+ taskId: task.id,
110
+ triggerType: "manual",
111
+ agentRunUsageIdempotencyKey,
112
+ }],
113
+ });
114
+ } catch (error) {
115
+ // A duplicate trigger token started this run already; treat the retry
116
+ // as a no-op so the (idempotent) usage charge stays the only effect.
117
+ if (isWorkflowAlreadyStarted(error)) {
118
+ return;
119
+ }
120
+ throw error;
121
+ }
122
+ },
123
+ };
124
+ const documentIndexer: DocumentIndexClient = {
125
+ indexDocument: async ({ accountId, workspaceId, documentId }) => {
126
+ const workflowId = `document-index-${documentId}-${crypto.randomUUID()}`;
127
+ await temporal.workflow.start("documentIndexWorkflow", {
128
+ taskQueue: settings.temporalTaskQueue,
129
+ workflowId,
130
+ args: [{ accountId, workspaceId, documentId }],
131
+ });
132
+ },
133
+ };
134
+ return {
135
+ client,
136
+ documentIndexer,
137
+ close: async () => {
138
+ await connection.close();
139
+ },
140
+ };
141
+ }
142
+
143
+ export async function startApi() {
144
+ const settings = getSettings();
145
+ const observability = createObservability(settings, { component: "api" });
146
+ // Step I: standalone → dbSchema unset → searchPath undefined → today's plain
147
+ // handle (public). Embedded → scoped to the dedicated schema + the host's RLS
148
+ // strategy.
149
+ const searchPath = dbSearchPath(settings);
150
+ const dbClient = createDb(settings.databaseUrl, {
151
+ ...(searchPath ? { searchPath } : {}),
152
+ rlsStrategy: settings.rlsStrategy,
153
+ });
154
+ let bus: Awaited<ReturnType<typeof createNatsEventBus>> | undefined;
155
+ let workflowClient: Awaited<ReturnType<typeof createTemporalWorkflowClient>> | undefined;
156
+ const retryOptions = startupRetryOptions(settings);
157
+ const onRetry = (event: Parameters<typeof logStartupDependencyRetry>[1]) => logStartupDependencyRetry(observability, event);
158
+ // The PRIVILEGED control-plane NATS login (M-AUTH): when the server runs with
159
+ // auth_callout, api/worker authenticate as a static account user permitted to
160
+ // request `agent.*.rpc`. Null in local dev (anonymous connect — the bus default).
161
+ const controlPlaneAuth = resolveNatsControlPlaneAuth(settings);
162
+ try {
163
+ bus = await retryStartupDependency(
164
+ "NATS",
165
+ () =>
166
+ createNatsEventBus(
167
+ settings.natsUrl,
168
+ controlPlaneAuth ? { user: controlPlaneAuth.user, pass: controlPlaneAuth.password } : undefined,
169
+ ),
170
+ {
171
+ ...retryOptions,
172
+ onRetry,
173
+ },
174
+ );
175
+ workflowClient = await retryStartupDependency("Temporal", () => createTemporalWorkflowClient(settings), {
176
+ ...retryOptions,
177
+ onRetry,
178
+ });
179
+ } catch (error) {
180
+ await Promise.allSettled([
181
+ bus?.close(),
182
+ workflowClient?.close(),
183
+ dbClient.close(),
184
+ ]);
185
+ throw error;
186
+ }
187
+ if (!bus || !workflowClient) {
188
+ await dbClient.close();
189
+ throw new Error("OpenGeni API startup dependencies were not initialized");
190
+ }
191
+ const app = createApp({
192
+ settings,
193
+ db: dbClient.db,
194
+ bus,
195
+ workflowClient: workflowClient.client,
196
+ documentIndexer: workflowClient.documentIndexer,
197
+ observability,
198
+ });
199
+ const server = Bun.serve({
200
+ hostname: settings.apiHost,
201
+ port: settings.apiPort,
202
+ idleTimeout: 255,
203
+ fetch: app.fetch,
204
+ });
205
+ // M10 — start the metrics-ingestion consumer (agent heartbeats → DB last-sample
206
+ // + downsampled series), gated on the selfhosted flag. A no-op when disabled.
207
+ let stopMetricsIngestion: (() => void) | undefined;
208
+ // Reconcile enrollments.has_display to the LIVE capability the agent reports in
209
+ // its connect Hello (has_display was frozen at the enroll-time snapshot). Gated
210
+ // on the same selfhosted flag.
211
+ let stopHelloIngestion: (() => void) | undefined;
212
+ // M-AUTH — start the NATS auth-callout responder (the tenancy boundary): it
213
+ // validates an agent's enrollment bearer presented at NATS connect and mints a
214
+ // workspace-scoped user JWT. Gated on the selfhosted flag + a resolvable callout
215
+ // config; without the callout plane it never starts (selfhosted agents simply
216
+ // cannot connect — graceful). It runs on its OWN connection (the callout auth
217
+ // user), separate from the privileged control-plane bus.
218
+ let authCalloutResponder: ResponderConnection | undefined;
219
+ if (settings.sandboxSelfhostedEnabled) {
220
+ stopMetricsIngestion = startMetricsIngestion({ db: dbClient.db, bus, observability });
221
+ stopHelloIngestion = startHelloIngestion({ db: dbClient.db, bus, observability });
222
+ observability.info("OpenGeni machine-metrics + hello ingestion consumers started", {});
223
+
224
+ const callout = resolveNatsCalloutConfig(settings);
225
+ if (callout) {
226
+ try {
227
+ authCalloutResponder = await startAuthCalloutResponder(
228
+ { db: dbClient.db, settings, callout, observability },
229
+ settings.natsUrl,
230
+ );
231
+ } catch (error) {
232
+ // A responder start failure must not crash the API (other planes work); log
233
+ // loudly — selfhosted agents will fail to connect until it is up.
234
+ observability.error("OpenGeni NATS auth-callout responder failed to start", {
235
+ error: error instanceof Error ? error.message : String(error),
236
+ });
237
+ }
238
+ } else {
239
+ observability.warn(
240
+ "OpenGeni selfhosted enabled but the NATS auth-callout plane is not configured; selfhosted agents cannot connect",
241
+ {},
242
+ );
243
+ }
244
+ }
245
+ observability.info("OpenGeni API listening", {
246
+ host: settings.apiHost,
247
+ port: settings.apiPort,
248
+ });
249
+ return {
250
+ server,
251
+ close: async () => {
252
+ server.stop(true);
253
+ stopMetricsIngestion?.();
254
+ stopHelloIngestion?.();
255
+ await Promise.allSettled([
256
+ authCalloutResponder?.close(),
257
+ bus.close(),
258
+ workflowClient.close(),
259
+ dbClient.close(),
260
+ ]);
261
+ },
262
+ };
263
+ }
264
+
265
+ if (import.meta.main) {
266
+ await startApi();
267
+ }
268
+
269
+ export function temporalOverlapPolicy(policy: ScheduledTaskOverlapPolicy): ScheduleOverlapPolicy {
270
+ if (policy === "skip") {
271
+ return ScheduleOverlapPolicy.SKIP;
272
+ }
273
+ if (policy === "buffer_one") {
274
+ return ScheduleOverlapPolicy.BUFFER_ONE;
275
+ }
276
+ return ScheduleOverlapPolicy.ALLOW_ALL;
277
+ }
278
+
279
+ export function shouldCreateScheduleAfterUpdateError(error: unknown): boolean {
280
+ return error instanceof ScheduleNotFoundError;
281
+ }
282
+
283
+ export function temporalScheduleSpec(schedule: ScheduledTaskScheduleSpec): ScheduleSpec {
284
+ if (schedule.type === "interval") {
285
+ return {
286
+ intervals: [{ every: `${schedule.everySeconds}s` }],
287
+ ...(schedule.startAt ? { startAt: new Date(schedule.startAt) } : {}),
288
+ ...(schedule.endAt ? { endAt: new Date(schedule.endAt) } : {}),
289
+ };
290
+ }
291
+ if (schedule.type === "calendar") {
292
+ return {
293
+ calendars: [{
294
+ hour: schedule.hour,
295
+ minute: schedule.minute,
296
+ second: 0,
297
+ ...(schedule.daysOfWeek ? { dayOfWeek: schedule.daysOfWeek } : {}),
298
+ }],
299
+ timezone: schedule.timeZone,
300
+ };
301
+ }
302
+ const runAt = new Date(schedule.runAt);
303
+ return {
304
+ calendars: [{
305
+ year: runAt.getUTCFullYear(),
306
+ month: temporalMonth(runAt.getUTCMonth()),
307
+ dayOfMonth: runAt.getUTCDate(),
308
+ hour: runAt.getUTCHours(),
309
+ minute: runAt.getUTCMinutes(),
310
+ second: runAt.getUTCSeconds(),
311
+ }],
312
+ timezone: "UTC",
313
+ };
314
+ }
315
+
316
+ function temporalMonth(monthIndex: number) {
317
+ return TEMPORAL_MONTHS[monthIndex]!;
318
+ }
319
+
320
+ function temporalScheduleOptions(task: ScheduledTask, taskQueue: string): ScheduleOptions {
321
+ return {
322
+ scheduleId: task.temporalScheduleId,
323
+ spec: temporalScheduleSpec(task.schedule),
324
+ action: {
325
+ type: "startWorkflow",
326
+ workflowType: "scheduledTaskFireWorkflow",
327
+ taskQueue,
328
+ args: [{
329
+ accountId: task.accountId,
330
+ workspaceId: task.workspaceId,
331
+ taskId: task.id,
332
+ triggerType: "scheduled",
333
+ }],
334
+ },
335
+ policies: {
336
+ overlap: temporalOverlapPolicy(task.overlapPolicy),
337
+ catchupWindow: "24h",
338
+ pauseOnFailure: false,
339
+ },
340
+ state: {
341
+ paused: task.status === "paused",
342
+ ...(task.schedule.type === "once" ? { remainingActions: 1 } : {}),
343
+ },
344
+ memo: {
345
+ accountId: task.accountId,
346
+ workspaceId: task.workspaceId,
347
+ scheduledTaskId: task.id,
348
+ name: task.name,
349
+ },
350
+ };
351
+ }
352
+
353
+ function temporalScheduleUpdateOptions(options: ScheduleOptions): ScheduleUpdateOptions {
354
+ return {
355
+ spec: options.spec,
356
+ action: options.action,
357
+ ...(options.policies ? { policies: options.policies } : {}),
358
+ state: options.state ?? {},
359
+ ...(options.searchAttributes ? { searchAttributes: options.searchAttributes } : {}),
360
+ ...(options.typedSearchAttributes ? { typedSearchAttributes: options.typedSearchAttributes } : {}),
361
+ };
362
+ }
@@ -0,0 +1,57 @@
1
+ import {
2
+ getDocumentChunk,
3
+ listDocumentBases,
4
+ searchDocuments,
5
+ type DocumentServices,
6
+ } from "@opengeni/documents";
7
+ import type { Database } from "@opengeni/db";
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import * as z from "zod/v4";
10
+
11
+ export function buildDocumentsMcpServer(db: Database, workspaceId: string, documentServices: DocumentServices): McpServer {
12
+ const server = new McpServer({
13
+ name: "opengeni-documents",
14
+ version: "1.0.0",
15
+ });
16
+
17
+ server.registerTool("list_document_bases", {
18
+ description: "List document bases available for retrieval.",
19
+ inputSchema: {},
20
+ }, async () => ({
21
+ content: [{ type: "text", text: JSON.stringify(await listDocumentBases(db, workspaceId)) }],
22
+ }));
23
+
24
+ server.registerTool("search_documents", {
25
+ description: "Search indexed documents.",
26
+ inputSchema: {
27
+ query: z.string(),
28
+ baseIds: z.array(z.string()).optional(),
29
+ limit: z.number().optional(),
30
+ },
31
+ }, async ({ query, baseIds, limit }) => ({
32
+ content: [{
33
+ type: "text",
34
+ text: JSON.stringify(await searchDocuments(db, {
35
+ workspaceId,
36
+ query,
37
+ ...(baseIds ? { baseIds } : {}),
38
+ ...(limit ? { limit } : {}),
39
+ }, documentServices)),
40
+ }],
41
+ }));
42
+
43
+ server.registerTool("fetch_document_chunk", {
44
+ description: "Fetch one indexed document chunk by id.",
45
+ inputSchema: {
46
+ chunkId: z.string(),
47
+ },
48
+ }, async ({ chunkId }) => {
49
+ const found = await getDocumentChunk(db, workspaceId, chunkId);
50
+ return {
51
+ content: [{ type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` }],
52
+ isError: !found,
53
+ };
54
+ });
55
+
56
+ return server;
57
+ }