@opengeni/db 3.2.0-canary.0 → 3.4.0-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 (81) hide show
  1. package/dist/canonical-human-identities.js +3 -3
  2. package/dist/{chunk-SIBYBA2D.js → chunk-6DPJA36C.js} +30 -11
  3. package/dist/chunk-6DPJA36C.js.map +1 -0
  4. package/dist/{chunk-KRLPEMLH.js → chunk-C2AFP7SB.js} +184 -6
  5. package/dist/chunk-C2AFP7SB.js.map +1 -0
  6. package/dist/{chunk-AZHW7R5V.js → chunk-H7MXYG7R.js} +12 -8
  7. package/dist/chunk-H7MXYG7R.js.map +1 -0
  8. package/dist/{chunk-2PRS4J5Q.js → chunk-HY3F5Q4E.js} +3 -3
  9. package/dist/{chunk-NAB6FKFN.js → chunk-O6FTELKH.js} +3 -3
  10. package/dist/{chunk-UZNXG44K.js → chunk-PYMY3AJW.js} +324 -8
  11. package/dist/chunk-PYMY3AJW.js.map +1 -0
  12. package/dist/{chunk-6HMM2R5T.js → chunk-TRBOWUG3.js} +98 -4
  13. package/dist/chunk-TRBOWUG3.js.map +1 -0
  14. package/dist/{chunk-E6CLMKEE.js → chunk-V3TRUXFJ.js} +2 -2
  15. package/dist/{chunk-2EH4WRHL.js → chunk-ZWRIPCTR.js} +2 -2
  16. package/dist/editable-artifact-durable-export.js +2 -2
  17. package/dist/editable-artifacts.js +3 -3
  18. package/dist/governed-learning-activation.d.ts +16 -0
  19. package/dist/index.d.ts +108 -7
  20. package/dist/index.js +3605 -1417
  21. package/dist/index.js.map +1 -1
  22. package/dist/insights-model-bundle.d.ts +30 -0
  23. package/dist/insights-usage-bundle.d.ts +32 -0
  24. package/dist/insights.d.ts +10 -2
  25. package/dist/lossless-json.d.ts +1 -1
  26. package/dist/managed-user-setup.d.ts +82 -0
  27. package/dist/memory-domain.d.ts +2 -2
  28. package/dist/migrate.d.ts +3 -1
  29. package/dist/migrate.js +1 -1
  30. package/dist/new-session-drafts.d.ts +4 -4
  31. package/dist/organization-membership-lifecycle.d.ts +60 -1
  32. package/dist/pr-review.d.ts +44 -3
  33. package/dist/provision-roles.js +1 -1
  34. package/dist/runtime-posture.d.ts +12 -7
  35. package/dist/schema.d.ts +839 -101
  36. package/dist/schema.js +9 -1
  37. package/dist/session-background-commands.js +3 -3
  38. package/dist/session-tenancy.d.ts +12 -1
  39. package/dist/session-tenancy.js +3 -3
  40. package/dist/video-generation.js +3 -3
  41. package/drizzle/0348_named_signup_and_user_setup.sql +838 -0
  42. package/drizzle/0349_greenfield_session_tenancy_activation.sql +792 -0
  43. package/drizzle/0350_organization_shared_workspace_administration.sql +889 -0
  44. package/drizzle/0351_organization_user_setup_delivery.sql +978 -0
  45. package/drizzle/0352_session_variable_set_attachments.sql +1661 -0
  46. package/drizzle/0353_automatic_session_title_policy_fence.sql +476 -0
  47. package/drizzle/0354_automatic_session_title_quarantine_index.sql +9 -0
  48. package/drizzle/0355_automatic_session_title_quarantine.sql +92 -0
  49. package/drizzle/0356_set_based_insights_session_visibility.sql +237 -0
  50. package/drizzle/0357_rig_platform_base_only.sql +44 -0
  51. package/drizzle/0358_pr_review_managed_github_app.sql +108 -0
  52. package/drizzle/0359_insights_force_rls_read_capability.sql +936 -0
  53. package/drizzle/0360_organization_identity_confirmation_prompt.sql +103 -0
  54. package/drizzle/0361_remember_knowledge_memory_materialization.sql +249 -0
  55. package/package.json +6 -6
  56. package/src/database.ts +12 -8
  57. package/src/governed-learning-activation.ts +74 -2
  58. package/src/index.ts +879 -92
  59. package/src/insights-model-bundle.ts +681 -0
  60. package/src/insights-usage-bundle.ts +240 -0
  61. package/src/insights.ts +265 -152
  62. package/src/lossless-json.ts +2 -1
  63. package/src/managed-user-setup.ts +316 -0
  64. package/src/memory-domain.ts +3 -3
  65. package/src/migrate.ts +38 -14
  66. package/src/new-session-drafts.ts +22 -15
  67. package/src/organization-membership-lifecycle.ts +253 -1
  68. package/src/pr-review.ts +449 -6
  69. package/src/provision-roles.ts +92 -0
  70. package/src/runtime-posture.ts +247 -4
  71. package/src/schema.ts +210 -4
  72. package/src/session-tenancy.ts +120 -2
  73. package/dist/chunk-6HMM2R5T.js.map +0 -1
  74. package/dist/chunk-AZHW7R5V.js.map +0 -1
  75. package/dist/chunk-KRLPEMLH.js.map +0 -1
  76. package/dist/chunk-SIBYBA2D.js.map +0 -1
  77. package/dist/chunk-UZNXG44K.js.map +0 -1
  78. /package/dist/{chunk-2PRS4J5Q.js.map → chunk-HY3F5Q4E.js.map} +0 -0
  79. /package/dist/{chunk-NAB6FKFN.js.map → chunk-O6FTELKH.js.map} +0 -0
  80. /package/dist/{chunk-E6CLMKEE.js.map → chunk-V3TRUXFJ.js.map} +0 -0
  81. /package/dist/{chunk-2EH4WRHL.js.map → chunk-ZWRIPCTR.js.map} +0 -0
package/src/index.ts CHANGED
@@ -18,9 +18,11 @@ import {
18
18
  OrganizationMember,
19
19
  WorkspaceMember as WorkspaceMemberContract,
20
20
  SessionGoalSnapshot,
21
+ AUTOMATIC_SESSION_TITLE_FALLBACK,
21
22
  ScheduledTaskRunAcceptedExecution,
22
23
  SessionGoalRootConstraintsWrite,
23
24
  normalizeSessionGoalRootConstraints,
25
+ normalizeAutomaticSessionTitle,
24
26
  sessionVisibilityToPublic,
25
27
  sessionGoalUtf8Bytes,
26
28
  } from "@opengeni/contracts";
@@ -425,6 +427,7 @@ export * from "./company-brain-context-selection";
425
427
  export * from "./knowledge-source-sync";
426
428
  export * from "./task-notes";
427
429
  export * from "./managed-human-provisioning";
430
+ export * from "./managed-user-setup";
428
431
  export * from "./organization-membership-backfill";
429
432
  export * from "./connection-tenancy-backfill";
430
433
  export * from "./generated-images";
@@ -451,6 +454,8 @@ export * from "./capability-integrations";
451
454
  export * from "./integration-bindings";
452
455
  export * from "./integration-facets";
453
456
  export * from "./insights";
457
+ export * from "./insights-model-bundle";
458
+ export * from "./insights-usage-bundle";
454
459
  export * from "./organization-membership-lifecycle";
455
460
  import { assertActiveManagedHumanOrganizationMembership } from "./organization-membership-lifecycle";
456
461
  // Deliberately NOT `export *`: `accountIdInRlsScope` is an internal convenience
@@ -1502,6 +1507,7 @@ export async function ensureManagedAccessForUser(
1502
1507
  email: string;
1503
1508
  name: string;
1504
1509
  emailVerified?: boolean;
1510
+ provisionFallbackOrganization?: boolean;
1505
1511
  },
1506
1512
  ): Promise<AccessContext> {
1507
1513
  return (await ensureManagedAccessForUserWithOrganizationMemberships(db, input)).accessContext;
@@ -1646,6 +1652,7 @@ export async function ensureManagedAccessForUserWithOrganizationMemberships(
1646
1652
  email: string;
1647
1653
  name: string;
1648
1654
  emailVerified?: boolean;
1655
+ provisionFallbackOrganization?: boolean;
1649
1656
  },
1650
1657
  ): Promise<ManagedAccessProvisioningResult> {
1651
1658
  const subjectId = `user:${input.userId}`;
@@ -1710,18 +1717,6 @@ export async function ensureManagedAccessForUserWithOrganizationMemberships(
1710
1717
  (membership) => membership.status === "suspended" || membership.status === "revoked",
1711
1718
  )
1712
1719
  ) {
1713
- if (
1714
- fallbackAccount &&
1715
- existingOrganizationMemberships.some(
1716
- (membership) => membership.organizationId === fallbackAccount.id,
1717
- )
1718
- ) {
1719
- return await projectManagedOrganizationAccess(txDb, {
1720
- subjectId,
1721
- subjectLabel,
1722
- organizationMemberships: existingOrganizationMemberships,
1723
- });
1724
- }
1725
1720
  return {
1726
1721
  accessContext: {
1727
1722
  mode: "managed",
@@ -1755,6 +1750,20 @@ export async function ensureManagedAccessForUserWithOrganizationMemberships(
1755
1750
  organizationMemberships: [],
1756
1751
  };
1757
1752
  }
1753
+ if (input.provisionFallbackOrganization === false) {
1754
+ return {
1755
+ accessContext: {
1756
+ mode: "managed",
1757
+ subjectId,
1758
+ subjectLabel,
1759
+ accountGrants: [],
1760
+ workspaceGrants: [],
1761
+ defaultAccountId: null,
1762
+ defaultWorkspaceId: null,
1763
+ },
1764
+ organizationMemberships: [],
1765
+ };
1766
+ }
1758
1767
  const accountName = input.name || input.email;
1759
1768
  let [account] = await tx
1760
1769
  .select()
@@ -1776,7 +1785,9 @@ export async function ensureManagedAccessForUserWithOrganizationMemberships(
1776
1785
  })
1777
1786
  .onConflictDoUpdate({
1778
1787
  target: [schema.managedAccounts.externalSource, schema.managedAccounts.externalId],
1779
- set: { name: accountName, updatedAt: new Date() },
1788
+ // The insert race may converge here, but later access refreshes must
1789
+ // never turn one-shot signup intent into an organization rename.
1790
+ set: { updatedAt: new Date() },
1780
1791
  })
1781
1792
  .returning();
1782
1793
  }
