@opengeni/db 0.2.2 → 0.4.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.
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ import type {
34
34
  SessionGoal,
35
35
  SessionGoalCreatedBy,
36
36
  SessionGoalStatus,
37
+ SessionMcpServerMetadata,
37
38
  SessionStatus,
38
39
  SessionTurn,
39
40
  SessionTurnSource,
@@ -348,6 +349,7 @@ export const allWorkspacePermissions: Permission[] = [
348
349
  "api_keys:manage",
349
350
  "environments:manage",
350
351
  "environments:use",
352
+ "mcp_servers:attach",
351
353
  "goals:manage",
352
354
  "enrollments:read",
353
355
  "enrollments:manage",
@@ -1200,6 +1202,33 @@ export type EnabledMcpCapabilityServer = {
1200
1202
  headersEncrypted?: Record<string, string>;
1201
1203
  };
1202
1204
 
1205
+ export type CreateSessionMcpServerInput = {
1206
+ id: string;
1207
+ name?: string | null;
1208
+ url: string;
1209
+ allowedTools?: string[] | null;
1210
+ timeoutMs?: number | null;
1211
+ cacheToolsList?: boolean | null;
1212
+ headersEncrypted?: Record<string, string>;
1213
+ };
1214
+
1215
+ export type UpdateSessionMcpServerCredentialsInput = {
1216
+ id: string;
1217
+ headersEncrypted: Record<string, string>;
1218
+ };
1219
+
1220
+ export type UpdateSessionMcpServerCredentialsResult = {
1221
+ servers: SessionMcpServerMetadata[];
1222
+ missingIds: string[];
1223
+ };
1224
+
1225
+ export type SessionMcpServerForRun = SessionMcpServerMetadata & {
1226
+ allowedTools?: string[];
1227
+ timeoutMs?: number;
1228
+ cacheToolsList?: boolean;
1229
+ headers: Record<string, string>;
1230
+ };
1231
+
1203
1232
  export type EnqueueSessionTurnInput = {
1204
1233
  accountId: string;
1205
1234
  workspaceId: string;
@@ -3085,6 +3114,155 @@ function mapWorkspaceEnvironmentVariableMetadata(row: {
3085
3114
  };
3086
3115
  }
3087
3116
 
3117
+ function mapSessionMcpServerMetadata(row: typeof schema.sessionMcpServers.$inferSelect): SessionMcpServerMetadata {
3118
+ return {
3119
+ id: row.serverId,
3120
+ name: row.name ?? null,
3121
+ url: row.url,
3122
+ headerNames: Object.keys(row.headersEncrypted ?? {}).sort(),
3123
+ credentialVersion: Number(row.credentialVersion),
3124
+ };
3125
+ }
3126
+
3127
+ async function sessionMcpServerMetadataForSessions(
3128
+ db: Database,
3129
+ workspaceId: string,
3130
+ sessionIds: string[],
3131
+ ): Promise<Map<string, SessionMcpServerMetadata[]>> {
3132
+ const grouped = new Map<string, SessionMcpServerMetadata[]>();
3133
+ if (sessionIds.length === 0) {
3134
+ return grouped;
3135
+ }
3136
+ const rows = await db.select().from(schema.sessionMcpServers)
3137
+ .where(and(
3138
+ eq(schema.sessionMcpServers.workspaceId, workspaceId),
3139
+ inArray(schema.sessionMcpServers.sessionId, sessionIds),
3140
+ ))
3141
+ .orderBy(asc(schema.sessionMcpServers.createdAt), asc(schema.sessionMcpServers.serverId));
3142
+ for (const row of rows) {
3143
+ const list = grouped.get(row.sessionId) ?? [];
3144
+ list.push(mapSessionMcpServerMetadata(row));
3145
+ grouped.set(row.sessionId, list);
3146
+ }
3147
+ return grouped;
3148
+ }
3149
+
3150
+ async function insertSessionMcpServers(db: Database, input: {
3151
+ accountId: string;
3152
+ workspaceId: string;
3153
+ sessionId: string;
3154
+ servers: CreateSessionMcpServerInput[];
3155
+ }): Promise<SessionMcpServerMetadata[]> {
3156
+ if (input.servers.length === 0) {
3157
+ return [];
3158
+ }
3159
+ const rows = await db.insert(schema.sessionMcpServers).values(input.servers.map((server) => ({
3160
+ accountId: input.accountId,
3161
+ workspaceId: input.workspaceId,
3162
+ sessionId: input.sessionId,
3163
+ serverId: server.id,
3164
+ name: server.name ?? null,
3165
+ url: server.url,
3166
+ allowedTools: server.allowedTools ?? null,
3167
+ timeoutMs: server.timeoutMs ?? null,
3168
+ cacheToolsList: server.cacheToolsList ?? false,
3169
+ headersEncrypted: server.headersEncrypted ?? {},
3170
+ }))).returning();
3171
+ return rows.map(mapSessionMcpServerMetadata);
3172
+ }
3173
+
3174
+ export async function createSessionMcpServers(db: Database, input: {
3175
+ accountId: string;
3176
+ workspaceId: string;
3177
+ sessionId: string;
3178
+ servers: CreateSessionMcpServerInput[];
3179
+ }): Promise<SessionMcpServerMetadata[]> {
3180
+ return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) =>
3181
+ await insertSessionMcpServers(scopedDb, input)
3182
+ );
3183
+ }
3184
+
3185
+ export async function listSessionMcpServerMetadata(db: Database, workspaceId: string, sessionId: string): Promise<SessionMcpServerMetadata[]> {
3186
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3187
+ const grouped = await sessionMcpServerMetadataForSessions(scopedDb, workspaceId, [sessionId]);
3188
+ return grouped.get(sessionId) ?? [];
3189
+ });
3190
+ }
3191
+
3192
+ export async function updateSessionMcpServerCredentials(db: Database, input: {
3193
+ workspaceId: string;
3194
+ sessionId: string;
3195
+ updates: UpdateSessionMcpServerCredentialsInput[];
3196
+ }): Promise<UpdateSessionMcpServerCredentialsResult> {
3197
+ return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => await scopedDb.transaction(async (tx) =>
3198
+ await updateSessionMcpServerCredentialsInTransaction(tx, input)
3199
+ ));
3200
+ }
3201
+
3202
+ async function updateSessionMcpServerCredentialsInTransaction(
3203
+ tx: Pick<Database, "update">,
3204
+ input: {
3205
+ workspaceId: string;
3206
+ sessionId: string;
3207
+ updates: UpdateSessionMcpServerCredentialsInput[];
3208
+ },
3209
+ ): Promise<UpdateSessionMcpServerCredentialsResult> {
3210
+ const servers: SessionMcpServerMetadata[] = [];
3211
+ const missingIds: string[] = [];
3212
+ for (const update of input.updates) {
3213
+ const [row] = await tx.update(schema.sessionMcpServers)
3214
+ .set({
3215
+ headersEncrypted: update.headersEncrypted,
3216
+ credentialVersion: sql`${schema.sessionMcpServers.credentialVersion} + 1`,
3217
+ updatedAt: new Date(),
3218
+ })
3219
+ .where(and(
3220
+ eq(schema.sessionMcpServers.workspaceId, input.workspaceId),
3221
+ eq(schema.sessionMcpServers.sessionId, input.sessionId),
3222
+ eq(schema.sessionMcpServers.serverId, update.id),
3223
+ ))
3224
+ .returning();
3225
+ if (!row) {
3226
+ missingIds.push(update.id);
3227
+ } else {
3228
+ servers.push(mapSessionMcpServerMetadata(row));
3229
+ }
3230
+ }
3231
+ return { servers, missingIds };
3232
+ }
3233
+
3234
+ export async function listSessionMcpServersForRun(
3235
+ db: Database,
3236
+ workspaceId: string,
3237
+ sessionId: string,
3238
+ encryptionKey: Uint8Array,
3239
+ ): Promise<SessionMcpServerForRun[]> {
3240
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3241
+ const rows = await scopedDb.select().from(schema.sessionMcpServers)
3242
+ .where(and(
3243
+ eq(schema.sessionMcpServers.workspaceId, workspaceId),
3244
+ eq(schema.sessionMcpServers.sessionId, sessionId),
3245
+ ))
3246
+ .orderBy(asc(schema.sessionMcpServers.createdAt), asc(schema.sessionMcpServers.serverId));
3247
+ return rows.map((row) => {
3248
+ let headers: Record<string, string>;
3249
+ try {
3250
+ headers = Object.fromEntries(Object.entries(row.headersEncrypted ?? {})
3251
+ .map(([name, stored]) => [name, decryptEnvironmentValue(encryptionKey, stored)]));
3252
+ } catch {
3253
+ throw new Error("session MCP server credential decryption failed");
3254
+ }
3255
+ return {
3256
+ ...mapSessionMcpServerMetadata(row),
3257
+ ...(row.allowedTools ? { allowedTools: row.allowedTools } : {}),
3258
+ ...(row.timeoutMs ? { timeoutMs: row.timeoutMs } : {}),
3259
+ ...(row.cacheToolsList ? { cacheToolsList: row.cacheToolsList } : {}),
3260
+ headers,
3261
+ };
3262
+ });
3263
+ });
3264
+ }
3265
+
3088
3266
  export async function createSession(db: Database, input: {
3089
3267
  accountId: string;
3090
3268
  workspaceId: string;
@@ -3096,6 +3274,9 @@ export async function createSession(db: Database, input: {
3096
3274
  sandboxBackend: SandboxBackend;
3097
3275
  environmentId?: string | null;
3098
3276
  firstPartyMcpPermissions?: Permission[] | null;
3277
+ // Per-session agent persona/system instructions (org-visible, not a secret).
3278
+ // Null/omitted ⇒ the session carries none (composed instructions unchanged).
3279
+ instructions?: string | null;
3099
3280
  parentSessionId?: string | null;
3100
3281
  createIdempotencyKey?: string | null;
3101
3282
  // The shared-sandbox group to join. Omit (or null) for a singleton group:
@@ -3103,6 +3284,7 @@ export async function createSession(db: Database, input: {
3103
3284
  // shared spawn passes the parent's sandboxGroupId so both run in ONE box.
3104
3285
  sandboxGroupId?: string | null;
3105
3286
  sandboxOs?: SandboxOs;
3287
+ mcpServers?: CreateSessionMcpServerInput[];
3106
3288
  }): Promise<Session> {
3107
3289
  // Generate the id up front so the same uuid can seed sandbox_group_id for a
3108
3290
  // singleton group (sandbox_group_id cannot SQL-default to id).
@@ -3122,6 +3304,7 @@ export async function createSession(db: Database, input: {
3122
3304
  sandboxGroupId: input.sandboxGroupId ?? id,
3123
3305
  environmentId: input.environmentId ?? null,
3124
3306
  firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
3307
+ instructions: input.instructions ?? null,
3125
3308
  parentSessionId: input.parentSessionId ?? null,
3126
3309
  createIdempotencyKey: input.createIdempotencyKey ?? null,
3127
3310
  status: "queued",
@@ -3129,7 +3312,13 @@ export async function createSession(db: Database, input: {
3129
3312
  if (!row) {
3130
3313
  throw new Error("Failed to create session");
3131
3314
  }
3132
- return mapSession(row);
3315
+ const mcpServers = await insertSessionMcpServers(scopedDb, {
3316
+ accountId: input.accountId,
3317
+ workspaceId: input.workspaceId,
3318
+ sessionId: row.id,
3319
+ servers: input.mcpServers ?? [],
3320
+ });
3321
+ return mapSession(row, mcpServers);
3133
3322
  });
3134
3323
  }
3135
3324
 
@@ -3154,12 +3343,15 @@ export async function createSessionWithIdempotencyKey(db: Database, input: {
3154
3343
  sandboxBackend: SandboxBackend;
3155
3344
  environmentId?: string | null;
3156
3345
  firstPartyMcpPermissions?: Permission[] | null;
3346
+ // Per-session agent persona/system instructions (org-visible, not a secret).
3347
+ instructions?: string | null;
3157
3348
  parentSessionId?: string | null;
3158
3349
  createIdempotencyKey: string;
3159
3350
  // The shared-sandbox group to join. Omit (or null) for a singleton group
3160
3351
  // (group === the new row's own id); a shared spawn passes the parent's group.
3161
3352
  sandboxGroupId?: string | null;
3162
3353
  sandboxOs?: SandboxOs;
3354
+ mcpServers?: CreateSessionMcpServerInput[];
3163
3355
  }): Promise<{ session: Session; created: boolean }> {
3164
3356
  // Generate the id up front so the same uuid can seed sandbox_group_id for a
3165
3357
  // singleton group (sandbox_group_id cannot SQL-default to id).
@@ -3179,6 +3371,7 @@ export async function createSessionWithIdempotencyKey(db: Database, input: {
3179
3371
  sandboxGroupId: input.sandboxGroupId ?? id,
3180
3372
  environmentId: input.environmentId ?? null,
3181
3373
  firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
3374
+ instructions: input.instructions ?? null,
3182
3375
  parentSessionId: input.parentSessionId ?? null,
3183
3376
  createIdempotencyKey: input.createIdempotencyKey,
3184
3377
  status: "queued",
@@ -3187,7 +3380,13 @@ export async function createSessionWithIdempotencyKey(db: Database, input: {
3187
3380
  where: sql`${schema.sessions.createIdempotencyKey} is not null`,
3188
3381
  }).returning();
3189
3382
  if (inserted) {
3190
- return { session: mapSession(inserted), created: true };
3383
+ const mcpServers = await insertSessionMcpServers(scopedDb, {
3384
+ accountId: input.accountId,
3385
+ workspaceId: input.workspaceId,
3386
+ sessionId: inserted.id,
3387
+ servers: input.mcpServers ?? [],
3388
+ });
3389
+ return { session: mapSession(inserted, mcpServers), created: true };
3191
3390
  }
3192
3391
  const [existing] = await scopedDb.select().from(schema.sessions).where(and(
3193
3392
  eq(schema.sessions.workspaceId, input.workspaceId),
@@ -3199,7 +3398,8 @@ export async function createSessionWithIdempotencyKey(db: Database, input: {
3199
3398
  // than silently returning a phantom.
3200
3399
  throw new Error("Failed to create session under idempotency key");
3201
3400
  }
3202
- return { session: mapSession(existing), created: false };
3401
+ const grouped = await sessionMcpServerMetadataForSessions(scopedDb, input.workspaceId, [existing.id]);
3402
+ return { session: mapSession(existing, grouped.get(existing.id) ?? []), created: false };
3203
3403
  });
3204
3404
  }
3205
3405
 
@@ -3209,14 +3409,18 @@ export async function getSessionByCreateIdempotencyKey(db: Database, workspaceId
3209
3409
  eq(schema.sessions.workspaceId, workspaceId),
3210
3410
  eq(schema.sessions.createIdempotencyKey, createIdempotencyKey),
3211
3411
  )).limit(1);
3212
- return row ? mapSession(row) : null;
3412
+ if (!row) return null;
3413
+ const grouped = await sessionMcpServerMetadataForSessions(scopedDb, workspaceId, [row.id]);
3414
+ return mapSession(row, grouped.get(row.id) ?? []);
3213
3415
  });
3214
3416
  }
3215
3417
 
3216
3418
  export async function getSession(db: Database, workspaceId: string, sessionId: string): Promise<Session | null> {
3217
3419
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3218
3420
  const [row] = await scopedDb.select().from(schema.sessions).where(and(eq(schema.sessions.workspaceId, workspaceId), eq(schema.sessions.id, sessionId))).limit(1);
3219
- return row ? mapSession(row) : null;
3421
+ if (!row) return null;
3422
+ const grouped = await sessionMcpServerMetadataForSessions(scopedDb, workspaceId, [row.id]);
3423
+ return mapSession(row, grouped.get(row.id) ?? []);
3220
3424
  });
3221
3425
  }
3222
3426
 
@@ -3262,7 +3466,8 @@ export async function listSessions(db: Database, workspaceId: string, limit = 50
3262
3466
  .where(eq(schema.sessions.workspaceId, workspaceId))
3263
3467
  .orderBy(desc(schema.sessions.createdAt), desc(schema.sessions.id))
3264
3468
  .limit(limit);
3265
- return rows.map(mapSession);
3469
+ const grouped = await sessionMcpServerMetadataForSessions(scopedDb, workspaceId, rows.map((row) => row.id));
3470
+ return rows.map((row) => mapSession(row, grouped.get(row.id) ?? []));
3266
3471
  });
3267
3472
  }
3268
3473
 
@@ -3294,16 +3499,63 @@ export async function requireSession(db: Database, workspaceId: string, sessionI
3294
3499
  return session;
3295
3500
  }
3296
3501
 
3297
- export async function listSessionEvents(db: Database, workspaceId: string, sessionId: string, after = 0, limit = 500): Promise<SessionEvent[]> {
3502
+ export type ListSessionEventsOptions = {
3503
+ after?: number;
3504
+ before?: number;
3505
+ limit?: number;
3506
+ };
3507
+
3508
+ const POSTGRES_INT_MAX = 2_147_483_647;
3509
+
3510
+ export async function listSessionEvents(db: Database, workspaceId: string, sessionId: string): Promise<SessionEvent[]>;
3511
+ export async function listSessionEvents(db: Database, workspaceId: string, sessionId: string, after: number, limit?: number): Promise<SessionEvent[]>;
3512
+ export async function listSessionEvents(db: Database, workspaceId: string, sessionId: string, options: ListSessionEventsOptions): Promise<SessionEvent[]>;
3513
+ export async function listSessionEvents(
3514
+ db: Database,
3515
+ workspaceId: string,
3516
+ sessionId: string,
3517
+ afterOrOptions: number | ListSessionEventsOptions = 0,
3518
+ legacyLimit = 500,
3519
+ ): Promise<SessionEvent[]> {
3520
+ const options = typeof afterOrOptions === "number"
3521
+ ? { after: afterOrOptions, limit: legacyLimit }
3522
+ : afterOrOptions;
3523
+ const after = normalizeEventSequence(options.after, 0);
3524
+ const limit = normalizeEventLimit(options.limit, 500);
3525
+ const hasBefore = options.before !== undefined && Number.isFinite(options.before);
3526
+ const before = hasBefore ? Math.floor(options.before as number) : undefined;
3527
+
3298
3528
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3529
+ const filters: SQL[] = [
3530
+ eq(schema.sessionEvents.workspaceId, workspaceId),
3531
+ eq(schema.sessionEvents.sessionId, sessionId),
3532
+ gt(schema.sessionEvents.sequence, after),
3533
+ ];
3534
+ if (before !== undefined && before <= POSTGRES_INT_MAX) {
3535
+ filters.push(lt(schema.sessionEvents.sequence, before));
3536
+ }
3299
3537
  const rows = await scopedDb.select().from(schema.sessionEvents)
3300
- .where(and(eq(schema.sessionEvents.workspaceId, workspaceId), eq(schema.sessionEvents.sessionId, sessionId), gt(schema.sessionEvents.sequence, after)))
3301
- .orderBy(asc(schema.sessionEvents.sequence))
3538
+ .where(and(...filters))
3539
+ .orderBy(hasBefore ? desc(schema.sessionEvents.sequence) : asc(schema.sessionEvents.sequence))
3302
3540
  .limit(limit);
3303
- return rows.map(mapEvent);
3541
+ return (hasBefore ? rows.reverse() : rows).map(mapEvent);
3304
3542
  });
3305
3543
  }
