@opengeni/core 0.4.6 → 0.4.7

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.
@@ -1,7 +1,8 @@
1
1
  import { CODEX_MODEL_ID_PREFIX } from "@opengeni/codex";
2
- import { configuredAllowedModels, type Settings } from "@opengeni/config";
2
+ import { configuredAllowedModels, policyProviderIdForModel, type Settings } from "@opengeni/config";
3
3
  import {
4
4
  CreateSessionRequest,
5
+ evaluateWorkspaceModelPolicy,
5
6
  reasoningEffortForMetadata,
6
7
  type AccessGrant,
7
8
  type GoalSpec,
@@ -17,36 +18,47 @@ import {
17
18
  type ToolRef,
18
19
  } from "@opengeni/contracts";
19
20
  import {
20
- appendSessionEventsWithLockedSessionUpdate,
21
21
  createSession,
22
- createSessionGoal,
23
22
  createSessionWithIdempotencyKey,
24
- enqueueSessionTurn,
25
- encryptEnvironmentValue,
23
+ enqueueSessionMessageAtomically,
24
+ encryptVariableSetValue,
26
25
  getAnySessionInGroup,
27
26
  getEnrollment,
28
- listDistinctEnvironmentIdsInGroup,
27
+ getRig,
28
+ getWorkspaceDefaultRigId,
29
+ listDistinctVariableSetIdsInGroup,
30
+ listDistinctRigVersionIdsInGroup,
29
31
  getSandbox,
30
32
  getSession,
31
33
  getSessionByCreateIdempotencyKey,
34
+ getSessionLineage,
32
35
  getSessionTurn,
36
+ getWorkspaceModelPolicy,
37
+ initializeSessionStartAtomically,
33
38
  requireSession,
34
- setTemporalWorkflowId,
35
39
  updateSessionTitle as updateSessionTitleRow,
36
40
  type CreateSessionMcpServerInput,
37
41
  type Database,
38
42
  type UpdateSessionMcpServerCredentialsInput,
43
+ SessionQueueConflictError,
39
44
  } from "@opengeni/db";
40
- import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
45
+ import {
46
+ appendAndPublishEvents,
47
+ publishDurableSessionEvents,
48
+ type EventBus,
49
+ } from "@opengeni/events";
41
50
  import { HTTPException } from "hono/http-exception";
42
51
  import { hasPermission, requirePermission } from "../access";
43
52
  import { recordWorkspaceUsage, requireLimit } from "../billing/limits";
44
- import type { ApiRouteDeps, SessionWorkflowClient } from "../dependencies";
53
+ import type {
54
+ AcceptSessionUserMessageDependencies,
55
+ ApiRouteDeps,
56
+ SessionWorkflowClient,
57
+ } from "../dependencies";
45
58
  import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
46
59
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
47
- import { requireEnvironmentEncryption, validateEnvironmentAttachment } from "./environments";
60
+ import { requireVariableSetEncryption, validateVariableSetAttachment } from "./environments";
48
61
  import {
49
- mergeResourceRefs,
50
62
  mergeToolRefs,
51
63
  normalizeResources,
52
64
  validateFileResources,
@@ -67,13 +79,19 @@ type ValidatedSessionMcpServers = {
67
79
  metadata: SessionMcpServerMetadata[];
68
80
  };
69
81
 
70
- function normalizedSessionMcpCredentialHeaders(headers: Record<string, string> | undefined): Record<string, string> {
82
+ function normalizedSessionMcpCredentialHeaders(
83
+ headers: Record<string, string> | undefined,
84
+ ): Record<string, string> {
71
85
  if (!headers) {
72
86
  return {};
73
87
  }
74
- const entries = Object.entries(headers).map(([name, value]) => [name.trim(), value] as const).filter(([name]) => name.length > 0);
88
+ const entries = Object.entries(headers)
89
+ .map(([name, value]) => [name.trim(), value] as const)
90
+ .filter(([name]) => name.length > 0);
75
91
  if (entries.length > maxSessionMcpCredentialHeaders) {
76
- throw new HTTPException(422, { message: `a session MCP server supports at most ${maxSessionMcpCredentialHeaders} credential headers` });
92
+ throw new HTTPException(422, {
93
+ message: `a session MCP server supports at most ${maxSessionMcpCredentialHeaders} credential headers`,
94
+ });
77
95
  }
78
96
  const seen = new Set<string>();
79
97
  for (const [name, value] of entries) {
@@ -86,12 +104,16 @@ function normalizedSessionMcpCredentialHeaders(headers: Record<string, string> |
86
104
  }
87
105
  seen.add(lower);
88
106
  if (value.length === 0 || value.length > maxSessionMcpCredentialHeaderValueLength) {
89
- throw new HTTPException(422, { message: `credential header ${name} must be 1-${maxSessionMcpCredentialHeaderValueLength} characters` });
107
+ throw new HTTPException(422, {
108
+ message: `credential header ${name} must be 1-${maxSessionMcpCredentialHeaderValueLength} characters`,
109
+ });
90
110
  }
91
111
  // RFC 9110 §5.5: field values are HTAB / printable characters.
92
112
  // eslint-disable-next-line no-control-regex
93
113
  if (/[\u0000-\u0008\u000A-\u001F\u007F]/.test(value)) {
94
- throw new HTTPException(422, { message: `credential header ${name} contains forbidden control characters` });
114
+ throw new HTTPException(422, {
115
+ message: `credential header ${name} contains forbidden control characters`,
116
+ });
95
117
  }
96
118
  }
97
119
  return Object.fromEntries(entries);
@@ -109,7 +131,9 @@ function mcpServerConfigFromInput(server: SessionMcpServerInput): Settings["mcpS
109
131
  };
110
132
  }
111
133
 
112
- function mcpServerConfigFromMetadata(server: SessionMcpServerMetadata): Settings["mcpServers"][number] {
134
+ function mcpServerConfigFromMetadata(
135
+ server: SessionMcpServerMetadata,
136
+ ): Settings["mcpServers"][number] {
113
137
  return {
114
138
  id: server.id,
115
139
  ...(server.name ? { name: server.name } : {}),
@@ -118,21 +142,24 @@ function mcpServerConfigFromMetadata(server: SessionMcpServerMetadata): Settings
118
142
  };
119
143
  }
120
144
 
121
- function settingsWithSessionMcpServerConfigs(settings: Settings, servers: Settings["mcpServers"]): Settings {
145
+ function settingsWithSessionMcpServerConfigs(
146
+ settings: Settings,
147
+ servers: Settings["mcpServers"],
148
+ ): Settings {
122
149
  if (servers.length === 0) {
123
150
  return settings;
124
151
  }
125
152
  const sessionIds = new Set(servers.map((server) => server.id));
126
153
  return {
127
154
  ...settings,
128
- mcpServers: [
129
- ...settings.mcpServers.filter((server) => !sessionIds.has(server.id)),
130
- ...servers,
131
- ],
155
+ mcpServers: [...settings.mcpServers.filter((server) => !sessionIds.has(server.id)), ...servers],
132
156
  };
133
157
  }
134
158
 
135
- export function settingsWithSessionMcpServerMetadata(settings: Settings, servers: SessionMcpServerMetadata[]): Settings {
159
+ export function settingsWithSessionMcpServerMetadata(
160
+ settings: Settings,
161
+ servers: SessionMcpServerMetadata[],
162
+ ): Settings {
136
163
  return settingsWithSessionMcpServerConfigs(settings, servers.map(mcpServerConfigFromMetadata));
137
164
  }
138
165
 
@@ -145,7 +172,7 @@ function validateSessionMcpServersForCreate(
145
172
  return { runtimeServers: [], dbServers: [], metadata: [] };
146
173
  }
147
174
  requirePermission(grant, "mcp_servers:attach");
148
- const encryptionKey = requireEnvironmentEncryption(settings);
175
+ const encryptionKey = requireVariableSetEncryption(settings);
149
176
  const existingIds = new Set(settings.mcpServers.map((server) => server.id));
150
177
  const seenIds = new Set<string>();
151
178
  const runtimeServers: Settings["mcpServers"] = [];
@@ -161,7 +188,10 @@ function validateSessionMcpServersForCreate(
161
188
  }
162
189
  const headers = normalizedSessionMcpCredentialHeaders(server.headers);
163
190
  const headersEncrypted = Object.fromEntries(
164
- Object.entries(headers).map(([name, value]) => [name, encryptEnvironmentValue(encryptionKey, value)]),
191
+ Object.entries(headers).map(([name, value]) => [
192
+ name,
193
+ encryptVariableSetValue(encryptionKey, value),
194
+ ]),
165
195
  );
166
196
  runtimeServers.push(mcpServerConfigFromInput(server));
167
197
  dbServers.push({
@@ -195,12 +225,14 @@ function validateSessionMcpCredentialUpdates(input: {
195
225
  return [];
196
226
  }
197
227
  requirePermission(input.grant, "mcp_servers:attach");
198
- const encryptionKey = requireEnvironmentEncryption(input.settings);
228
+ const encryptionKey = requireVariableSetEncryption(input.settings);
199
229
  const knownIds = new Set(input.session.mcpServers.map((server) => server.id));
200
230
  const seenIds = new Set<string>();
201
231
  const encryptedUpdates = input.updates.map((update) => {
202
232
  if (seenIds.has(update.id)) {
203
- throw new HTTPException(422, { message: `duplicate session MCP credential update id: ${update.id}` });
233
+ throw new HTTPException(422, {
234
+ message: `duplicate session MCP credential update id: ${update.id}`,
235
+ });
204
236
  }
205
237
  seenIds.add(update.id);
206
238
  if (!knownIds.has(update.id)) {
@@ -210,7 +242,10 @@ function validateSessionMcpCredentialUpdates(input: {
210
242
  return {
211
243
  id: update.id,
212
244
  headersEncrypted: Object.fromEntries(
213
- Object.entries(headers).map(([name, value]) => [name, encryptEnvironmentValue(encryptionKey, value)]),
245
+ Object.entries(headers).map(([name, value]) => [
246
+ name,
247
+ encryptVariableSetValue(encryptionKey, value),
248
+ ]),
214
249
  ),
215
250
  };
216
251
  });
@@ -232,7 +267,12 @@ export async function createAndStartSession(input: {
232
267
  sandboxBackend: Settings["sandboxBackend"];
233
268
  metadata: Record<string, unknown>;
234
269
  // Names/ids only; the session.created payload never carries variable values.
235
- environment?: { id: string; name: string } | null;
270
+ variableSet?: { id: string; name: string } | null;
271
+ // The rig + frozen active rig version resolved at create (M3). Both null ⇒ a
272
+ // rig-less session (byte-for-byte today's behavior). Frozen here so a later
273
+ // rig promote never moves an existing session's version.
274
+ rigId?: string | null;
275
+ rigVersionId?: string | null;
236
276
  goal?: GoalSpec | null;
237
277
  // Per-session agent persona/system instructions (org-visible metadata, not a
238
278
  // secret). Persisted on the session row and composed system-level AFTER the
@@ -251,8 +291,8 @@ export async function createAndStartSession(input: {
251
291
  parentSessionId?: string | null;
252
292
  // Workspace-scoped CREATE idempotency key. When present, a double-fire with
253
293
  // the same key (sequential retry OR concurrent race) collapses to a single
254
- // session: a prior winner is returned as-is and the start flow below is
255
- // skipped, so the dup never re-emits events / re-enqueues a turn.
294
+ // session. Every caller repairs or re-delivers the winner's one atomic start;
295
+ // the durable initializer prevents duplicate events or turns.
256
296
  createIdempotencyKey?: string | null;
257
297
  // The shared-sandbox group this session's box joins (addendum 05 §D). Null/
258
298
  // omitted ⇒ a singleton group (the new row's own id, today's 1:1 behavior); a
@@ -281,14 +321,23 @@ export async function createAndStartSession(input: {
281
321
  // Fast path with a key: return a session already created under this key
282
322
  // (the sequential retry / double-submit case) without inserting again.
283
323
  if (input.createIdempotencyKey) {
284
- const existing = await getSessionByCreateIdempotencyKey(input.db, input.workspaceId, input.createIdempotencyKey);
324
+ const existing = await getSessionByCreateIdempotencyKey(
325
+ input.db,
326
+ input.workspaceId,
327
+ input.createIdempotencyKey,
328
+ );
285
329
  if (existing) {
286
- return existing;
330
+ return await finishStartSession(
331
+ existing.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
332
+ existing,
333
+ );
287
334
  }
288
335
  // No prior session: insert under the key, racing concurrent creates. The
289
336
  // partial unique index lets exactly one insert win; a loser gets back the
290
- // winner's row with created=false and must NOT run the start flow (the
291
- // winner owns the events/turn/workflow), so we return it as-is.
337
+ // winner's row with created=false. Both callers may enter the idempotent
338
+ // initializer; exactly one creates the first events/turn. Each retry
339
+ // advances the coalesced wake revision so an in-flight stale delivery can
340
+ // never acknowledge work committed by the other caller.
292
341
  const { session: keyed, created } = await createSessionWithIdempotencyKey(input.db, {
293
342
  accountId: input.accountId,
294
343
  workspaceId: input.workspaceId,
@@ -298,7 +347,9 @@ export async function createAndStartSession(input: {
298
347
  metadata: sessionMetadata,
299
348
  model: input.model,
300
349
  sandboxBackend: input.sandboxBackend,
301
- environmentId: input.environment?.id ?? null,
350
+ variableSetId: input.variableSet?.id ?? null,
351
+ rigId: input.rigId ?? null,
352
+ rigVersionId: input.rigVersionId ?? null,
302
353
  firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
303
354
  instructions: input.instructions ?? null,
304
355
  parentSessionId: input.parentSessionId ?? null,
@@ -308,7 +359,10 @@ export async function createAndStartSession(input: {
308
359
  mcpServers: input.mcpServers ?? [],
309
360
  });
310
361
  if (!created) {
311
- return keyed;
362
+ return await finishStartSession(
363
+ keyed.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
364
+ keyed,
365
+ );
312
366
  }
313
367
  return await finishStartSession(input, keyed);
314
368
  }
@@ -321,7 +375,9 @@ export async function createAndStartSession(input: {
321
375
  metadata: sessionMetadata,
322
376
  model: input.model,
323
377
  sandboxBackend: input.sandboxBackend,
324
- environmentId: input.environment?.id ?? null,
378
+ variableSetId: input.variableSet?.id ?? null,
379
+ rigId: input.rigId ?? null,
380
+ rigVersionId: input.rigVersionId ?? null,
325
381
  firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
326
382
  instructions: input.instructions ?? null,
327
383
  parentSessionId: input.parentSessionId ?? null,
@@ -333,81 +389,37 @@ export async function createAndStartSession(input: {
333
389
  }
334
390
 
335
391
  /**
336
- * The post-insert half of {@link createAndStartSession}: durable goal row,
337
- * the initial event batch (session.created / goal.set / user.message /
338
- * status.changed), turn enqueue, and the workflow wake. Split out so the
339
- * idempotency-key winner and the key-less create share one body, and the
340
- * idempotency-key loser/dup can skip it entirely.
392
+ * Complete or repair the post-insert half of {@link createAndStartSession}.
393
+ * All durable initial state is installed by one idempotent transaction; every
394
+ * caller may then advance and deliver the coalesced wake revision without
395
+ * duplicating the goal, events, or first turn.
341
396
  */
342
- async function finishStartSession(input: {
343
- db: Database;
344
- bus: EventBus;
345
- workflowClient: SessionWorkflowClient;
346
- initialMessage: string;
347
- resources: ResourceRef[];
348
- tools: ToolRef[];
349
- clientEventId?: string;
350
- model: string;
351
- reasoningEffort: Settings["openaiReasoningEffort"];
352
- sandboxBackend: Settings["sandboxBackend"];
353
- environment?: { id: string; name: string } | null;
354
- goal?: GoalSpec | null;
355
- sessionMcpServers?: SessionMcpServerMetadata[];
356
- seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
357
- }, session: Session): Promise<Session> {
358
- // The goal row is durable session state; the workflow picks it up from the
359
- // database once the first turn completes — no extra workflow plumbing here.
360
- const goal = input.goal
361
- ? await createSessionGoal(input.db, {
362
- accountId: session.accountId,
363
- workspaceId: session.workspaceId,
364
- sessionId: session.id,
365
- text: input.goal.text,
366
- successCriteria: input.goal.successCriteria ?? null,
367
- maxAutoContinuations: input.goal.maxAutoContinuations ?? null,
368
- createdBy: "api",
369
- })
370
- : null;
371
- const initialPayload = {
372
- text: input.initialMessage,
373
- ...(input.resources.length ? { resources: input.resources } : {}),
374
- ...(input.tools.length ? { tools: input.tools } : {}),
375
- };
376
- const events = await appendAndPublishEvents(input.db, input.bus, session.workspaceId, session.id, [
377
- {
378
- type: "session.created",
379
- payload: {
380
- status: "queued",
381
- ...(input.environment ? { environmentId: input.environment.id, environmentName: input.environment.name } : {}),
382
- ...(input.sessionMcpServers?.length ? { mcpServers: input.sessionMcpServers } : {}),
383
- },
384
- },
385
- ...(goal ? [{
386
- type: "goal.set" as const,
387
- payload: {
388
- goalId: goal.id,
389
- text: goal.text,
390
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
391
- version: goal.version,
392
- actor: "api",
393
- replaced: false,
394
- },
395
- }] : []),
396
- {
397
- type: "user.message",
398
- payload: initialPayload,
399
- ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
400
- },
401
- { type: "session.status.changed", payload: { status: "queued" } },
402
- ]);
403
- const userEvent = events.find((event) => event.type === "user.message");
404
- if (!userEvent) {
405
- throw new HTTPException(500, { message: "failed to append initial user event" });
406
- }
397
+ async function finishStartSession(
398
+ input: {
399
+ db: Database;
400
+ bus: EventBus;
401
+ workflowClient: SessionWorkflowClient;
402
+ initialMessage: string;
403
+ resources: ResourceRef[];
404
+ tools: ToolRef[];
405
+ clientEventId?: string;
406
+ model: string;
407
+ reasoningEffort: Settings["openaiReasoningEffort"];
408
+ sandboxBackend: Settings["sandboxBackend"];
409
+ variableSet?: { id: string; name: string } | null;
410
+ goal?: GoalSpec | null;
411
+ sessionMcpServers?: SessionMcpServerMetadata[];
412
+ seedTargetSandbox?: {
413
+ sandboxId: string;
414
+ settings: Settings;
415
+ workingDir?: string | null;
416
+ } | null;
417
+ },
418
+ session: Session,
419
+ ): Promise<Session> {
407
420
  // Create-time machine targeting (A-2a): seed the active-sandbox pointer BEFORE
408
- // the first turn is enqueued + the workflow woken, so the FIRST turn routes to
409
- // the chosen machine. Race-free: the epoch-fenced setActiveSandbox commits here,
410
- // before wakeSessionWorkflow below signals the worker. swapActiveSandbox does
421
+ // the atomic initial turn transaction, so the FIRST turn routes to the chosen
422
+ // machine. swapActiveSandbox does
411
423
  // the same ownership+liveness validation as the live swap; an invalid/unowned/
412
424
  // offline target FAILS the create (422) — never a silent fall-back to the box.
413
425
  if (input.seedTargetSandbox) {
@@ -437,29 +449,40 @@ async function finishStartSession(input: {
437
449
  });
438
450
  }
439
451
  }
440
- const workflowId = workflowIdForSession(session.id);
441
- await setTemporalWorkflowId(input.db, session.workspaceId, session.id, workflowId);
442
- const turn = await enqueueSessionTurn(input.db, {
452
+ const started = await initializeSessionStartAtomically(input.db, {
443
453
  accountId: session.accountId,
444
454
  workspaceId: session.workspaceId,
445
455
  sessionId: session.id,
446
- triggerEventId: userEvent.id,
447
- temporalWorkflowId: workflowId,
448
- source: "user",
449
- prompt: input.initialMessage,
450
- resources: input.resources,
451
- tools: input.tools,
452
- model: input.model,
453
- reasoningEffort: input.reasoningEffort,
454
- sandboxBackend: input.sandboxBackend,
455
- metadata: {},
456
+ ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
457
+ reasoningEffortFallback: input.reasoningEffort,
458
+ createdEventPayload: {
459
+ ...(input.variableSet
460
+ ? { variableSetId: input.variableSet.id, variableSetName: input.variableSet.name }
461
+ : {}),
462
+ ...(input.sessionMcpServers?.length ? { mcpServers: input.sessionMcpServers } : {}),
463
+ },
464
+ goal: input.goal
465
+ ? {
466
+ text: input.goal.text,
467
+ ...(input.goal.successCriteria !== undefined
468
+ ? { successCriteria: input.goal.successCriteria }
469
+ : {}),
470
+ ...(input.goal.maxAutoContinuations !== undefined
471
+ ? { maxAutoContinuations: input.goal.maxAutoContinuations }
472
+ : {}),
473
+ }
474
+ : null,
456
475
  });
457
- await appendAndPublishEvents(input.db, input.bus, session.workspaceId, session.id, [{
458
- type: "turn.queued",
459
- turnId: turn.id,
460
- payload: { turnId: turn.id, triggerEventId: userEvent.id, source: turn.source },
461
- }]);
462
- await input.workflowClient.wakeSessionWorkflow({ accountId: session.accountId, workspaceId: session.workspaceId, sessionId: session.id, workflowId });
476
+ await publishDurableSessionEvents(input.bus, session.workspaceId, session.id, started.events);
477
+ if (started.workflowWakeRevision !== null) {
478
+ await input.workflowClient.wakeSessionWorkflow({
479
+ accountId: session.accountId,
480
+ workspaceId: session.workspaceId,
481
+ sessionId: session.id,
482
+ workflowId: started.temporalWorkflowId,
483
+ wakeRevision: started.workflowWakeRevision,
484
+ });
485
+ }
463
486
  return await requireSession(input.db, session.workspaceId, session.id);
464
487
  }
465
488
 
@@ -502,18 +525,64 @@ export function assertConfiguredModel(settings: Settings, model: string | null |
502
525
  throw new HTTPException(422, { message: `model is not available: ${model}` });
503
526
  }
504
527
 
505
- export async function requireQueuedTurnForApi(db: Database, workspaceId: string, sessionId: string, turnId: string): Promise<SessionTurn> {
528
+ /**
529
+ * Reject a model the WORKSPACE's model policy blocks, at the same choke points
530
+ * as assertConfiguredModel — a 422 at the edge instead of a queued turn the
531
+ * worker's authoritative post-resolution gate would fail. `model` is the
532
+ * EFFECTIVE value the caller is about to persist: pass the explicit value at
533
+ * message/turn-update/scheduled-task edges (omitted inherits an
534
+ * already-validated stored default), but at session CREATION pass
535
+ * `payload.model ?? settings.openaiModel` — an omitted model stamps the
536
+ * deployment default onto the session, and under a restricted policy that
537
+ * default may be exactly the provider the policy exists to block.
538
+ */
539
+ export async function assertWorkspaceModelPolicyAllows(
540
+ db: Database,
541
+ settings: Settings,
542
+ workspaceId: string,
543
+ model: string | null | undefined,
544
+ ): Promise<void> {
545
+ if (model === null || model === undefined) {
546
+ return;
547
+ }
548
+ const policy = await getWorkspaceModelPolicy(db, workspaceId);
549
+ if (!policy) {
550
+ return;
551
+ }
552
+ const providerId = policyProviderIdForModel(settings, model);
553
+ const verdict = evaluateWorkspaceModelPolicy(policy, { providerId, modelId: model });
554
+ if (!verdict.allowed) {
555
+ throw new HTTPException(422, {
556
+ message:
557
+ verdict.reason === "provider"
558
+ ? `model "${model}" is not allowed by this workspace's model policy: provider "${providerId}" is not in the allowed providers`
559
+ : `model "${model}" is not allowed by this workspace's model policy`,
560
+ });
561
+ }
562
+ }
563
+
564
+ export async function requireQueuedTurnForApi(
565
+ db: Database,
566
+ workspaceId: string,
567
+ sessionId: string,
568
+ turnId: string,
569
+ ): Promise<SessionTurn> {
506
570
  const turn = await getSessionTurn(db, workspaceId, turnId);
507
571
  if (!turn || turn.sessionId !== sessionId) {
508
572
  throw new HTTPException(404, { message: "session turn not found" });
509
573
  }
510
574
  if (turn.status !== "queued") {
511
- throw new HTTPException(409, { message: `turn is ${turn.status}; only queued turns can be changed` });
575
+ throw new HTTPException(409, {
576
+ message: `turn is ${turn.status}; only queued turns can be changed`,
577
+ });
512
578
  }
513
579
  return turn;
514
580
  }
515
581
 
516
- export function reasoningEffortForSession(metadata: Record<string, unknown>, fallback: Settings["openaiReasoningEffort"]): Settings["openaiReasoningEffort"] {
582
+ export function reasoningEffortForSession(
583
+ metadata: Record<string, unknown>,
584
+ fallback: Settings["openaiReasoningEffort"],
585
+ ): Settings["openaiReasoningEffort"] {
517
586
  return reasoningEffortForMetadata(metadata, fallback);
518
587
  }
519
588
 
@@ -527,7 +596,7 @@ export function reasoningEffortForSession(metadata: Record<string, unknown>, fal
527
596
  export async function postUserMessageTurn(input: {
528
597
  db: Database;
529
598
  bus: EventBus;
530
- workflowClient: SessionWorkflowClient;
599
+ workflowClient: Pick<SessionWorkflowClient, "wakeSessionWorkflow" | "signalSessionControl">;
531
600
  settings: Settings;
532
601
  accountId: string;
533
602
  workspaceId: string;
@@ -539,6 +608,11 @@ export async function postUserMessageTurn(input: {
539
608
  reasoningEffort?: Settings["openaiReasoningEffort"] | null;
540
609
  clientEventId?: string;
541
610
  mcpCredentialUpdates?: UpdateSessionMcpServerCredentialsInput[];
611
+ delivery?: "queue" | "steer";
612
+ origin?: "human" | "operator";
613
+ actor?: string;
614
+ expectedControlGeneration?: number;
615
+ expectedWorkspaceInferenceGeneration?: number;
542
616
  }): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
543
617
  const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
544
618
  const requestedModel = input.model ?? null;
@@ -546,85 +620,79 @@ export async function postUserMessageTurn(input: {
546
620
  // Reject an explicit per-message model the host does not expose; an omitted
547
621
  // model inherits the session's model downstream (always a configured id).
548
622
  assertConfiguredModel(settings, requestedModel);
549
- const appended = await appendSessionEventsWithLockedSessionUpdate(db, workspaceId, sessionId, async (lockedSession, lockedUpdate) => {
550
- // Cancelled is the one terminal state: an explicit user act. A FAILED
551
- // session stays revivable by talking to it — conversation truth lives in
552
- // session_history_items, so a failed turn does not invalidate history,
553
- // and the manager channel of record must always answer when spoken to.
554
- // The new message transitions failed -> queued (clearing the stale
555
- // activeTurnId) and the signalWithStart below starts a fresh workflow
556
- // run for the completed (failed) one, exactly as for idle sessions.
557
- if (lockedSession.status === "cancelled") {
558
- throw new HTTPException(409, { message: `session is ${lockedSession.status}; cannot accept a new user message` });
623
+ await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, requestedModel);
624
+ let result;
625
+ try {
626
+ result = await enqueueSessionMessageAtomically(db, {
627
+ accountId,
628
+ workspaceId,
629
+ sessionId,
630
+ actor: input.actor ?? accountId,
631
+ origin: input.origin ?? "human",
632
+ text: input.text,
633
+ resources: input.resources,
634
+ tools: input.tools,
635
+ model: requestedModel,
636
+ reasoningEffort: requestedReasoningEffort,
637
+ clientEventId: input.clientEventId ?? null,
638
+ mcpCredentialUpdates: input.mcpCredentialUpdates ?? [],
639
+ delivery: input.delivery ?? "queue",
640
+ ...(input.expectedControlGeneration !== undefined
641
+ ? { expectedControlGeneration: input.expectedControlGeneration }
642
+ : {}),
643
+ ...(input.expectedWorkspaceInferenceGeneration !== undefined
644
+ ? {
645
+ expectedWorkspaceInferenceGeneration: input.expectedWorkspaceInferenceGeneration,
646
+ }
647
+ : {}),
648
+ reasoningEffortFallback: settings.openaiReasoningEffort,
649
+ });
650
+ } catch (error) {
651
+ if (error instanceof SessionQueueConflictError) {
652
+ throw new HTTPException(409, { message: error.message });
559
653
  }
560
- const mcpCredentialUpdates = input.mcpCredentialUpdates?.length
561
- ? await lockedUpdate.updateSessionMcpServerCredentials(input.mcpCredentialUpdates)
562
- : { servers: [], missingIds: [] };
563
- if (mcpCredentialUpdates.missingIds.length > 0) {
564
- throw new HTTPException(422, { message: `unknown session MCP server id: ${mcpCredentialUpdates.missingIds[0]}` });
654
+ if (error instanceof Error && error.message.includes("cancelled")) {
655
+ throw new HTTPException(409, { message: error.message });
565
656
  }
566
- const nextResources = mergeResourceRefs(lockedSession.resources, input.resources);
567
- const nextTools = mergeToolRefs(lockedSession.tools, input.tools);
568
- const shouldQueueSession = lockedSession.status === "idle" || lockedSession.status === "failed";
569
- return {
570
- events: [
571
- {
572
- type: "user.message",
573
- payload: {
574
- text: input.text,
575
- ...(input.resources.length ? { resources: input.resources } : {}),
576
- ...(input.tools.length ? { tools: input.tools } : {}),
577
- ...(requestedModel ? { model: requestedModel } : {}),
578
- ...(requestedReasoningEffort ? { reasoningEffort: requestedReasoningEffort } : {}),
579
- ...(mcpCredentialUpdates.servers.length ? { mcpCredentialUpdates: mcpCredentialUpdates.servers } : {}),
580
- },
581
- ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
582
- },
583
- ...(shouldQueueSession ? [{ type: "session.status.changed" as const, payload: { status: "queued" } }] : []),
584
- ],
585
- update: {
586
- resources: nextResources,
587
- tools: nextTools,
588
- ...(shouldQueueSession ? { status: "queued" as const, activeTurnId: null } : {}),
589
- },
590
- };
591
- }).then(async (events) => {
592
- await bus.publish(workspaceId, sessionId, events);
593
- return events;
594
- });
595
- const accepted = appended[0];
596
- if (!accepted) {
597
- throw new HTTPException(500, { message: "failed to append client event" });
598
- }
599
- const workflowId = workflowIdForSession(sessionId);
600
- const session = await requireSession(db, workspaceId, sessionId);
601
- const turn = await enqueueSessionTurn(db, {
602
- accountId,
603
- workspaceId,
604
- sessionId,
605
- triggerEventId: accepted.id,
606
- temporalWorkflowId: workflowId,
607
- source: "user",
608
- prompt: input.text,
609
- resources: input.resources,
610
- tools: input.tools,
611
- model: requestedModel ?? session.model,
612
- reasoningEffort: requestedReasoningEffort ?? reasoningEffortForSession(session.metadata, settings.openaiReasoningEffort),
613
- sandboxBackend: session.sandboxBackend,
614
- metadata: {},
615
- });
616
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
617
- type: "turn.queued",
618
- turnId: turn.id,
619
- payload: { turnId: turn.id, triggerEventId: accepted.id, source: turn.source },
620
- }]);
621
- await workflowClient.wakeSessionWorkflow({ accountId, workspaceId, sessionId, workflowId });
622
- return { accepted, turn };
657
+ if (error instanceof Error && error.message.startsWith("Unknown session MCP server")) {
658
+ throw new HTTPException(422, { message: error.message });
659
+ }
660
+ throw error;
661
+ }
662
+ await bus.publish(workspaceId, sessionId, result.events);
663
+ if (result.shouldSignalControl && result.controlEvent) {
664
+ if (result.workflowWakeRevision === null) {
665
+ throw new Error("Steer control has no workflow wake revision");
666
+ }
667
+ await workflowClient.signalSessionControl({
668
+ accountId,
669
+ workspaceId,
670
+ sessionId,
671
+ eventId: result.controlEvent.id,
672
+ workflowId: result.temporalWorkflowId,
673
+ workflowWakeRevision: result.workflowWakeRevision,
674
+ });
675
+ } else if (result.shouldWake) {
676
+ if (result.workflowWakeRevision === null) {
677
+ throw new Error("Runnable prompt has no workflow wake revision");
678
+ }
679
+ await workflowClient.wakeSessionWorkflow({
680
+ accountId,
681
+ workspaceId,
682
+ sessionId,
683
+ workflowId: result.temporalWorkflowId,
684
+ wakeRevision: result.workflowWakeRevision,
685
+ });
686
+ }
687
+ return {
688
+ accepted: result.accepted,
689
+ turn: result.turn,
690
+ };
623
691
  }
624
692
 
625
693
  /**
626
694
  * Full create-session flow shared by `POST /sessions` and the first-party MCP
627
- * `session_create` tool: payload validation, resource/tool/environment
695
+ * `session_create` tool: payload validation, resource/tool/variableSet
628
696
  * checks, usage limits, session start, and usage recording. `rawPayload` is
629
697
  * the unparsed request body so absent-vs-empty `tools` keeps its meaning
630
698
  * (absent applies the workspace's default capability MCP tools).
@@ -637,9 +705,20 @@ export async function createSessionForRequest(
637
705
  ): Promise<Session> {
638
706
  const { settings, db, bus, workflowClient, objectStorage } = deps;
639
707
  const payload = CreateSessionRequest.parse(rawPayload);
640
- const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
641
- const sessionMcpServers = validateSessionMcpServersForCreate(capabilityRuntimeSettings, grant, payload.mcpServers);
642
- const runtimeSettings = settingsWithSessionMcpServerConfigs(capabilityRuntimeSettings, sessionMcpServers.runtimeServers);
708
+ const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
709
+ db,
710
+ workspaceId,
711
+ settings,
712
+ );
713
+ const sessionMcpServers = validateSessionMcpServersForCreate(
714
+ capabilityRuntimeSettings,
715
+ grant,
716
+ payload.mcpServers,
717
+ );
718
+ const runtimeSettings = settingsWithSessionMcpServerConfigs(
719
+ capabilityRuntimeSettings,
720
+ sessionMcpServers.runtimeServers,
721
+ );
643
722
  const resources = normalizeResources(payload.resources);
644
723
  const requestedTools = validateToolRefs(payload.tools, runtimeSettings);
645
724
  const defaultedTools = hasOwnProperty(rawPayload, "tools")
@@ -647,7 +726,7 @@ export async function createSessionForRequest(
647
726
  : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, capabilityRuntimeSettings);
648
727
  // The first-party MCP server is attached to EVERY session. It hosts the
649
728
  // session's own metadata tool (set_session_title) + goal tools, and — only
650
- // when the grant carries the permission — the orchestration/environment/
729
+ // when the grant carries the permission — the orchestration/variableSet/
651
730
  // github tools. Capability is gated per-tool by permission, never by whether
652
731
  // the server is attached, so a bare chat still gets titling while the
653
732
  // dangerous tools stay off by default.
@@ -657,13 +736,57 @@ export async function createSessionForRequest(
657
736
  throw new HTTPException(503, { message: "object storage is not configured" });
658
737
  }
659
738
  await validateFileResources(db, workspaceId, resources);
660
- // Environment attachment requires environments:use on the calling grant
661
- // (validateEnvironmentAttachment enforces it), preserving the invariant
739
+ // VariableSet attachment requires variable-sets:use on the calling grant
740
+ // (validateVariableSetAttachment enforces it), preserving the invariant
662
741
  // that sandboxed agents cannot self-attach workspace secrets.
663
- const environment = payload.environmentId
664
- ? await validateEnvironmentAttachment({ settings, db }, grant, workspaceId, payload.environmentId)
742
+ const variableSet = payload.variableSetId
743
+ ? await validateVariableSetAttachment(
744
+ { settings, db },
745
+ grant,
746
+ workspaceId,
747
+ payload.variableSetId,
748
+ )
665
749
  : null;
750
+ // RIG BINDING (M3). Resolve the rig this session rides — the EXPLICIT payload
751
+ // rigId when given, else the workspace default rig (workspaces.default_rig_id)
752
+ // — and FREEZE both the rig id and its currently-ACTIVE version onto the row.
753
+ // The session then rides that exact version for its whole life; a later
754
+ // promote never moves it. Rig-less (both null) when neither resolves, which is
755
+ // byte-for-byte today's behavior (zero extra work, zero row change).
756
+ // - An EXPLICIT unknown/inactive rigId is a caller error → 422.
757
+ // - A stale workspace-default rig (deleted → FK-nulled, or somehow with no
758
+ // active version) degrades SILENTLY to rig-less: an operator-side default
759
+ // must never brick every create in the workspace.
760
+ const requestedRigId = payload.rigId ?? (await getWorkspaceDefaultRigId(db, workspaceId));
761
+ let frozenRigId: string | null = null;
762
+ let frozenRigVersionId: string | null = null;
763
+ if (requestedRigId) {
764
+ const rig = await getRig(db, workspaceId, requestedRigId);
765
+ if (!rig || !rig.activeVersion) {
766
+ if (payload.rigId) {
767
+ throw new HTTPException(422, {
768
+ message: rig
769
+ ? `rig ${payload.rigId} has no active version to bind`
770
+ : `unknown rigId: ${payload.rigId}`,
771
+ });
772
+ }
773
+ // else: workspace-default fallback that no longer resolves → rig-less.
774
+ } else {
775
+ frozenRigId = rig.id;
776
+ frozenRigVersionId = rig.activeVersion.id;
777
+ }
778
+ }
666
779
  assertConfiguredModel(settings, payload.model);
780
+ // Session creation persists the EFFECTIVE model — an omitted payload.model
781
+ // stamps the deployment default onto the session — so the policy must vet
782
+ // that effective value, not just explicit ones (a restricted workspace's
783
+ // default-model session would otherwise be born blocked).
784
+ await assertWorkspaceModelPolicyAllows(
785
+ db,
786
+ settings,
787
+ workspaceId,
788
+ payload.model ?? settings.openaiModel,
789
+ );
667
790
  const model = payload.model ?? settings.openaiModel;
668
791
  const reasoningEffort = payload.reasoningEffort ?? settings.openaiReasoningEffort;
669
792
  // A session's first-party MCP token can carry a non-default permission set
@@ -674,11 +797,16 @@ export async function createSessionForRequest(
674
797
  if (firstPartyMcpPermissions && firstPartyMcpPermissions.length === 0) {
675
798
  // An empty set would sign an unusable zero-permission token; the default
676
799
  // worker set is expressed by omitting the field.
677
- throw new HTTPException(422, { message: "firstPartyMcpPermissions must not be empty; omit it for the default worker permission set" });
800
+ throw new HTTPException(422, {
801
+ message:
802
+ "firstPartyMcpPermissions must not be empty; omit it for the default worker permission set",
803
+ });
678
804
  }
679
805
  for (const permission of firstPartyMcpPermissions ?? []) {
680
806
  if (!hasPermission(grant.permissions, permission)) {
681
- throw new HTTPException(403, { message: `cannot grant first-party MCP permission beyond the creating grant: ${permission}` });
807
+ throw new HTTPException(403, {
808
+ message: `cannot grant first-party MCP permission beyond the creating grant: ${permission}`,
809
+ });
682
810
  }
683
811
  }
684
812
  // Invariant: a goal-bearing session always carries goals:manage in its
@@ -690,7 +818,11 @@ export async function createSessionForRequest(
690
818
  // spawned session itself via the worker-signed sessionId claim, so a
691
819
  // worker managing its OWN goal is not an escalation of the spawner's
692
820
  // authority.
693
- if (payload.goal && firstPartyMcpPermissions && !firstPartyMcpPermissions.includes("goals:manage")) {
821
+ if (
822
+ payload.goal &&
823
+ firstPartyMcpPermissions &&
824
+ !firstPartyMcpPermissions.includes("goals:manage")
825
+ ) {
694
826
  firstPartyMcpPermissions = [...firstPartyMcpPermissions, "goals:manage"];
695
827
  }
696
828
  // Parent linkage: a worker is linked to its manager ONLY from the
@@ -704,7 +836,10 @@ export async function createSessionForRequest(
704
836
  // its completion wake injects a user.message + queued turn into that session
705
837
  // without holding sessions:control on it (a cross-session write escalation).
706
838
  // The claim is the only trustworthy parent source.
707
- const parentSessionId = typeof grant.metadata?.["sessionId"] === "string" ? grant.metadata["sessionId"] as string : null;
839
+ const parentSessionId =
840
+ typeof grant.metadata?.["sessionId"] === "string"
841
+ ? (grant.metadata["sessionId"] as string)
842
+ : null;
708
843
  // Shared-sandbox placement (addendum 05 §D.2/§D.3, decision I10/OD-S1).
709
844
  //
710
845
  // The DEFAULT rule is context-dependent and resolved server-side from the
@@ -724,39 +859,75 @@ export async function createSessionForRequest(
724
859
  const sandboxChoice = payload.sandbox ?? (parentSessionId ? "shared" : "new");
725
860
  let sandboxGroupId: string | null = null;
726
861
  let inheritedBackend: Session["sandboxBackend"] | undefined;
727
- // ENV-AWARE GROUPING: under the CURRENT mechanics the workspace Environment is
862
+ // ENV-AWARE GROUPING: under the CURRENT mechanics the workspace VariableSet is
728
863
  // creation-time box state — the box's manifest env is fixed when it is cold-
729
864
  // created, and the SDK's provided-session guard rejects any manifest-env delta
730
- // at attach. A session carrying a DIFFERENT Environment than the box it joins
865
+ // at attach. A session carrying a DIFFERENT VariableSet than the box it joins
731
866
  // is therefore a genuine shared-state conflict TODAY: its first turn on a warm
732
- // box dies with "Live sandbox sessions cannot change manifest environment
733
- // variables" (proven live, sessions 5aee77e9 + 63d18823). Until the Environment
867
+ // box dies with "Live sandbox sessions cannot change manifest variableSet
868
+ // variables" (proven live, sessions 5aee77e9 + 63d18823). Until the VariableSet
734
869
  // is evicted from the manifest (per-exec, like the git token), grouping must be
735
870
  // env-aware: the INHERITED default falls back to an own box on mismatch (a
736
871
  // credentialed worker spawned from a credential-less manager just works), and
737
- // an EXPLICIT shared/{groupId} request with a mismatched Environment fails
872
+ // an EXPLICIT shared/{groupId} request with a mismatched VariableSet fails
738
873
  // fast at create (422) instead of poisoning the session's first turn.
739
874
  // The env conflict is a BOX property, so a boxless group is exempt: a
740
875
  // backend:"none" session runs in-process with no sandbox, no manifest, and no
741
876
  // provided-session attach — no shared box state exists to conflict, and
742
877
  // env-differing spawns from such parents shared safely before the env-aware
743
878
  // check. They keep sharing (and keep inheriting "none").
744
- const requestedEnvironmentId = payload.environmentId ?? null;
745
- const environmentMatchesGroup = (memberEnvironmentId: string | null): boolean =>
746
- memberEnvironmentId === requestedEnvironmentId;
879
+ const requestedVariableSetId = payload.variableSetId ?? null;
880
+ const variableSetMatchesGroup = (memberVariableSetId: string | null): boolean =>
881
+ memberVariableSetId === requestedVariableSetId;
882
+ // RIG-AWARE GROUPING (M3), the exact sibling of the env-aware gate above: the
883
+ // box's rig-baked setup/tooling is fixed at cold-create, so a session joining a
884
+ // shared box must ride the SAME frozen rig_version_id. A mismatch is a genuine
885
+ // shared-state conflict (the box was set up for a different rig) — the INHERITED
886
+ // default falls back to an own box, an EXPLICIT shared/{groupId} request 422s at
887
+ // create rather than poisoning the first turn on the lease's rig-conflict guard.
888
+ // null on either side = compatible (a rig-less session shares with a rig-less
889
+ // box exactly as today); the boxless backend:'none' exemption is shared with the
890
+ // env gate (no box state to conflict).
891
+ const rigVersionMatchesGroup = (memberRigVersionId: string | null): boolean =>
892
+ memberRigVersionId === frozenRigVersionId;
747
893
  if (sandboxChoice === "shared") {
748
894
  if (!parentSessionId) {
749
- throw new HTTPException(422, { message: "sandbox:'shared' requires a parent session (spawn from inside a session); use 'new' for a top-level create." });
895
+ throw new HTTPException(422, {
896
+ message:
897
+ "sandbox:'shared' requires a parent session (spawn from inside a session); use 'new' for a top-level create.",
898
+ });
750
899
  }
751
900
  const parent = await getSession(db, workspaceId, parentSessionId);
752
901
  if (!parent) {
753
- throw new HTTPException(404, { message: `parent session not found in workspace: ${parentSessionId}` });
902
+ throw new HTTPException(404, {
903
+ message: `parent session not found in workspace: ${parentSessionId}`,
904
+ });
905
+ }
906
+ const parentBoxed = parent.sandboxBackend !== "none";
907
+ const variableSetMismatch =
908
+ parentBoxed && !variableSetMatchesGroup(parent.variableSetId ?? null);
909
+ let rigMismatch = parentBoxed && !rigVersionMatchesGroup(parent.rigVersionId ?? null);
910
+ if (parentBoxed && !rigMismatch) {
911
+ const memberRigVersionIds = await listDistinctRigVersionIdsInGroup(
912
+ db,
913
+ workspaceId,
914
+ parent.sandboxGroupId,
915
+ );
916
+ rigMismatch = !memberRigVersionIds.every((memberRigVersionId) =>
917
+ rigVersionMatchesGroup(memberRigVersionId),
918
+ );
754
919
  }
755
- if (parent.sandboxBackend !== "none" && !environmentMatchesGroup(parent.environmentId ?? null)) {
920
+ if (variableSetMismatch || rigMismatch) {
756
921
  if (payload.sandbox === "shared") {
757
922
  // The caller explicitly asked to share while carrying a different
758
- // Environment — surface the conflict at create time, not turn time.
759
- throw new HTTPException(422, { message: "sandbox:'shared' requires the same environment as the creator's box (the box environment is fixed at creation); omit sandbox or pass 'new' when attaching a different environment." });
923
+ // VariableSet / rig — surface the conflict at create time, not turn time.
924
+ // VariableSet is checked first so its (pre-rig) message is unchanged for
925
+ // the env-only mismatch the existing gate already covered.
926
+ throw new HTTPException(422, {
927
+ message: variableSetMismatch
928
+ ? "sandbox:'shared' requires the same variableSet / same environment as the creator's box (the box variable set/environment is fixed at creation); omit sandbox or pass 'new' when attaching a different variableSet/environment."
929
+ : "sandbox:'shared' requires the same rig as the creator's box (the box's rig setup is fixed at creation); omit sandbox or pass 'new' when binding a different rig.",
930
+ });
760
931
  }
761
932
  // Inherited default: deterministic separation on the genuine shared-state
762
933
  // conflict — the worker gets its own box (resolved like a top-level
@@ -769,17 +940,46 @@ export async function createSessionForRequest(
769
940
  } else if (typeof sandboxChoice === "object") {
770
941
  const member = await getAnySessionInGroup(db, workspaceId, sandboxChoice.groupId);
771
942
  if (!member) {
772
- throw new HTTPException(404, { message: `sandbox group not found in workspace: ${sandboxChoice.groupId}` });
943
+ throw new HTTPException(404, {
944
+ message: `sandbox group not found in workspace: ${sandboxChoice.groupId}`,
945
+ });
773
946
  }
774
947
  if (member.sandboxBackend !== "none") {
775
948
  // Compare against EVERY member, not one arbitrary row: a legacy env-blind
776
- // group can carry mixed environmentIds, and an any-member read would make
949
+ // group can carry mixed variableSetIds, and an any-member read would make
777
950
  // the join verdict nondeterministic. Post-env-aware groups are homogeneous
778
951
  // (both join paths enforce equality), so this reads one distinct value in
779
952
  // the common case; a mixed legacy group deterministically rejects.
780
- const memberEnvironmentIds = await listDistinctEnvironmentIdsInGroup(db, workspaceId, sandboxChoice.groupId);
781
- if (!memberEnvironmentIds.every((memberEnvironmentId) => environmentMatchesGroup(memberEnvironmentId))) {
782
- throw new HTTPException(422, { message: `sandbox group ${sandboxChoice.groupId} runs a different environment (the box environment is fixed at creation); create with the group's environment or omit sandbox for an own box.` });
953
+ const memberVariableSetIds = await listDistinctVariableSetIdsInGroup(
954
+ db,
955
+ workspaceId,
956
+ sandboxChoice.groupId,
957
+ );
958
+ if (
959
+ !memberVariableSetIds.every((memberVariableSetId) =>
960
+ variableSetMatchesGroup(memberVariableSetId),
961
+ )
962
+ ) {
963
+ throw new HTTPException(422, {
964
+ message: `sandbox group ${sandboxChoice.groupId} runs a different variableSet / different environment (the box variable set/environment is fixed at creation); create with the group's variableSet/environment or omit sandbox for an own box.`,
965
+ });
966
+ }
967
+ // Same deterministic all-members check for the frozen rig version (M3): the
968
+ // box's rig setup is fixed at creation, so every member must ride the rig
969
+ // this create resolved (or the group is rig-less and so is this create).
970
+ const memberRigVersionIds = await listDistinctRigVersionIdsInGroup(
971
+ db,
972
+ workspaceId,
973
+ sandboxChoice.groupId,
974
+ );
975
+ if (
976
+ !memberRigVersionIds.every((memberRigVersionId) =>
977
+ rigVersionMatchesGroup(memberRigVersionId),
978
+ )
979
+ ) {
980
+ throw new HTTPException(422, {
981
+ message: `sandbox group ${sandboxChoice.groupId} runs a different rig (the box's rig setup is fixed at creation); create with the group's rig or omit sandbox for an own box.`,
982
+ });
783
983
  }
784
984
  }
785
985
  sandboxGroupId = sandboxChoice.groupId;
@@ -791,7 +991,10 @@ export async function createSessionForRequest(
791
991
  // — reject it at the edge (mirrors the backend:'none' guard) rather than silently
792
992
  // dropping it, since the default group box has no working-dir seam yet.
793
993
  if (payload.workingDir !== undefined && !payload.targetSandboxId) {
794
- throw new HTTPException(422, { message: "workingDir requires targetSandboxId (it is the targeted machine's working directory)" });
994
+ throw new HTTPException(422, {
995
+ message:
996
+ "workingDir requires targetSandboxId (it is the targeted machine's working directory)",
997
+ });
795
998
  }
796
999
  // Honest-label (Stage-D closure): a top-level session TARGETED at a Connected
797
1000
  // Machine (a selfhosted sandbox) runs machine-primary every turn, so its HOME
@@ -821,23 +1024,32 @@ export async function createSessionForRequest(
821
1024
  let machineHomeBackend: Session["sandboxBackend"] | undefined;
822
1025
  let machineHomeOs: Session["sandboxOs"] | undefined;
823
1026
  if (
824
- payload.targetSandboxId
825
- && inheritedBackend === undefined
826
- && settings.sandboxOwnershipEnabled
827
- && settings.sandboxSelfhostedEnabled
1027
+ payload.targetSandboxId &&
1028
+ inheritedBackend === undefined &&
1029
+ settings.sandboxOwnershipEnabled &&
1030
+ settings.sandboxSelfhostedEnabled
828
1031
  ) {
829
1032
  const targetSandbox = await getSandbox(db, workspaceId, payload.targetSandboxId);
830
1033
  if (targetSandbox?.kind === "selfhosted") {
831
1034
  machineHomeBackend = "selfhosted";
832
1035
  if (targetSandbox.enrollmentId) {
833
1036
  const enrollment = await getEnrollment(db, workspaceId, targetSandbox.enrollmentId);
834
- if (enrollment && (enrollment.os === "macos" || enrollment.os === "windows" || enrollment.os === "linux")) {
1037
+ if (
1038
+ enrollment &&
1039
+ (enrollment.os === "macos" || enrollment.os === "windows" || enrollment.os === "linux")
1040
+ ) {
835
1041
  machineHomeOs = enrollment.os;
836
1042
  }
837
1043
  }
838
1044
  }
839
1045
  }
840
- await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "agent_run:create", quantity: 1, model });
1046
+ await requireLimit(deps, {
1047
+ accountId: grant.accountId,
1048
+ workspaceId,
1049
+ action: "agent_run:create",
1050
+ quantity: 1,
1051
+ model,
1052
+ });
841
1053
  const session = await createAndStartSession({
842
1054
  db,
843
1055
  bus,
@@ -855,14 +1067,18 @@ export async function createSessionForRequest(
855
1067
  // machine-targeted top-level create labels the home "selfhosted"
856
1068
  // (machineHomeBackend), overriding the caller/deployment default so the row
857
1069
  // matches where the session actually runs.
858
- sandboxBackend: inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
1070
+ sandboxBackend:
1071
+ inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
859
1072
  // Mirror the backend relabel on the OS axis: only a machine-targeted
860
1073
  // top-level create carries a derived OS; everything else is omitted and the
861
1074
  // "linux" default holds (shared spawns keep the parent-box behavior).
862
1075
  ...(machineHomeOs ? { sandboxOs: machineHomeOs } : {}),
863
1076
  sandboxGroupId,
864
1077
  metadata: payload.metadata,
865
- environment: environment ? { id: environment.id, name: environment.name } : null,
1078
+ variableSet: variableSet ? { id: variableSet.id, name: variableSet.name } : null,
1079
+ // Frozen rig binding (M3): both null for a rig-less session (today's path).
1080
+ rigId: frozenRigId,
1081
+ rigVersionId: frozenRigVersionId,
866
1082
  goal: payload.goal ?? null,
867
1083
  // Per-session persona instructions (already trimmed/validated by the
868
1084
  // contracts schema). Persisted on the row; composed system-level at turn
@@ -903,7 +1119,7 @@ export async function createSessionForRequest(
903
1119
  * workspace's default capability MCP tools, matching an absent `tools` key.
904
1120
  */
905
1121
  export async function acceptSessionUserMessage(
906
- deps: ApiRouteDeps,
1122
+ deps: AcceptSessionUserMessageDependencies,
907
1123
  grant: AccessGrant,
908
1124
  workspaceId: string,
909
1125
  sessionId: string,
@@ -916,15 +1132,26 @@ export async function acceptSessionUserMessage(
916
1132
  reasoningEffort?: ReasoningEffort | null;
917
1133
  clientEventId?: string;
918
1134
  mcpCredentialUpdates?: SessionMcpCredentialUpdateInput[];
1135
+ delivery?: "queue" | "steer";
1136
+ origin?: "human" | "operator";
1137
+ expectedControlGeneration?: number;
1138
+ expectedWorkspaceInferenceGeneration?: number;
919
1139
  },
920
1140
  ): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
921
1141
  const { settings, db, bus, workflowClient, objectStorage } = deps;
922
- const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
1142
+ const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
1143
+ db,
1144
+ workspaceId,
1145
+ settings,
1146
+ );
923
1147
  // Hoisted above requireLimit so the codex-billed predicate can resolve the
924
1148
  // turn's effective model (a follow-up turn inherits the session's model). A
925
1149
  // pure read with no side effects.
926
1150
  const existingSession = await requireSession(db, workspaceId, sessionId);
927
- const runtimeSettings = settingsWithSessionMcpServerMetadata(capabilityRuntimeSettings, existingSession.mcpServers);
1151
+ const runtimeSettings = settingsWithSessionMcpServerMetadata(
1152
+ capabilityRuntimeSettings,
1153
+ existingSession.mcpServers,
1154
+ );
928
1155
  const requestedResources = normalizeResources(input.resources ?? []);
929
1156
  const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
930
1157
  const requestedTools = input.toolsProvided
@@ -941,7 +1168,10 @@ export async function acceptSessionUserMessage(
941
1168
  throw new HTTPException(503, { message: "object storage is not configured" });
942
1169
  }
943
1170
  await validateFileResources(db, workspaceId, requestedResources);
944
- await validateGitHubRepositorySelection(db, workspaceId, [...existingSession.resources, ...requestedResources]);
1171
+ await validateGitHubRepositorySelection(db, workspaceId, [
1172
+ ...existingSession.resources,
1173
+ ...requestedResources,
1174
+ ]);
945
1175
  const mcpCredentialUpdates = validateSessionMcpCredentialUpdates({
946
1176
  settings,
947
1177
  grant,
@@ -962,6 +1192,17 @@ export async function acceptSessionUserMessage(
962
1192
  model: input.model ?? null,
963
1193
  reasoningEffort: input.reasoningEffort ?? null,
964
1194
  mcpCredentialUpdates,
1195
+ delivery: input.delivery ?? "queue",
1196
+ origin: input.origin ?? "human",
1197
+ actor: grant.subjectId,
1198
+ ...(input.expectedControlGeneration !== undefined
1199
+ ? { expectedControlGeneration: input.expectedControlGeneration }
1200
+ : {}),
1201
+ ...(input.expectedWorkspaceInferenceGeneration !== undefined
1202
+ ? {
1203
+ expectedWorkspaceInferenceGeneration: input.expectedWorkspaceInferenceGeneration,
1204
+ }
1205
+ : {}),
965
1206
  ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
966
1207
  });
967
1208
  await recordWorkspaceUsage(deps, {
@@ -997,18 +1238,31 @@ export async function updateSessionTitle(
997
1238
  const { db, bus } = deps;
998
1239
  const result = await updateSessionTitleRow(db, { workspaceId, sessionId, title, source });
999
1240
  if (result.updated) {
1000
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
1001
- type: "session.title_set",
1002
- payload: {
1003
- title: result.title ?? title,
1004
- source,
1241
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
1242
+ {
1243
+ type: "session.title_set",
1244
+ payload: {
1245
+ title: result.title ?? title,
1246
+ source,
1247
+ },
1005
1248
  },
1006
- }]);
1249
+ ]);
1007
1250
  }
1008
1251
  return result;
1009
1252
  }
1010
1253
 
1011
- function withFirstPartyTools(tools: ToolRef[], runtimeSettings: { mcpServers: Array<{ id: string }> }): ToolRef[] {
1254
+ export async function readSessionLineage(db: Database, workspaceId: string, sessionId: string) {
1255
+ const lineage = await getSessionLineage(db, workspaceId, sessionId);
1256
+ if (!lineage) {
1257
+ throw new HTTPException(404, { message: "session not found" });
1258
+ }
1259
+ return lineage;
1260
+ }
1261
+
1262
+ function withFirstPartyTools(
1263
+ tools: ToolRef[],
1264
+ runtimeSettings: { mcpServers: Array<{ id: string }> },
1265
+ ): ToolRef[] {
1012
1266
  if (!runtimeSettings.mcpServers.some((server) => server.id === "opengeni")) {
1013
1267
  return tools;
1014
1268
  }
@@ -1016,5 +1270,7 @@ function withFirstPartyTools(tools: ToolRef[], runtimeSettings: { mcpServers: Ar
1016
1270
  }
1017
1271
 
1018
1272
  function hasOwnProperty(value: unknown, key: string): boolean {
1019
- return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
1273
+ return Boolean(
1274
+ value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key),
1275
+ );
1020
1276
  }