@@ -1810,7 +1821,8 @@ export async function ensureManagedAccessForUserWithOrganizationMemberships(
1810
1821
  })
1811
1822
  .onConflictDoUpdate({
1812
1823
  target: [schema.workspaces.externalSource, schema.workspaces.externalId],
1813
- set: { name: "Default workspace", updatedAt: new Date() },
1824
+ // Preserve a later administrator rename on legacy fallback workspaces.
1825
+ set: { updatedAt: new Date() },
1814
1826
  })
1815
1827
  .returning();
1816
1828
  }
@@ -2050,7 +2062,13 @@ export async function getWorkspace(db: Database, workspaceId: string): Promise<W
2050
2062
  .from(schema.workspaces)
2051
2063
  .where(eq(schema.workspaces.id, workspaceId))
2052
2064
  .limit(1);
2053
- return row ? mapWorkspace(row, await workspaceControlProjection(db, row.id)) : null;
2065
+ return row
2066
+ ? mapWorkspace(
2067
+ row,
2068
+ await workspaceControlProjection(db, row.id),
2069
+ await workspaceKindProjection(db, row),
2070
+ )
2071
+ : null;
2054
2072
  }
2055
2073
 
2056
2074
  export async function getManagedAccount(
@@ -2087,7 +2105,11 @@ export async function listWorkspacesForSubject(
2087
2105
  .limit(limit);
2088
2106
  return await Promise.all(
2089
2107
  rows.map(async (row) =>
2090
- mapWorkspace(row.workspace, await workspaceControlProjection(db, row.workspace.id)),
2108
+ mapWorkspace(
2109
+ row.workspace,
2110
+ await workspaceControlProjection(db, row.workspace.id),
2111
+ await workspaceKindProjection(db, row.workspace),
2112
+ ),
2091
2113
  ),
2092
2114
  );
2093
2115
  }
@@ -2136,13 +2158,17 @@ export async function createWorkspace(
2136
2158
  workspaceId: row.id,
2137
2159
  accountId: row.accountId,
2138
2160
  });
2139
- return mapWorkspace(row, {
2140
- state: "active",
2141
- revision: 0,
2142
- reason: null,
2143
- changedBy: null,
2144
- changedAt: null,
2145
- });
2161
+ return mapWorkspace(
2162
+ row,
2163
+ {
2164
+ state: "active",
2165
+ revision: 0,
2166
+ reason: null,
2167
+ changedBy: null,
2168
+ changedAt: null,
2169
+ },
2170
+ "shared",
2171
+ );
2146
2172
  });
2147
2173
  }
2148
2174
 
@@ -2356,7 +2382,11 @@ export async function updateWorkspace(
2356
2382
  if (!row) {
2357
2383
  throw new Error(`Workspace not found: ${workspaceId}`);
2358
2384
  }
2359
- return mapWorkspace(row, await workspaceControlProjection(db, workspaceId));
2385
+ return mapWorkspace(
2386
+ row,
2387
+ await workspaceControlProjection(db, workspaceId),
2388
+ await workspaceKindProjection(db, row),
2389
+ );
2360
2390
  }
2361
2391
 
2362
2392
  // Deep-merge (top-level) a settings patch into workspaces.settings, atomically.
@@ -2417,6 +2447,7 @@ export async function updateWorkspaceSettings(
2417
2447
  return mapWorkspace(
2418
2448
  row,
2419
2449
  await workspaceControlProjection(tx as unknown as Database, workspaceId),
2450
+ await workspaceKindProjection(tx as unknown as Database, row),
2420
2451
  );
2421
2452
  }),
2422
2453
  );
@@ -2432,7 +2463,11 @@ export async function updateWorkspaceSettings(
2432
2463
  if (!row) {
2433
2464
  throw new Error(`Workspace not found: ${workspaceId}`);
2434
2465
  }
2435
- return mapWorkspace(row, await workspaceControlProjection(db, workspaceId));
2466
+ return mapWorkspace(
2467
+ row,
2468
+ await workspaceControlProjection(db, workspaceId),
2469
+ await workspaceKindProjection(db, row),
2470
+ );
2436
2471
  }
2437
2472
 