3306
3544
 
3545
+ function normalizeEventSequence(value: number | undefined, fallback: number): number {
3546
+ if (value === undefined || !Number.isFinite(value)) {
3547
+ return fallback;
3548
+ }
3549
+ return Math.floor(value);
3550
+ }
3551
+
3552
+ function normalizeEventLimit(value: number | undefined, fallback: number): number {
3553
+ if (value === undefined || !Number.isFinite(value)) {
3554
+ return fallback;
3555
+ }
3556
+ return Math.max(0, Math.floor(value));
3557
+ }
3558
+
3307
3559
  export async function getSessionEvent(db: Database, workspaceId: string, eventId: string): Promise<SessionEvent | null> {
3308
3560
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3309
3561
  const [row] = await scopedDb.select().from(schema.sessionEvents).where(and(eq(schema.sessionEvents.workspaceId, workspaceId), eq(schema.sessionEvents.id, eventId))).limit(1);
@@ -3384,8 +3636,8 @@ export async function getSessionHistoryItems(db: Database, workspaceId: string,
3384
3636
 
3385
3637
  /**
3386
3638
  * The LIVE conversation-truth read path: only active rows, position-ordered.
3387
- * After a client-side context compaction this returns [active summary,
3388
- * ...active recent tail]; with no compaction yet it equals
3639
+ * After a client-side context compaction this returns [retained user messages,
3640
+ * active summary]; with no compaction yet it equals
3389
3641
  * getSessionHistoryItems. The model-facing read path uses this so superseded
3390
3642
  * (summarized-away) prefix rows are excluded while the full transcript stays in
3391
3643
  * the table as an audit trail.
@@ -3555,8 +3807,15 @@ export async function applyContextCompaction(db: Database, input: {
3555
3807
  turnId?: string | null;
3556
3808
  /** Active prefix rows with position < boundaryPosition get superseded. */
3557
3809
  boundaryPosition: number;
3558
- /** Fractional position for the new summary row (must be < boundaryPosition). */
3810
+ /** Position for the new summary row. Old boundary mode uses a fractional half-step before the kept tail. */
3559
3811
  summaryPosition: number;
3812
+ /**
3813
+ * Optional replacement rows inserted after superseding the old active set.
3814
+ * Used by Codex-parity client compaction to rebuild active history as retained
3815
+ * user messages plus one summary. These rows are synthetic replay rows, so
3816
+ * they intentionally do not inherit the current compaction turn id.
3817
+ */
3818
+ replacementItems?: Array<{ position: number; item: Record<string, unknown> }>;
3560
3819
  summaryItem: Record<string, unknown>;
3561
3820
  }): Promise<void> {
3562
3821
  await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId }, async (scopedDb) => {
@@ -3569,6 +3828,20 @@ export async function applyContextCompaction(db: Database, input: {
3569
3828
  eq(schema.sessionHistoryItems.active, true),
3570
3829
  lt(schema.sessionHistoryItems.position, input.boundaryPosition),
3571
3830
  ));
3831
+ if (input.replacementItems && input.replacementItems.length > 0) {
3832
+ await tx.insert(schema.sessionHistoryItems).values(input.replacementItems.map((entry) => ({
3833
+ accountId: input.accountId,
3834
+ workspaceId: input.workspaceId,
3835
+ sessionId: input.sessionId,
3836
+ turnId: null,
3837
+ position: entry.position,
3838
+ item: sanitizeEventPayload(entry.item),
3839
+ active: true,
3840
+ }))).onConflictDoUpdate({
3841
+ target: [schema.sessionHistoryItems.workspaceId, schema.sessionHistoryItems.sessionId, schema.sessionHistoryItems.position],
3842
+ set: { active: true },
3843
+ });
3844
+ }
3572
3845
  // Insert the summary at its FRACTIONAL position. The supersede step above
3573
3846
  // also sets active=false for any rows with position < boundaryPosition —
3574
3847
  // which on a RETRY includes the summary itself (it sits below the
@@ -4215,6 +4488,14 @@ export interface LeaseSnapshot {
4215
4488
  expiresAt: Date;
4216
4489
  }
4217
4490
 
4491
+ export interface LiveModalSandboxLeaseAttribution {
4492
+ leaseId: string;
4493
+ workspaceId: string;
4494
+ sandboxGroupId: string;
4495
+ instanceId: string | null;
4496
+ liveness: SandboxLeaseLiveness;
4497
+ }
4498
+
4218
4499
  export interface AcquireLeaseInput {
4219
4500
  accountId: string;
4220
4501
  workspaceId: string;
@@ -4514,6 +4795,40 @@ export async function commitWarmingToWarm(db: Database, input: {
4514
4795
  });
4515
4796
  }
4516
4797
 
4798
+ // §4.2a — leak-proof create attribution. The spawner calls this immediately
4799
+ // after the provider create returns, before display/readiness/setup work. It
4800
+ // intentionally does NOT bump lease_epoch or mark the lease warm; it only makes
4801
+ // the just-created provider id durable while the row is still warming so a
4802
+ // failure/reaper/provider-side sweep can identify and stop it.
4803
+ export async function recordWarmingSandboxCreated(db: Database, input: {
4804
+ accountId: string;
4805
+ workspaceId: string;
4806
+ sandboxGroupId: string;
4807
+ expectedEpoch: number;
4808
+ instanceId: string;
4809
+ resumeBackendId?: string | null;
4810
+ resumeState?: Record<string, unknown> | null;
4811
+ leaseTtlMs: number;
4812
+ }): Promise<{ recorded: boolean; lease: LeaseSnapshot | null }> {
4813
+ return await withRlsContext(db, { accountId: input.accountId, workspaceId: input.workspaceId },
4814
+ async (scopedDb) => {
4815
+ const resumeStateJson = input.resumeState == null ? null : JSON.stringify(input.resumeState);
4816
+ const rows = await scopedDb.execute<LeaseRow>(sql`
4817
+ update sandbox_leases set
4818
+ instance_id = ${input.instanceId},
4819
+ resume_backend_id = ${input.resumeBackendId ?? null},
4820
+ resume_state = ${resumeStateJson}::jsonb,
4821
+ expires_at = now() + (${String(input.leaseTtlMs)} || ' milliseconds')::interval,
4822
+ updated_at = now()
4823
+ where workspace_id = ${input.workspaceId} and sandbox_group_id = ${input.sandboxGroupId}
4824
+ and liveness = 'warming' and lease_epoch = ${input.expectedEpoch}
4825
+ returning *
4826
+ `);
4827
+ if (rows.length === 0) return { recorded: false, lease: null };
4828
+ return { recorded: true, lease: mapLeaseRow(rows[0]!) };
4829
+ });
4830
+ }
4831
+
4517
4832
  // §4.3 — caught spawn failure: warming -> cold (W3). Holders are intentionally
4518
4833
  // left intact — the arrival that triggered the spawn still wants a box, so the
4519
4834
  // next acquireLease re-CAS cold->warming.
@@ -4696,15 +5011,34 @@ export async function reapStaleLeaseHolders(db: Database, input: {
4696
5011
  where L.id = c.id and L.workspace_id = ${input.workspaceId}
4697
5012
  `);
4698
5013
 
4699
- // (c) WARMING-death: a 'warming' row whose LEASE TTL lapsed = an uncaught
4700
- // spawner death. Reset to cold so a queued turn can re-acquire and re-spawn.
5014
+ // (c1) WARMING-death before provider create returned: no instance_id was
5015
+ // ever persisted, so there is no provider box to stop. Reset to cold so a
5016
+ // queued turn can re-acquire and re-spawn.
4701
5017
  const warmingReset = await tx.execute<{ id: string }>(sql`
4702
5018
  update sandbox_leases set
4703
5019
  liveness = 'cold', instance_id = null,
4704
5020
  resume_backend_id = null, resume_state = null,
4705
5021
  data_plane_url = null, terminal_data_plane_url = null, updated_at = now()
4706
5022
  where workspace_id = ${input.workspaceId}
4707
- and liveness = 'warming' and expires_at < now()
5023
+ and liveness = 'warming' and expires_at < now() and instance_id is null
5024
+ returning id
5025
+ `);
5026
+
5027
+ // (c2) WARMING-death after provider create returned: instance_id is known,
5028
+ // so do NOT drop it. Convert to immediately-drainable so the caller's
5029
+ // provider terminate path stops the box before the lease goes cold.
5030
+ const warmingDrain = await tx.execute<{ id: string }>(sql`
5031
+ update sandbox_leases set
5032
+ liveness = 'draining',
5033
+ refcount = 0,
5034
+ turn_holders = 0,
5035
+ viewer_holders = 0,
5036
+ data_plane_url = null,
5037
+ terminal_data_plane_url = null,
5038
+ expires_at = now() - interval '1 millisecond',
5039
+ updated_at = now()
5040
+ where workspace_id = ${input.workspaceId}
5041
+ and liveness = 'warming' and expires_at < now() and instance_id is not null
4708
5042
  returning id
4709
5043
  `);
4710
5044
 
@@ -4719,7 +5053,7 @@ export async function reapStaleLeaseHolders(db: Database, input: {
4719
5053
 
4720
5054
  return {
4721
5055
  reapedViewers: reaped.length,
4722
- warmingReset: warmingReset.length,
5056
+ warmingReset: warmingReset.length + warmingDrain.length,
4723
5057
  drained: drainable.map((r) => ({
4724
5058
  workspaceId: input.workspaceId,
4725
5059
  sandboxGroupId: r.sandbox_group_id,
@@ -4779,6 +5113,28 @@ export async function listMeterableWarmLeases(db: Database): Promise<MeterableWa
4779
5113
  }));
4780
5114
  }
4781
5115
 
5116
+ // Cross-workspace live Modal lease read for the provider-side orphan sweep. The
5117
+ // SECURITY DEFINER function is the sanctioned RLS bypass; see migration 0036.
5118
+ export async function listLiveModalSandboxLeaseAttributions(db: Database): Promise<LiveModalSandboxLeaseAttribution[]> {
5119
+ const rows = await rawRows<{
5120
+ lease_id: string;
5121
+ workspace_id: string;
5122
+ sandbox_group_id: string;
5123
+ instance_id: string | null;
5124
+ liveness: SandboxLeaseLiveness;
5125
+ }>(db, sql`
5126
+ select lease_id, workspace_id, sandbox_group_id, instance_id, liveness
5127
+ from opengeni_private.list_live_modal_sandbox_leases()
5128
+ `);
5129
+ return rows.map((r) => ({
5130
+ leaseId: r.lease_id,
5131
+ workspaceId: r.workspace_id,
5132
+ sandboxGroupId: r.sandbox_group_id,
5133
+ instanceId: r.instance_id,
5134
+ liveness: r.liveness,
5135
+ }));
5136
+ }
5137
+
4782
5138
  // §4.7 — explicit re-arm seam (D1). acquireLease already re-arms a draining
4783
5139
  // lease inline; this is the standalone version for callers that learn a holder
4784
5140
  // is wanted during the grace window without going through acquireLease first.
@@ -7275,7 +7631,11 @@ export async function appendSessionEventsAndUpdateSession(db: Database, workspac
7275
7631
  }));
7276
7632
  }
7277
7633
 
7278
- export async function appendSessionEventsWithLockedSessionUpdate(db: Database, workspaceId: string, sessionId: string, build: (session: Session) => {
7634
+ type LockedSessionUpdateContext = {
7635
+ updateSessionMcpServerCredentials: (updates: UpdateSessionMcpServerCredentialsInput[]) => Promise<UpdateSessionMcpServerCredentialsResult>;
7636
+ };
7637
+
7638
+ type LockedSessionUpdateResult = {
7279
7639
  events: AppendEventInput[];
7280
7640
  update?: {
7281
7641
  resources?: ResourceRef[];
@@ -7285,13 +7645,22 @@ export async function appendSessionEventsWithLockedSessionUpdate(db: Database, w
7285
7645
  status?: SessionStatus;
7286
7646
  activeTurnId?: string | null;
7287
7647
  };
7288
- }): Promise<SessionEvent[]> {
7648
+ };
7649
+
7650
+ export async function appendSessionEventsWithLockedSessionUpdate(
7651
+ db: Database,
7652
+ workspaceId: string,
7653
+ sessionId: string,
7654
+ build: (session: Session, context: LockedSessionUpdateContext) => LockedSessionUpdateResult | Promise<LockedSessionUpdateResult>,
7655
+ ): Promise<SessionEvent[]> {
7289
7656
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => await scopedDb.transaction(async (tx) => {
7290
7657
  const [sessionRow] = await tx.select().from(schema.sessions).where(and(eq(schema.sessions.workspaceId, workspaceId), eq(schema.sessions.id, sessionId))).for("update").limit(1);
7291
7658
  if (!sessionRow) {
7292
7659
  throw new Error(`Session not found: ${sessionId}`);
7293
7660
  }
7294
- const built = build(mapSession(sessionRow));
7661
+ const built = await build(mapSession(sessionRow), {
7662
+ updateSessionMcpServerCredentials: async (updates) => await updateSessionMcpServerCredentialsInTransaction(tx, { workspaceId, sessionId, updates }),
7663
+ });
7295
7664
  if (built.events.length === 0) {
7296
7665
  return [];
7297
7666
  }
@@ -7330,7 +7699,7 @@ export function sessionSubject(workspaceId: string, sessionId: string): string {
7330
7699
  return `workspaces.${workspaceId}.sessions.${sessionId}.events`;
7331
7700
  }
7332
7701
 
7333
- function mapSession(row: typeof schema.sessions.$inferSelect): Session {
7702
+ function mapSession(row: typeof schema.sessions.$inferSelect, mcpServers: SessionMcpServerMetadata[] = []): Session {
7334
7703
  return {
7335
7704
  id: row.id,
7336
7705
  accountId: row.accountId,
@@ -7339,6 +7708,7 @@ function mapSession(row: typeof schema.sessions.$inferSelect): Session {
7339
7708
  initialMessage: row.initialMessage,
7340
7709
  title: row.title ?? null,
7341
7710
  titleSource: (row.titleSource as "user" | "agent" | null) ?? null,
7711
+ instructions: row.instructions ?? null,
7342
7712
  resources: row.resources as ResourceRef[],
7343
7713
  tools: row.tools as ToolRef[],
7344
7714
  metadata: row.metadata,
@@ -7353,6 +7723,7 @@ function mapSession(row: typeof schema.sessions.$inferSelect): Session {
7353
7723
  activeEpoch: Number(row.activeEpoch),
7354
7724
  environmentId: row.environmentId,
7355
7725
  firstPartyMcpPermissions: (row.firstPartyMcpPermissions as Permission[] | null) ?? null,
7726
+ mcpServers,
7356
7727
  parentSessionId: row.parentSessionId ?? null,
7357
7728
  createIdempotencyKey: row.createIdempotencyKey ?? null,
7358
7729
  temporalWorkflowId: row.temporalWorkflowId,
package/src/schema.ts CHANGED
@@ -187,6 +187,13 @@ export const sessions = pgTable("sessions", {
187
187
  initialMessage: text("initial_message").notNull(),
188
188
  title: text("title"),
189
189
  titleSource: text("title_source"),
190
+ // Per-session agent persona/system instructions supplied at create (the
191
+ // per-agent-type prompt lever for embedding hosts). NULL ⇒ the session
192
+ // carried none, so the composed agent instructions are byte-identical to a
193
+ // workspace-only persona (no backfill, no behavior change for existing rows).
194
+ // Composed system-level AFTER the workspace agentInstructions; never emitted
195
+ // as a timeline event.
196
+ instructions: text("instructions"),
190
197
  resources: jsonb("resources").$type<unknown[]>().notNull().default([]),
191
198
  tools: jsonb("tools").$type<unknown[]>().notNull().default([]),
192
199
  metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
@@ -283,6 +290,28 @@ export const sessions = pgTable("sessions", {
283
290
  createIdempotency: uniqueIndex("sessions_workspace_create_idempotency_idx").on(table.workspaceId, table.createIdempotencyKey).where(sql`${table.createIdempotencyKey} is not null`),
284
291
  }));
285
292
 
293
+ export const sessionMcpServers = pgTable("session_mcp_servers", {
294
+ id: uuid("id").primaryKey().defaultRandom(),
295
+ accountId: uuid("account_id").notNull().references(() => managedAccounts.id, { onDelete: "cascade" }),
296
+ workspaceId: uuid("workspace_id").notNull().references(() => workspaces.id, { onDelete: "cascade" }),
297
+ sessionId: uuid("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
298
+ serverId: text("server_id").notNull(),
299
+ name: text("name"),
300
+ url: text("url").notNull(),
301
+ allowedTools: jsonb("allowed_tools").$type<string[]>(),
302
+ timeoutMs: integer("timeout_ms"),
303
+ cacheToolsList: boolean("cache_tools_list").notNull().default(false),
304
+ // Map of header name -> AES-GCM ciphertext. Values are decrypted only by the
305
+ // worker's run-preparation path and never returned by API helpers.
306
+ headersEncrypted: jsonb("headers_encrypted").$type<Record<string, string>>().notNull().default({}),
307
+ credentialVersion: integer("credential_version").notNull().default(1),
308
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
309
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
310
+ }, (table) => ({
311
+ sessionServer: uniqueIndex("session_mcp_servers_session_server_idx").on(table.workspaceId, table.sessionId, table.serverId),
312
+ session: index("session_mcp_servers_session_idx").on(table.workspaceId, table.sessionId),
313
+ }));
314
+
286
315
  export const files = pgTable("files", {
287
316
  id: uuid("id").primaryKey().defaultRandom(),
288
317
  accountId: uuid("account_id").notNull().references(() => managedAccounts.id, { onDelete: "cascade" }),