2438
2473
  export async function setWorkspaceDefaultRig(
@@ -2451,7 +2486,11 @@ export async function setWorkspaceDefaultRig(
2451
2486
  if (!row) {
2452
2487
  throw new Error(`Workspace not found: ${workspaceId}`);
2453
2488
  }
2454
- return mapWorkspace(row, await workspaceControlProjection(db, workspaceId));
2489
+ return mapWorkspace(
2490
+ row,
2491
+ await workspaceControlProjection(db, workspaceId),
2492
+ await workspaceKindProjection(db, row),
2493
+ );
2455
2494
  }
2456
2495
 
2457
2496
  export async function getWorkspaceGrant(
@@ -15899,17 +15938,31 @@ async function validateScheduledTargetExecutionAtClaim(
15899
15938
  ),
15900
15939
  )
15901
15940
  .orderBy(asc(schema.sessionMcpServers.serverId));
15902
- const variableSet = target.variableSetId
15903
- ? await getVariableSet(
15941
+ const targetVariableSets =
15942
+ target.variableSets.length > 0
15943
+ ? target.variableSets
15944
+ : target.variableSetId && target.variableSetGeneration
15945
+ ? [
15946
+ {
15947
+ id: target.variableSetId,
15948
+ generation: target.variableSetGeneration,
15949
+ },
15950
+ ]
15951
+ : [];
15952
+ const liveVariableSets = await Promise.all(
15953
+ targetVariableSets.map(async (selected) => {
15954
+ const variableSet = await getVariableSet(
15904
15955
  tx,
15905
15956
  {
15906
15957
  accountId: input.accountId,
15907
15958
  workspaceId: input.workspaceId,
15908
15959
  subjectId: accepted.personalResourceAuthoritySubjectId ?? "scheduled-task",
15909
15960
  },
15910
- target.variableSetId,
15911
- )
15912
- : null;
15961
+ selected.id,
15962
+ );
15963
+ return variableSet ? { id: variableSet.id, generation: variableSet.generation } : null;
15964
+ }),
15965
+ );
15913
15966
  const [rigVersion] =
15914
15967
  target.rigId && target.rigVersionId
15915
15968
  ? await tx
@@ -15950,8 +16003,10 @@ async function validateScheduledTargetExecutionAtClaim(
15950
16003
  stableJson(target.toolPolicy) !== stableJson(input.session.toolPolicy) ||
15951
16004
  stableJson(target.mcpServerIds) !== stableJson(mcpServers.map((server) => server.id)) ||
15952
16005
  target.toolPolicyVersion !== input.session.toolPolicyVersion ||
15953
- target.variableSetId !== (input.session.variableSetId ?? null) ||
15954
- target.variableSetGeneration !== (variableSet?.generation ?? null) ||
16006
+ stableJson(targetVariableSets) !==
16007
+ stableJson(liveVariableSets.filter((value) => value !== null)) ||
16008
+ stableJson(targetVariableSets.map((variableSet) => variableSet.id)) !==
16009
+ stableJson((input.session.variableSetIds as string[]) ?? []) ||
15955
16010
  target.rigId !== (input.session.rigId ?? null) ||
15956
16011
  target.rigVersionId !== (input.session.rigVersionId ?? null) ||
15957
16012
  stableJson(target.rigDefaultVariableSets) !==
@@ -16339,7 +16394,7 @@ export class VariableSetAttachedError extends Error {
16339
16394
 
16340
16395
  constructor(cause?: unknown) {
16341
16396
  super(
16342
- "variable set remains attached to a scheduled task or active session",
16397
+ "variable set remains attached to a scheduled task or session",
16343
16398
  cause === undefined ? undefined : { cause },
16344
16399
  );
16345
16400
  }
@@ -16422,8 +16477,14 @@ export async function countActiveSessionsUsingVariableSet(
16422
16477
  .where(
16423
16478
  and(
16424
16479
  eq(schema.sessions.workspaceId, workspaceId),
16425
- eq(schema.sessions.variableSetId, variableSetId),
16426
- inArray(schema.sessions.status, ["queued", "running", "requires_action"]),
16480
+ sql`${schema.sessions.variableSetIds} ? ${variableSetId}`,
16481
+ inArray(schema.sessions.status, [
16482
+ "queued",
16483
+ "running",
16484
+ "requires_action",
16485
+ "recovering",
16486
+ "waiting_capacity",
16487
+ ]),
16427
16488
  ),
16428
16489
  );
16429
16490
  return Number(count);
@@ -16528,7 +16589,15 @@ export class RigChangeAlreadyVerifyingError extends Error {
16528
16589
  }
16529
16590
  }
16530
16591
 
16592
+ export class RigImageOverrideUnsupportedError extends Error {
16593
+ constructor() {
16594
+ super("Rig image overrides are unsupported; Rigs use the deployment platform sandbox image");
16595
+ this.name = "RigImageOverrideUnsupportedError";
16596
+ }
16597
+ }
16598
+
16531
16599
  export type RigVersionContentInput = {
16600
+ /** @deprecated Rig versions always use the deployment platform sandbox image. */
16532
16601
  image?: string | null;
16533
16602
  setupScript?: string | null;
16534
16603
  checks?: RigCheck[];
@@ -16538,6 +16607,12 @@ export type RigVersionContentInput = {
16538
16607
  createdBy?: string | null;
16539
16608
  };
16540
16609
 
16610
+ function assertRigUsesPlatformImage(input: RigVersionContentInput | undefined): void {
16611
+ if (input?.image != null) {
16612
+ throw new RigImageOverrideUnsupportedError();
16613
+ }
16614
+ }
16615
+
16541
16616
  function mapRigVersion(row: typeof schema.rigVersions.$inferSelect): RigVersion {
16542
16617
  return {
16543
16618
  id: row.id,
@@ -16773,6 +16848,7 @@ export async function createRig(
16773
16848
  initialVersion?: RigVersionContentInput;
16774
16849
  },
16775
16850
  ): Promise<Rig> {
16851
+ assertRigUsesPlatformImage(input.initialVersion);
16776
16852
  if (input.scope !== undefined || input.subjectId !== undefined) {
16777
16853
  return await withRlsContext(
16778
16854
  db,
@@ -16786,7 +16862,7 @@ export async function createRig(
16786
16862
  ${input.accountId}::uuid, ${input.workspaceId}::uuid,
16787
16863
  ${input.scope ?? "workspace"}, ${input.name}, ${input.description ?? null},
16788
16864
  ${input.createdBy ?? null}, ${JSON.stringify({
16789
- image: content.image ?? null,
16865
+ image: null,
16790
16866
  setupScript: content.setupScript ?? null,
16791
16867
  checks: content.checks ?? [],
16792
16868
  credentialHooks: content.credentialHooks ?? [],
@@ -16826,7 +16902,7 @@ export async function createRig(
16826
16902
  workspaceId: input.workspaceId,
16827
16903
  rigId: rigRow.id,
16828
16904
  version: 1,
16829
- image: content.image ?? null,
16905
+ image: null,
16830
16906
  setupScript: content.setupScript ?? null,
16831
16907
  checks: content.checks ?? [],
16832
16908
  credentialHooks: content.credentialHooks ?? [],
@@ -17180,6 +17256,66 @@ function isSessionChannelFkViolation(error: unknown): boolean {
17180
17256
  );
17181
17257
  }
17182
17258
 
17259
+ const sessionVariableSetSelectionFkConstraints = new Set([
17260
+ // Legacy final-entry mirror on sessions.variable_set_id.
17261
+ "sessions_environment_id_fkey",
17262
+ // Ordered attachment projection populated by the session INSERT/UPDATE trigger.
17263
+ "session_variable_set_attachments_variable_set_id_fkey",
17264
+ ]);
17265
+
17266
+ /** A selected Variable Set disappeared after validation; unrelated FKs stay loud. */
17267
+ function isSessionVariableSetSelectionFkViolation(error: unknown): boolean {
17268
+ return (
17269
+ nestedPostgresSqlState(error) === "23503" &&
17270
+ sessionVariableSetSelectionFkConstraints.has(safeDatabaseErrorFacts(error).constraint ?? "")
17271
+ );
17272
+ }
17273
+
17274
+ export class SessionVariableSetSelectionUnavailableError extends Error {
17275
+ readonly variableSetIds: string[];
17276
+
17277
+ constructor(variableSetIds: string[], cause?: unknown) {
17278
+ super(
17279
+ "One or more selected Variable Sets are no longer available.",
17280
+ cause === undefined ? undefined : { cause },
17281
+ );
17282
+ this.name = "SessionVariableSetSelectionUnavailableError";
17283
+ this.variableSetIds = variableSetIds;
17284
+ }
17285
+ }
17286
+
17287
+ async function translateSessionVariableSetSelectionCreateError(
17288
+ db: Database,
17289
+ input: SessionCreateInput,
17290
+ error: unknown,
17291
+ ): Promise<never> {
17292
+ if (!isSessionVariableSetSelectionFkViolation(error) || !input.subjectId) throw error;
17293
+
17294
+ // The failed create transaction, including its session row and projection
17295
+ // trigger, has rolled back. Resolve the exact requested identities under the
17296
+ // original account/workspace/subject RLS scope before translating the known
17297
+ // FK race. If every identity is still available, preserve the original
17298
+ // integrity failure so an unrelated defect cannot be hidden.
17299
+ const variableSetIds = [
17300
+ ...new Set(input.variableSetIds ?? (input.variableSetId ? [input.variableSetId] : [])),
17301
+ ];
17302
+ const unavailableVariableSetIds: string[] = [];
17303
+ for (const variableSetId of variableSetIds) {
17304
+ const current = await getVariableSet(
17305
+ db,
17306
+ {
17307
+ accountId: input.accountId,
17308
+ workspaceId: input.workspaceId,
17309
+ subjectId: input.subjectId,
17310
+ },
17311
+ variableSetId,
17312
+ );
17313
+ if (!current) unavailableVariableSetIds.push(variableSetId);
17314
+ }
17315
+ if (unavailableVariableSetIds.length === 0) throw error;
17316
+ throw new SessionVariableSetSelectionUnavailableError(unavailableVariableSetIds, error);
17317
+ }
17318
+
17183
17319
  function mapChannel(row: typeof schema.channels.$inferSelect): Channel {
17184
17320
  return {
17185
17321
  id: row.id,
@@ -17422,6 +17558,311 @@ export async function setSessionChannel(
17422
17558
  });
17423
17559
  }
17424
17560
 
17561
+ export type UpdateSessionVariableSetsResult =
17562
+ | { status: "updated"; event: SessionEvent }
17563
+ | { status: "unchanged" }
17564
+ | { status: "invalid_variable_sets"; variableSetIds: string[] }
17565
+ | {
17566
+ status: "blocked";
17567
+ reason: "turn_in_flight" | "shared_sandbox_group" | "live_sandbox_holders";
17568
+ }
17569
+ | { status: "not_found" };
17570
+
17571
+ /**
17572
+ * Replace the complete ordered session Variable Set selection at a quiescent
17573
+ * turn boundary. The session row + FK-backed attachment trigger + audit/event
17574
+ * evidence commit together. A live managed box is fenced for rotation so its
17575
+ * baked environment can never serve the next turn after a detach.
17576
+ */
17577
+ export async function updateSessionVariableSets(
17578
+ db: Database,
17579
+ input: {
17580
+ accountId: string;
17581
+ workspaceId: string;
17582
+ sessionId: string;
17583
+ subjectId: string;
17584
+ variableSets: Array<{
17585
+ id: string;
17586
+ name: string;
17587
+ scope: VariableSet["scope"];
17588
+ }>;
17589
+ },
17590
+ ): Promise<UpdateSessionVariableSetsResult> {
17591
+ return await withWorkspaceSubjectSessionActivityRls<UpdateSessionVariableSetsResult>(
17592
+ db,
17593
+ input.workspaceId,
17594
+ input.subjectId,
17595
+ async (scopedDb) =>
17596
+ await scopedDb.transaction(async (rawTx) => {
17597
+ const tx = rawTx as unknown as Database;
17598
+ const locks = await lockSessionEventWriteRows(tx, {
17599
+ workspaceId: input.workspaceId,
17600
+ controlLock: "share",
17601
+ sessionIds: [input.sessionId],
17602
+ });
17603
+ const session = locks.sessions[0];
17604
+ if (!session || session.accountId !== input.accountId) return { status: "not_found" };
17605
+ // Prompt admission and turn claim both take the same session-row lock
17606
+ // before creating or claiming work. Inspect the complete tenancy
17607
+ // quiescence set while that lock is held so an accepted queued turn can
17608
+ // neither slip through this mutation nor be admitted against the old
17609
+ // Variable Set selection after this transaction commits.
17610
+ const [pendingWork] = await rawRows<{ blocker: string | null }>(
17611
+ tx,
17612
+ sql`select case
17613
+ when ${session.activeTurnId}::uuid is not null
17614
+ or exists (select 1 from ${schema.sessionTurns} turn_row
17615
+ where turn_row.workspace_id = ${input.workspaceId}
17616
+ and turn_row.session_id = ${input.sessionId}
17617
+ and turn_row.status in (
17618
+ 'queued', 'running', 'requires_action', 'recovering', 'waiting_capacity'
17619
+ ))
17620
+ then 'nonterminal_turn'
17621
+ when exists (select 1 from ${schema.sessionTurnAttempts} attempt_row
17622
+ where attempt_row.workspace_id = ${input.workspaceId}
17623
+ and attempt_row.session_id = ${input.sessionId}
17624
+ and attempt_row.state <> 'closed')
17625
+ then 'nonterminal_attempt'
17626
+ when exists (select 1 from ${schema.sessionAttemptInterruptions} interruption_row
17627
+ where interruption_row.workspace_id = ${input.workspaceId}
17628
+ and interruption_row.session_id = ${input.sessionId}
17629
+ and interruption_row.state in ('pending', 'delivered', 'acknowledged'))
17630
+ then 'unsettled_interruption'
17631
+ when exists (select 1 from ${schema.sessionSystemUpdates} update_row
17632
+ where update_row.workspace_id = ${input.workspaceId}
17633
+ and update_row.session_id = ${input.sessionId}
17634
+ and update_row.state = 'pending')
17635
+ then 'pending_system_update'
17636
+ when exists (select 1 from ${schema.sessionHumanInputRequests} request_row
17637
+ where request_row.workspace_id = ${input.workspaceId}
17638
+ and request_row.session_id = ${input.sessionId}
17639
+ and request_row.status = 'pending')
17640
+ then 'pending_human_input'
17641
+ when exists (select 1 from ${schema.sessionPendingToolCalls} tool_row
17642
+ where tool_row.workspace_id = ${input.workspaceId}
17643
+ and tool_row.session_id = ${input.sessionId})
17644
+ then 'pending_tool_receipt'
17645
+ when exists (select 1 from ${schema.agentRunStates} run_state
17646
+ where run_state.workspace_id = ${input.workspaceId}
17647
+ and run_state.session_id = ${input.sessionId})
17648
+ then 'run_state'
17649
+ when exists (select 1 from ${schema.sessionGoals} goal_row
17650
+ where goal_row.workspace_id = ${input.workspaceId}
17651
+ and goal_row.session_id = ${input.sessionId}
17652
+ and goal_row.status = 'active')
17653
+ then 'active_goal'
17654
+ when exists (select 1 from ${schema.codexCapacityWaiters} codex_waiter
17655
+ where codex_waiter.workspace_id = ${input.workspaceId}
17656
+ and codex_waiter.session_id = ${input.sessionId}
17657
+ and codex_waiter.status = 'waiting')
17658
+ or exists (select 1 from ${schema.xaiCapacityWaiters} xai_waiter
17659
+ where xai_waiter.workspace_id = ${input.workspaceId}
17660
+ and xai_waiter.session_id = ${input.sessionId}
17661
+ and xai_waiter.status = 'waiting')
17662
+ then 'capacity_waiter'
17663
+ when exists (select 1 from ${schema.sessionRealtimeModes} realtime_mode
17664
+ where realtime_mode.workspace_id = ${input.workspaceId}
17665
+ and realtime_mode.session_id = ${input.sessionId}
17666
+ and realtime_mode.state = 'active')
17667
+ or exists (select 1 from ${schema.sessionRealtimeConnections} realtime_connection
17668
+ where realtime_connection.workspace_id = ${input.workspaceId}
17669
+ and realtime_connection.session_id = ${input.sessionId}
17670
+ and realtime_connection.state in ('negotiating', 'ready', 'active'))
17671
+ then 'active_realtime'
17672
+ when exists (select 1 from ${schema.scheduledTasks} scheduled_task
17673
+ where scheduled_task.workspace_id = ${input.workspaceId}
17674
+ and scheduled_task.reusable_session_id = ${input.sessionId}
17675
+ and scheduled_task.status = 'active')
17676
+ then 'active_scheduled_task'
17677
+ when exists (select 1 from ${schema.sandboxWorkspaceMutationAdmissions} admission_row
17678
+ where admission_row.workspace_id = ${input.workspaceId}
17679
+ and admission_row.session_id = ${input.sessionId}
17680
+ and admission_row.settled_at is null)
17681
+ then 'workspace_mutation_admission'
17682
+ when exists (select 1 from ${schema.sandboxRetainedProcesses} process_row
17683
+ where process_row.workspace_id = ${input.workspaceId}
17684
+ and process_row.session_id = ${input.sessionId}
17685
+ and process_row.state = 'active')
17686
+ then 'retained_process'
17687
+ else null
17688
+ end as blocker`,
17689
+ );
17690
+ if (pendingWork?.blocker !== null && pendingWork?.blocker !== undefined) {
17691
+ return { status: "blocked", reason: "turn_in_flight" };
17692
+ }
17693
+ const [groupCount] = await rawRows<{ count: number }>(
17694
+ tx,
17695
+ sql`select count(*)::int as count
17696
+ from sessions
17697
+ where workspace_id = ${input.workspaceId}
17698
+ and sandbox_group_id = ${session.sandboxGroupId}`,
17699
+ );
17700
+ if (Number(groupCount?.count ?? 0) > 1) {
17701
+ return { status: "blocked", reason: "shared_sandbox_group" };
17702
+ }
17703
+ // Serialize with the get-or-create admission path even when this group
17704
+ // has never materialized a lease row. Once a row exists, lock it too:
17705
+ // every ordinary holder acquire and retained-process promotion takes
17706
+ // that row lock before publishing its holder.
17707
+ await lockSandboxLeaseAdmission(tx, input.workspaceId, session.sandboxGroupId);
17708
+ await tx.execute(sql`select id
17709
+ from sandbox_leases
17710
+ where workspace_id = ${input.workspaceId}
17711
+ and sandbox_group_id = ${session.sandboxGroupId}
17712
+ for update`);
17713
+ const [holderCount] = await rawRows<{ count: number }>(
17714
+ tx,
17715
+ sql`select count(*)::int as count
17716
+ from sandbox_lease_holders holder
17717
+ join sandbox_leases lease on lease.id = holder.lease_id
17718
+ where lease.workspace_id = ${input.workspaceId}
17719
+ and lease.sandbox_group_id = ${session.sandboxGroupId}`,
17720
+ );
17721
+ if (Number(holderCount?.count ?? 0) > 0) {
17722
+ return { status: "blocked", reason: "live_sandbox_holders" };
17723
+ }
17724
+
17725
+ const previousIds =
17726
+ (session.variableSetIds as string[]) ??
17727
+ (session.variableSetId ? [session.variableSetId] : []);
17728
+ const nextIds = input.variableSets.map((variableSet) => variableSet.id);
17729
+ if (stableJson(previousIds) === stableJson(nextIds)) {
17730
+ return { status: "unchanged" };
17731
+ }
17732
+
17733
+ const now = new Date();
17734
+ await tx
17735
+ .update(schema.sessions)
17736
+ .set({
17737
+ variableSetIds: nextIds,
17738
+ variableSetId: nextIds.at(-1) ?? null,
17739
+ updatedAt: now,
17740
+ })
17741
+ .where(
17742
+ and(
17743
+ eq(schema.sessions.workspaceId, input.workspaceId),
17744
+ eq(schema.sessions.id, input.sessionId),
17745
+ ),
17746
+ );
17747
+ await tx.execute(sql`update sandbox_leases set
17748
+ rotation_requested_at = coalesce(rotation_requested_at, now()),
17749
+ rotation_reason = coalesce(rotation_reason, 'operator'),
17750
+ expires_at = least(expires_at, now()),
17751
+ updated_at = now()
17752
+ where workspace_id = ${input.workspaceId}
17753
+ and sandbox_group_id = ${session.sandboxGroupId}
17754
+ and liveness <> 'cold'`);
17755
+
17756
+ const previous = new Set(previousIds);
17757
+ const next = new Set(nextIds);
17758
+ const addedIds = nextIds.filter((id) => !previous.has(id));
17759
+ const removedIds = previousIds.filter((id) => !next.has(id));
17760
+ const sequence = session.lastSequence + 1;
17761
+ const [eventRow] = await tx
17762
+ .insert(schema.sessionEvents)
17763
+ .values(
17764
+ withLosslessContentWriteVersion(
17765
+ {
17766
+ accountId: input.accountId,
17767
+ workspaceId: input.workspaceId,
17768
+ sessionId: input.sessionId,
17769
+ sequence,
17770
+ type: "session.variable_sets.updated",
17771
+ payload: {
17772
+ actor: input.subjectId,
17773
+ variableSets: input.variableSets,
17774
+ addedIds,
17775
+ removedIds,
17776
+ collisionPolicy: "later_selected_set_wins",
17777
+ effectiveOn: "next_turn_or_sandbox_operation",
17778
+ },
17779
+ occurredAt: now,
17780
+ },
17781
+ "payload",
17782
+ "payloadCodecVersion",
17783
+ ),
17784
+ )
17785
+ .returning();
17786
+ if (!eventRow) throw new Error("Failed to append session Variable Set update event");
17787
+ await tx
17788
+ .update(schema.sessions)
17789
+ .set({ lastSequence: sequence })
17790
+ .where(
17791
+ and(
17792
+ eq(schema.sessions.workspaceId, input.workspaceId),
17793
+ eq(schema.sessions.id, input.sessionId),
17794
+ ),
17795
+ );
17796
+ const auditChanges = [
17797
+ ...addedIds.map((id) => ({
17798
+ action: "session.variable_set.attached",
17799
+ id,
17800
+ })),
17801
+ ...removedIds.map((id) => ({
17802
+ action: "session.variable_set.detached",
17803
+ id,
17804
+ })),
17805
+ ...(addedIds.length === 0 && removedIds.length === 0
17806
+ ? [
17807
+ {
17808
+ action: "session.variable_sets.reordered",
17809
+ id: input.sessionId,
17810
+ },
17811
+ ]
17812
+ : []),
17813
+ ];
17814
+ await tx.insert(schema.auditEvents).values(
17815
+ auditChanges.map(({ action, id }) =>
17816
+ withLosslessContentWriteVersion(
17817
+ {
17818
+ accountId: input.accountId,
17819
+ workspaceId: input.workspaceId,
17820
+ subjectId: input.subjectId,
17821
+ action,
17822
+ targetType: "session",
17823
+ targetId: input.sessionId,
17824
+ metadata: {
17825
+ sessionId: input.sessionId,
17826
+ ...(action === "session.variable_sets.reordered"
17827
+ ? { variableSetIds: nextIds }
17828
+ : { variableSetId: id }),
17829
+ },
17830
+ },
17831
+ "metadata",
17832
+ "metadataCodecVersion",
17833
+ ),
17834
+ ),
17835
+ );
17836
+ return { status: "updated", event: mapEvent(eventRow) };
17837
+ }),
17838
+ ).catch(async (error: unknown) => {
17839
+ if (!isSessionVariableSetSelectionFkViolation(error)) throw error;
17840
+
17841
+ // The failed transaction, including the session row, projection trigger,
17842
+ // lease rotation, event, and audit writes, has rolled back. Re-resolve the
17843
+ // exact requested identities under the same tenant/subject authority so a
17844
+ // concurrent revoke/delete is translated without masking any other FK bug.
17845
+ const unavailableVariableSetIds: string[] = [];
17846
+ for (const variableSet of input.variableSets) {
17847
+ const current = await getVariableSet(
17848
+ db,
17849
+ {
17850
+ accountId: input.accountId,
17851
+ workspaceId: input.workspaceId,
17852
+ subjectId: input.subjectId,
17853
+ },
17854
+ variableSet.id,
17855
+ );
17856
+ if (!current) unavailableVariableSetIds.push(variableSet.id);
17857
+ }
17858
+ if (unavailableVariableSetIds.length === 0) throw error;
17859
+ return {
17860
+ status: "invalid_variable_sets",
17861
+ variableSetIds: unavailableVariableSetIds,
17862
+ };
17863
+ });
17864
+ }
17865
+
17425
17866
  export async function countRigs(
17426
17867
  db: Database,
17427
17868
  access: string | RigAccessContext,
@@ -17473,6 +17914,7 @@ export async function createRigVersion(
17473
17914
  input: RigVersionContentInput,
17474
17915
  options: { activate?: boolean } = {},
17475
17916
  ): Promise<RigVersion> {
17917
+ assertRigUsesPlatformImage(input);
17476
17918
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
17477
17919
  const [rig] = await scopedDb
17478
17920
  .select({ id: schema.rigs.id, accountId: schema.rigs.accountId })
@@ -17511,7 +17953,7 @@ export async function createRigVersion(
17511
17953
  workspaceId,
17512
17954
  rigId,
17513
17955
  version: nextVersion,
17514
- image: input.image ?? null,
17956
+ image: null,
17515
17957
  setupScript: input.setupScript ?? null,
17516
17958
  checks: input.checks ?? [],
17517
17959
  credentialHooks: input.credentialHooks ?? [],
@@ -17543,6 +17985,7 @@ export async function createRigVersionForChangePromotion(
17543
17985
  providerImages?: RigProviderImages;
17544
17986
  },
17545
17987
  ): Promise<{ version: RigVersion; change: RigChange }> {
17988
+ assertRigUsesPlatformImage(input);
17546
17989
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
17547
17990
  const [rig] = await scopedDb
17548
17991
  .select({ id: schema.rigs.id, accountId: schema.rigs.accountId })
@@ -17627,7 +18070,7 @@ export async function createRigVersionForChangePromotion(
17627
18070
  workspaceId,
17628
18071
  rigId,
17629
18072
  version: nextVersion,
17630
- image: input.image ?? null,
18073
+ image: null,
17631
18074
  setupScript: input.setupScript ?? null,
17632
18075
  checks: input.checks ?? [],
17633
18076
  credentialHooks: input.credentialHooks ?? [],
@@ -17948,9 +18391,9 @@ async function retainRigProviderImageArtifacts(
17948
18391
 
17949
18392
  /**
17950
18393
  * Claim the one provider-image build slot for an existing immutable rig
17951
- * version. A finalized ready image is never overwritten in place: if the
17952
- * effective base/content identity changed, callers must mint/verify a new rig
17953
- * version and runtime setup remains the truthful fallback for this one.
18394
+ * version. The setup hash fences definition drift. Deployment base-image and
18395
+ * provider-image protocol rotations may replace operational metadata for the
18396
+ * same setup; late finalizers remain fenced by the build request id.
17954
18397
  */
17955
18398
  export async function claimRigVersionProviderImageBuild(
17956
18399
  db: Database,
@@ -17987,22 +18430,24 @@ export async function claimRigVersionProviderImageBuild(
17987
18430
  existing.contentHash === candidate.contentHash &&
17988
18431
  existing.setupHash === candidate.setupHash &&
17989
18432
  existing.sourceImage === candidate.sourceImage;
18433
+ const sameSetup = existing.setupHash === candidate.setupHash;
17990
18434
  if (existing.status === "ready") {
17991
- if (!sameContent) return { status: "conflict", image: existing };
17992
- const retained = await retainRigProviderImageArtifacts(scopedDb, input.workspaceId, {
17993
- [existing.backend]: existing,
17994
- });
17995
- if (retained[existing.backend] && existing.coldBootValidation?.version === 1) {
17996
- return { status: "ready", image: existing };
18435
+ if (sameContent) {
18436
+ const retained = await retainRigProviderImageArtifacts(scopedDb, input.workspaceId, {
18437
+ [existing.backend]: existing,
18438
+ });
18439
+ if (retained[existing.backend] && existing.coldBootValidation?.version === 1) {
18440
+ return { status: "ready", image: existing };
18441
+ }
17997
18442
  }
17998
18443
  }
17999
18444
  if (existing.status === "unsupported" && sameContent && !input.retryUnsupported) {
18000
18445
  return { status: "unsupported", image: existing };
18001
18446
  }
18002
- if (!sameContent) {
18447
+ if (!sameContent && !sameSetup) {
18003
18448
  return { status: "conflict", image: existing };
18004
18449
  }
18005
- if (existing.status === "building") {
18450
+ if (sameContent && existing.status === "building") {
18006
18451
  const startedAtMs = Date.parse(existing.startedAt);
18007
18452
  if (
18008
18453
  Number.isFinite(startedAtMs) &&
@@ -26912,6 +27357,7 @@ export type SessionCreateInput = {
26912
27357
  reasoningEffort: ReasoningEffort;
26913
27358
  latencyMode: LatencyMode;
26914
27359
  sandboxBackend: SandboxBackend;
27360
+ variableSetIds?: string[];
26915
27361
  variableSetId?: string | null;
26916
27362
  rigId?: string | null;
26917
27363
  rigVersionId?: string | null;
@@ -27292,6 +27738,8 @@ async function createSessionInTransaction(
27292
27738
  input: SessionCreateInput,
27293
27739
  id: string,
27294
27740
  ): Promise<SessionCreateResult> {
27741
+ const variableSetIds = input.variableSetIds ?? (input.variableSetId ? [input.variableSetId] : []);
27742
+ const variableSetId = variableSetIds.at(-1) ?? null;
27295
27743
  const createIdempotencyKey = input.createIdempotencyKey ?? null;
27296
27744
  const createRequestedVisibility = input.visibility ?? "workspace_shared";
27297
27745
  if (createRequestedVisibility === "user_private") {
@@ -27347,6 +27795,9 @@ async function createSessionInTransaction(
27347
27795
  ) {
27348
27796
  throw new SessionCreateIdempotencyConflictError();
27349
27797
  }
27798
+ if (stableJson(existing.variableSetIds ?? []) !== stableJson(variableSetIds)) {
27799
+ throw new SessionCreateIdempotencyConflictError();
27800
+ }
27350
27801
  if (existing.createRequestedVisibility !== createRequestedVisibility) {
27351
27802
  throw new SessionCreateIdempotencyConflictError();
27352
27803
  }
@@ -27457,6 +27908,8 @@ async function createSessionInTransaction(
27457
27908
  workspaceId: input.workspaceId,
27458
27909
  initialMessage: input.initialMessage,
27459
27910
  initialModelContext: input.initialModelContext ?? null,
27911
+ title: AUTOMATIC_SESSION_TITLE_FALLBACK,
27912
+ titleSource: "agent",
27460
27913
  resources: input.resources,
27461
27914
  skills: input.skills ?? [],
27462
27915
  tools: input.tools ?? [],
@@ -27478,7 +27931,8 @@ async function createSessionInTransaction(
27478
27931
  sandboxBackend: input.sandboxBackend,
27479
27932
  sandboxOs: input.sandboxOs ?? "linux",
27480
27933
  sandboxGroupId: input.sandboxGroupId ?? id,
27481
- variableSetId: input.variableSetId ?? null,
27934
+ variableSetIds,
27935
+ variableSetId,
27482
27936
  rigId: input.rigId ?? null,
27483
27937
  rigVersionId: input.rigVersionId ?? null,
27484
27938
  channelId: input.channelId ?? null,
@@ -27546,6 +28000,9 @@ async function createSessionInTransaction(
27546
28000
  ) {
27547
28001
  throw new SessionCreateIdempotencyConflictError();
27548
28002
  }
28003
+ if (stableJson(existing.variableSetIds ?? []) !== stableJson(variableSetIds)) {
28004
+ throw new SessionCreateIdempotencyConflictError();
28005
+ }
27549
28006
  if (existing.createRequestedVisibility !== createRequestedVisibility) {
27550
28007
  throw new SessionCreateIdempotencyConflictError();
27551
28008
  }
@@ -27597,15 +28054,20 @@ export async function createSession(db: Database, input: SessionCreateInput): Pr
27597
28054
  // Generate the id up front so the same uuid can seed sandbox_group_id for a
27598
28055
  // singleton group (sandbox_group_id cannot SQL-default to id).
27599
28056
  const id = input.requestedSessionId ?? crypto.randomUUID();
27600
- const result = await withSessionActivityRlsContext(
27601
- db,
27602
- { accountId: input.accountId, workspaceId: input.workspaceId },
27603
- async (scopedDb) =>
27604
- await withSessionActivitySavepoint(
27605
- scopedDb,
27606
- async (tx) => await createSessionInTransaction(tx, input, id),
27607
- ),
27608
- );
28057
+ let result: SessionCreateResult;
28058
+ try {
28059
+ result = await withSessionActivityRlsContext(
28060
+ db,
28061
+ { accountId: input.accountId, workspaceId: input.workspaceId },
28062
+ async (scopedDb) =>
28063
+ await withSessionActivitySavepoint(
28064
+ scopedDb,
28065
+ async (tx) => await createSessionInTransaction(tx, input, id),
28066
+ ),
28067
+ );
28068
+ } catch (error) {
28069
+ return await translateSessionVariableSetSelectionCreateError(db, input, error);
28070
+ }
27609
28071
  if (result.denied) {
27610
28072
  // Throw only after withRlsContext's outer transaction commits the denial.
27611
28073
  throw new SessionSpawnDeniedDbError(result.denial);
@@ -27630,15 +28092,19 @@ export async function createSessionWithIdempotencyKeyResult(
27630
28092
  // Generate the id up front so the same uuid can seed sandbox_group_id for a
27631
28093
  // singleton group (sandbox_group_id cannot SQL-default to id).
27632
28094
  const id = input.requestedSessionId ?? crypto.randomUUID();
27633
- return await withSessionActivityRlsContext(
27634
- db,
27635
- { accountId: input.accountId, workspaceId: input.workspaceId },
27636
- async (scopedDb) =>
27637
- await withSessionActivitySavepoint(
27638
- scopedDb,
27639
- async (tx) => await createSessionInTransaction(tx, input, id),
27640
- ),
27641
- );
28095
+ try {
28096
+ return await withSessionActivityRlsContext(
28097
+ db,
28098
+ { accountId: input.accountId, workspaceId: input.workspaceId },
28099
+ async (scopedDb) =>
28100
+ await withSessionActivitySavepoint(
28101
+ scopedDb,
28102
+ async (tx) => await createSessionInTransaction(tx, input, id),
28103
+ ),
28104
+ );
28105
+ } catch (error) {
28106
+ return await translateSessionVariableSetSelectionCreateError(db, input, error);
28107
+ }
27642
28108
  }
27643
28109
 
27644
28110
  /**
@@ -28322,6 +28788,26 @@ export async function listDistinctVariableSetIdsInGroup(
28322
28788
  });
28323
28789
  }
28324
28790
 
28791
+ /** Ordered Variable Set selections across every member of a sandbox group. */
28792
+ export async function listDistinctVariableSetSelectionsInGroup(
28793
+ db: Database,
28794
+ workspaceId: string,
28795
+ sandboxGroupId: string,
28796
+ ): Promise<string[][]> {
28797
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
28798
+ const rows = await scopedDb
28799
+ .selectDistinct({ variableSetIds: schema.sessions.variableSetIds })
28800
+ .from(schema.sessions)
28801
+ .where(
28802
+ and(
28803
+ eq(schema.sessions.workspaceId, workspaceId),
28804
+ eq(schema.sessions.sandboxGroupId, sandboxGroupId),
28805
+ ),
28806
+ );
28807
+ return rows.map((row) => (row.variableSetIds as string[]) ?? []);
28808
+ });
28809
+ }
28810
+
28325
28811
  // M3 rig sharing gate: the distinct frozen rig_version_ids across a group's
28326
28812
  // sessions. Mirrors listDistinctVariableSetIdsInGroup — the box's rig-baked
28327
28813
  // setup is fixed at cold-create, so a session joining a group must carry the
@@ -36282,6 +36768,24 @@ async function upsertLeaseHolder(
36282
36768
  `);
36283
36769
  }
36284
36770
 
36771
+ /**
36772
+ * Fence first lease materialization against control-plane mutations that must
36773
+ * prove the sandbox group has no holders. The lease-row lock remains the
36774
+ * canonical holder lock once the row exists; this advisory lock covers only
36775
+ * the otherwise-unlockable absent-row window.
36776
+ */
36777
+ async function lockSandboxLeaseAdmission(
36778
+ tx: Database,
36779
+ workspaceId: string,
36780
+ sandboxGroupId: string,
36781
+ ): Promise<void> {
36782
+ await tx.execute(
36783
+ sql`select pg_advisory_xact_lock(hashtextextended(
36784
+ ${`sandbox-lease-admission:${workspaceId}:${sandboxGroupId}`}, 0
36785
+ ))`,
36786
+ );
36787
+ }
36788
+
36285
36789
  // §4.1 — the get-or-create critical section. ONE transaction:
36286
36790
  // insert-or-nothing -> SELECT … FOR UPDATE (block, not skip) -> branch -> bump.
36287
36791
  // The single most load-bearing function: the sole double-spawn guard.
@@ -36304,6 +36808,7 @@ async function acquireLeaseOnce(
36304
36808
  const tx = txRaw as unknown as Database;
36305
36809
  const image = input.image ?? null;
36306
36810
  const rigVersionId = input.rigVersionId ?? null;
36811
+ await lockSandboxLeaseAdmission(tx, workspaceId, sandboxGroupId);
36307
36812
  // (1) Materialize the singleton row if absent. ON CONFLICT DO NOTHING + the
36308
36813
  // unique index = idempotent under a race; concurrent inserts collapse to
36309
36814
  // one row. expires_at seeded so a never-warmed cold row has a valid TTL. The
@@ -52777,10 +53282,21 @@ export async function recordSessionGoalProgressWithEvent(
52777
53282
  }
52778
53283
 
52779
53284
  /**
52780
- * Sets a session's display title. The clobber guard lives entirely in this
52781
- * single atomic UPDATE: a user-set title is permanent, so agent/auto writes
52782
- * carry an `AND title_source IS DISTINCT FROM 'user'` guard (NULL-safe in
52783
- * Postgres) while user writes are unconditional. Never read-modify-write.
53285
+ * Low-level row-only session-title update used by database-specific callers and
53286
+ * tests. Public product writers should use `updateSessionTitleWithEvent` so the
53287
+ * observable event commits atomically with this state transition. The clobber
53288
+ * guard lives entirely in this single atomic UPDATE: a user-set title is
53289
+ * permanent, so agent/auto writes
53290
+ * are first normalized through the shared short/sensitive-safe automatic-title
53291
+ * policy, then carry an `AND title_source IS DISTINCT FROM 'user'` guard
53292
+ * (NULL-safe in Postgres) while user writes are unconditional. Automatic
53293
+ * writes also bind the exact normalized candidate to a transaction-local
53294
+ * database capability, so a pre-policy binary cannot bypass normalization
53295
+ * during a rolling deployment. Never read-modify-write.
53296
+ *
53297
+ * `expectedCurrent`, when supplied, is an additional NULL-safe compare-and-set
53298
+ * fence. It is used by stale/retry-prone producers such as scheduled dispatch:
53299
+ * the write succeeds only if both the durable title and source still match.
52784
53300
  * Re-applying the exact title is also a no-op so a confused agent cannot churn
52785
53301
  * `updated_at` every turn. Returns `{ updated, title }`: `updated` is false when
52786
53302
  * the value was unchanged or an agent write was skipped because a user title
@@ -52794,13 +53310,37 @@ export async function updateSessionTitle(
52794
53310
  sessionId: string;
52795
53311
  title: string;
52796
53312
  source: "user" | "agent";
53313
+ expectedCurrent?: {
53314
+ title: string | null;
53315
+ source: "user" | "agent" | null;
53316
+ };
52797
53317
  },
52798
53318
  ): Promise<{ updated: boolean; title: string | null }> {
52799
53319
  return await withWorkspaceSessionActivityRls(db, input.workspaceId, async (scopedDb) => {
53320
+ const title =
53321
+ input.source === "agent" ? normalizeAutomaticSessionTitle(input.title) : input.title;
53322
+ if (title === null) {
53323
+ const [existing] = await scopedDb
53324
+ .select({ title: schema.sessions.title })
53325
+ .from(schema.sessions)
53326
+ .where(
53327
+ and(
53328
+ eq(schema.sessions.workspaceId, input.workspaceId),
53329
+ eq(schema.sessions.id, input.sessionId),
53330
+ ),
53331
+ )
53332
+ .limit(1);
53333
+ return { updated: false, title: existing?.title ?? null };
53334
+ }
53335
+ if (input.source === "agent") {
53336
+ await scopedDb.execute(
53337
+ sql`select pg_catalog.set_config('opengeni.automatic_session_title_v1_candidate', ${title}, true)`,
53338
+ );
53339
+ }
52800
53340
  const [row] = await scopedDb
52801
53341
  .update(schema.sessions)
52802
53342
  .set({
52803
- title: input.title,
53343
+ title,
52804
53344
  titleSource: input.source,
52805
53345
  updatedAt: new Date(),
52806
53346
  })
@@ -52808,10 +53348,16 @@ export async function updateSessionTitle(
52808
53348
  and(
52809
53349
  eq(schema.sessions.workspaceId, input.workspaceId),
52810
53350
  eq(schema.sessions.id, input.sessionId),
52811
- sql`${schema.sessions.title} is distinct from ${input.title}`,
53351
+ sql`${schema.sessions.title} is distinct from ${title}`,
52812
53352
  ...(input.source === "agent"
52813
53353
  ? [sql`${schema.sessions.titleSource} is distinct from 'user'`]
52814
53354
  : []),
53355
+ ...(input.expectedCurrent
53356
+ ? [
53357
+ sql`${schema.sessions.title} is not distinct from ${input.expectedCurrent.title}`,
53358
+ sql`${schema.sessions.titleSource} is not distinct from ${input.expectedCurrent.source}`,
53359
+ ]
53360
+ : []),
52815
53361
  ),
52816
53362
  )
52817
53363
  .returning({ title: schema.sessions.title });
@@ -52832,6 +53378,113 @@ export async function updateSessionTitle(
52832
53378
  });
52833
53379
  }
52834
53380
 
53381
+ /**
53382
+ * Atomically set a session title and append its public `session.title_set`
53383
+ * event under the canonical workspace/session event-write lock. The row CAS,
53384
+ * durable sequence, and event either all commit or all roll back, so a newer
53385
+ * title writer can never commit between the authoritative update and an older
53386
+ * event append.
53387
+ *
53388
+ * Exact retries are naturally idempotent: the unchanged-title predicate (and,
53389
+ * for stale producers, `expectedCurrent`) makes a committed retry a no-op with
53390
+ * no duplicate event. Automatic titles retain the rolling-deployment trigger's
53391
+ * exact transaction-local candidate capability.
53392
+ */
53393
+ export async function updateSessionTitleWithEvent(
53394
+ db: Database,
53395
+ input: {
53396
+ workspaceId: string;
53397
+ sessionId: string;
53398
+ title: string;
53399
+ source: "user" | "agent";
53400
+ expectedCurrent?: {
53401
+ title: string | null;
53402
+ source: "user" | "agent" | null;
53403
+ };
53404
+ },
53405
+ ): Promise<{ updated: boolean; title: string | null; events: SessionEvent[] }> {
53406
+ const title =
53407
+ input.source === "agent" ? normalizeAutomaticSessionTitle(input.title) : input.title;
53408
+ return await retryWorkspaceSessionEventActivityPersistence(
53409
+ db,
53410
+ input.workspaceId,
53411
+ true,
53412
+ {
53413
+ stage: "session_title.update_with_event",
53414
+ eventTypes: ["session.title_set"],
53415
+ maxAttempts: 3,
53416
+ },
53417
+ async (scopedDb) =>
53418
+ await scopedDb.transaction(async (tx) => {
53419
+ const locks = await lockSessionEventWriteRows(tx as unknown as Database, {
53420
+ workspaceId: input.workspaceId,
53421
+ controlLock: "none",
53422
+ sessionIds: [input.sessionId],
53423
+ });
53424
+ const session = locks.sessions[0];
53425
+ if (!session) return { updated: false, title: null, events: [] };
53426
+ if (title === null) {
53427
+ return { updated: false, title: session.title, events: [] };
53428
+ }
53429
+ if (input.source === "agent") {
53430
+ await tx.execute(
53431
+ sql`select pg_catalog.set_config('opengeni.automatic_session_title_v1_candidate', ${title}, true)`,
53432
+ );
53433
+ }
53434
+ const now = new Date();
53435
+ const sequence = session.lastSequence + 1;
53436
+ const [updated] = await tx
53437
+ .update(schema.sessions)
53438
+ .set({
53439
+ title,
53440
+ titleSource: input.source,
53441
+ lastSequence: sequence,
53442
+ updatedAt: now,
53443
+ })
53444
+ .where(
53445
+ and(
53446
+ eq(schema.sessions.workspaceId, input.workspaceId),
53447
+ eq(schema.sessions.id, input.sessionId),
53448
+ sql`${schema.sessions.title} is distinct from ${title}`,
53449
+ ...(input.source === "agent"
53450
+ ? [sql`${schema.sessions.titleSource} is distinct from 'user'`]
53451
+ : []),
53452
+ ...(input.expectedCurrent
53453
+ ? [
53454
+ sql`${schema.sessions.title} is not distinct from ${input.expectedCurrent.title}`,
53455
+ sql`${schema.sessions.titleSource} is not distinct from ${input.expectedCurrent.source}`,
53456
+ ]
53457
+ : []),
53458
+ ),
53459
+ )
53460
+ .returning({ title: schema.sessions.title });
53461
+ if (!updated) {
53462
+ return { updated: false, title: session.title, events: [] };
53463
+ }
53464
+ const [event] = await tx
53465
+ .insert(schema.sessionEvents)
53466
+ .values(
53467
+ withLosslessContentWriteVersion(
53468
+ {
53469
+ accountId: session.accountId,
53470
+ workspaceId: input.workspaceId,
53471
+ sessionId: input.sessionId,
53472
+ sequence,
53473
+ type: "session.title_set",
53474
+ payload: { title: updated.title ?? title, source: input.source },
53475
+ occurredAt: now,
53476
+ },
53477
+ "payload",
53478
+ "payloadCodecVersion",
53479
+ ),
53480
+ )
53481
+ .returning();
53482
+ if (!event) throw new Error("Failed to append session.title_set event");
53483
+ return { updated: true, title: updated.title, events: [mapEvent(event)] };
53484
+ }),
53485
+ );
53486
+ }
53487
+
52835
53488
  /**
52836
53489
  * Status transition helper. Idempotent: requesting the current status returns
52837
53490
  * `changed: false` so callers can skip emitting a duplicate event. `completed`
@@ -56986,17 +57639,33 @@ export async function claimSessionWorkForAttempt(
56986
57639
  ),
56987
57640
  )
56988
57641
  .orderBy(asc(schema.sessionMcpServers.serverId));
56989
- const targetVariableSet = targetPolicy.variableSetId
56990
- ? await getVariableSet(
57642
+ const targetVariableSets =
57643
+ targetPolicy.variableSets.length > 0
57644
+ ? targetPolicy.variableSets
57645
+ : targetPolicy.variableSetId && targetPolicy.variableSetGeneration
57646
+ ? [
57647
+ {
57648
+ id: targetPolicy.variableSetId,
57649
+ generation: targetPolicy.variableSetGeneration,
57650
+ },
57651
+ ]
57652
+ : [];
57653
+ const liveVariableSets = await Promise.all(
57654
+ targetVariableSets.map(async (selected) => {
57655
+ const variableSet = await getVariableSet(
56991
57656
  tx as unknown as Database,
56992
57657
  {
56993
57658
  accountId: session.accountId,
56994
57659
  workspaceId,
56995
57660
  subjectId: accepted.personalResourceAuthoritySubjectId ?? "scheduled-task",
56996
57661
  },
56997
- targetPolicy.variableSetId,
56998
- )
56999
- : null;
57662
+ selected.id,
57663
+ );
57664
+ return variableSet
57665
+ ? { id: variableSet.id, generation: variableSet.generation }
57666
+ : null;
57667
+ }),
57668
+ );
57000
57669
  if (
57001
57670
  targetPolicy.sessionId !== sessionId ||
57002
57671
  targetPolicy.visibility !== session.visibility ||
@@ -57009,8 +57678,10 @@ export async function claimSessionWorkForAttempt(
57009
57678
  stableJson(targetPolicy.mcpServerIds) !==
57010
57679
  stableJson(targetMcpServers.map((server) => server.id)) ||
57011
57680
  targetPolicy.toolPolicyVersion !== session.toolPolicyVersion ||
57012
- targetPolicy.variableSetId !== (session.variableSetId ?? null) ||
57013
- targetPolicy.variableSetGeneration !== (targetVariableSet?.generation ?? null) ||
57681
+ stableJson(targetVariableSets) !==
57682
+ stableJson(liveVariableSets.filter((value) => value !== null)) ||
57683
+ stableJson(targetVariableSets.map((variableSet) => variableSet.id)) !==
57684
+ stableJson((session.variableSetIds as string[]) ?? []) ||
57014
57685
  targetPolicy.rigId !== (session.rigId ?? null) ||
57015
57686
  targetPolicy.rigVersionId !== (session.rigVersionId ?? null) ||
57016
57687
  targetPolicy.maxNestedAgentDepthOverride !==
@@ -62478,6 +63149,7 @@ export async function getScheduledTargetSessionExecution(
62478
63149
  firstPartyMcpPermissions: schema.sessions.firstPartyMcpPermissions,
62479
63150
  toolPolicy: schema.sessions.toolPolicy,
62480
63151
  toolPolicyVersion: schema.sessions.toolPolicyVersion,
63152
+ variableSetIds: schema.sessions.variableSetIds,
62481
63153
  variableSetId: schema.sessions.variableSetId,
62482
63154
  rigId: schema.sessions.rigId,
62483
63155
  rigVersionId: schema.sessions.rigVersionId,
@@ -62500,8 +63172,9 @@ export async function getScheduledTargetSessionExecution(
62500
63172
  )
62501
63173
  .orderBy(asc(schema.sessionMcpServers.serverId));
62502
63174
  const latestStarted = await latestStartedSessionTurnRow(scopedDb, workspaceId, sessionId);
62503
- const variableSet = session.variableSetId
62504
- ? await getVariableSet(
63175
+ const variableSets = await Promise.all(
63176
+ ((session.variableSetIds as string[]) ?? []).map(async (variableSetId) => {
63177
+ const variableSet = await getVariableSet(
62505
63178
  scopedDb,
62506
63179
  {
62507
63180
  accountId: session.accountId,
@@ -62510,12 +63183,14 @@ export async function getScheduledTargetSessionExecution(
62510
63183
  // user-owned sets resolve only for the frozen causal subject.
62511
63184
  subjectId: authoritySubjectId ?? "scheduled-task",
62512
63185
  },
62513
- session.variableSetId,
62514
- )
62515
- : null;
62516
- if (session.variableSetId && !variableSet) {
62517
- throw new Error("scheduled target session variable set is unavailable");
62518
- }
63186
+ variableSetId,
63187
+ );
63188
+ if (!variableSet) {
63189
+ throw new Error(`scheduled target session Variable Set is unavailable: ${variableSetId}`);
63190
+ }
63191
+ return { id: variableSet.id, generation: variableSet.generation };
63192
+ }),
63193
+ );
62519
63194
  const rigMetadata =
62520
63195
  session.rigId && session.rigVersionId
62521
63196
  ? await getScheduledScopedRigVersionMetadata(
@@ -62573,8 +63248,9 @@ export async function getScheduledTargetSessionExecution(
62573
63248
  mcpServerIds: targetMcpServers.map((server) => server.id),
62574
63249
  effectiveMcpServerIds: targetMcpServers.map((server) => server.id),
62575
63250
  toolPolicyVersion: session.toolPolicyVersion,
63251
+ variableSets,
62576
63252
  variableSetId: session.variableSetId ?? null,
62577
- variableSetGeneration: variableSet?.generation ?? null,
63253
+ variableSetGeneration: variableSets.at(-1)?.generation ?? null,
62578
63254
  rigId: session.rigId ?? null,
62579
63255
  rigVersionId: session.rigVersionId ?? null,
62580
63256
  rigDefaultVariableSets,
@@ -63357,6 +64033,93 @@ export async function claimPendingSessionSystemUpdateOutbox(
63357
64033
  return deliveries;
63358
64034
  }
63359
64035
 
64036
+ export type AutomaticSessionTitleFanoutDelivery = {
64037
+ outboxId: string;
64038
+ event: SessionEvent;
64039
+ };
64040
+
64041
+ /**
64042
+ * Claim migration-created title events for deployment-wide NATS fanout. The
64043
+ * function is SECURITY DEFINER because one control worker drains obligations
64044
+ * across workspaces; callers receive only the exact already-public event
64045
+ * projection, never arbitrary session rows.
64046
+ */
64047
+ export async function claimAutomaticSessionTitleFanout(
64048
+ db: Database,
64049
+ limit = 100,
64050
+ ): Promise<AutomaticSessionTitleFanoutDelivery[]> {
64051
+ const rows = await rawRows<{
64052
+ outbox_id: string;
64053
+ workspace_id: string;
64054
+ session_id: string;
64055
+ event_id: string;
64056
+ sequence: number;
64057
+ type: string;
64058
+ payload: unknown;
64059
+ payload_codec_version: number | null;
64060
+ occurred_at: Date | string;
64061
+ client_event_id: string | null;
64062
+ turn_id: string | null;
64063
+ turn_generation: number | null;
64064
+ turn_attempt_id: string | null;
64065
+ turn_association: string | null;
64066
+ duplicate_of_event_id: string | null;
64067
+ duplicate_reason: string | null;
64068
+ }>(db, sql`select * from opengeni_private.claim_automatic_session_title_fanout_v1(${limit})`);
64069
+ return rows.map((row) => ({
64070
+ outboxId: row.outbox_id,
64071
+ event: {
64072
+ id: row.event_id,
64073
+ workspaceId: row.workspace_id,
64074
+ sessionId: row.session_id,
64075
+ sequence: row.sequence,
64076
+ type: row.type as SessionEventType,
64077
+ payload: fromPostgresLosslessJson(row.payload, row.payload_codec_version),
64078
+ occurredAt:
64079
+ row.occurred_at instanceof Date
64080
+ ? row.occurred_at.toISOString()
64081
+ : new Date(row.occurred_at).toISOString(),
64082
+ clientEventId: row.client_event_id,
64083
+ turnId: row.turn_id,
64084
+ turnGeneration: row.turn_generation,
64085
+ turnAttemptId: row.turn_attempt_id,
64086
+ turnAssociation: row.turn_association as SessionEvent["turnAssociation"],
64087
+ duplicateOfEventId: row.duplicate_of_event_id,
64088
+ duplicateReason: row.duplicate_reason,
64089
+ },
64090
+ }));
64091
+ }
64092
+
64093
+ export async function markAutomaticSessionTitleFanoutDelivered(
64094
+ db: Database,
64095
+ delivery: AutomaticSessionTitleFanoutDelivery,
64096
+ ): Promise<boolean> {
64097
+ const [row] = await rawRows<{ changed: boolean }>(
64098
+ db,
64099
+ sql`select opengeni_private.mark_automatic_session_title_fanout_delivered_v1(
64100
+ ${delivery.outboxId}::uuid,
64101
+ ${delivery.event.id}::uuid
64102
+ ) as changed`,
64103
+ );
64104
+ return row?.changed === true;
64105
+ }
64106
+
64107
+ export async function markAutomaticSessionTitleFanoutFailed(
64108
+ db: Database,
64109
+ delivery: AutomaticSessionTitleFanoutDelivery,
64110
+ error: string,
64111
+ ): Promise<boolean> {
64112
+ const [row] = await rawRows<{ changed: boolean }>(
64113
+ db,
64114
+ sql`select opengeni_private.mark_automatic_session_title_fanout_failed_v1(
64115
+ ${delivery.outboxId}::uuid,
64116
+ ${delivery.event.id}::uuid,
64117
+ ${error}
64118
+ ) as changed`,
64119
+ );
64120
+ return row?.changed === true;
64121
+ }
64122
+
63360
64123
  export type SessionWorkflowWake = {
63361
64124
  accountId: string;
63362
64125
  workspaceId: string;
@@ -65909,6 +66672,8 @@ function mapSession(
65909
66672
  activeSandboxId: row.activeSandboxId ?? null,
65910
66673
  activeEpoch: Number(row.activeEpoch),
65911
66674
  workingDir: row.workingDir ?? null,
66675
+ variableSetIds:
66676
+ (row.variableSetIds as string[]) ?? (row.variableSetId ? [row.variableSetId] : []),
65912
66677
  variableSetId: row.variableSetId,
65913
66678
  environmentId: row.variableSetId,
65914
66679
  // The rig + frozen rig version the session rides (M3). Both null for a
@@ -66420,13 +67185,35 @@ async function workspaceControlProjection(
66420
67185
  });
66421
67186
  }
66422
67187
 
67188
+ async function workspaceKindProjection(
67189
+ db: Database,
67190
+ row: Pick<typeof schema.workspaces.$inferSelect, "id" | "accountId">,
67191
+ ): Promise<Workspace["kind"]> {
67192
+ return await withRlsContext(
67193
+ db,
67194
+ { accountId: row.accountId, workspaceId: row.id },
67195
+ async (scopedDb) => {
67196
+ const [kind] = await rawRows<{ kind: string }>(
67197
+ scopedDb,
67198
+ sql`select get_workspace_kind(${row.accountId}::uuid, ${row.id}::uuid) as kind`,
67199
+ );
67200
+ if (kind?.kind !== "personal" && kind?.kind !== "shared") {
67201
+ throw new Error(`Workspace ${row.id} has no canonical kind`);
67202
+ }
67203
+ return kind.kind;
67204
+ },
67205
+ );
67206
+ }
67207
+
66423
67208
  function mapWorkspace(
66424
67209
  row: typeof schema.workspaces.$inferSelect,
66425
67210
  inferenceControl: Workspace["inferenceControl"],
67211
+ kind: Workspace["kind"],
66426
67212
  ): Workspace {
66427
67213
  return {
66428
67214
  id: row.id,
66429
67215
  accountId: row.accountId,
67216
+ kind,
66430
67217
  name: row.name,
66431
67218
  slug: row.slug,
66432
67219
  externalSource: row.externalSource,