@opengeni/core 0.4.6 → 0.4.8

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,52 @@ 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
+ encryptVariableSetValue,
26
24
  getAnySessionInGroup,
27
25
  getEnrollment,
28
- listDistinctEnvironmentIdsInGroup,
26
+ getRig,
27
+ getWorkspaceDefaultRigId,
28
+ listDistinctVariableSetIdsInGroup,
29
+ listDistinctRigVersionIdsInGroup,
29
30
  getSandbox,
30
31
  getSession,
31
32
  getSessionByCreateIdempotencyKey,
33
+ getSessionEvent,
34
+ getWorkspaceControlEvent,
35
+ getSessionLineage,
32
36
  getSessionTurn,
37
+ getWorkspaceModelPolicy,
38
+ initializeSessionStartAtomically,
33
39
  requireSession,
34
- setTemporalWorkflowId,
40
+ submitHumanPromptInTransaction,
35
41
  updateSessionTitle as updateSessionTitleRow,
42
+ withWorkspaceSubjectRls,
36
43
  type CreateSessionMcpServerInput,
37
44
  type Database,
38
45
  type UpdateSessionMcpServerCredentialsInput,
46
+ QueueCommandConflictError,
47
+ SessionControlConflictError,
39
48
  } from "@opengeni/db";
40
- import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
49
+ import {
50
+ appendAndPublishEvents,
51
+ publishDurableSessionEvents,
52
+ publishDurableWorkspaceControlEvent,
53
+ type EventBus,
54
+ } from "@opengeni/events";
41
55
  import { HTTPException } from "hono/http-exception";
42
56
  import { hasPermission, requirePermission } from "../access";
43
57
  import { recordWorkspaceUsage, requireLimit } from "../billing/limits";
44
- import type { ApiRouteDeps, SessionWorkflowClient } from "../dependencies";
58
+ import type {
59
+ AcceptSessionUserMessageDependencies,
60
+ ApiRouteDeps,
61
+ SessionWorkflowClient,
62
+ } from "../dependencies";
45
63
  import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
46
64
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
47
- import { requireEnvironmentEncryption, validateEnvironmentAttachment } from "./environments";
65
+ import { requireVariableSetEncryption, validateVariableSetAttachment } from "./environments";
48
66
  import {
49
- mergeResourceRefs,
50
67
  mergeToolRefs,
51
68
  normalizeResources,
52
69
  validateFileResources,
@@ -67,13 +84,19 @@ type ValidatedSessionMcpServers = {
67
84
  metadata: SessionMcpServerMetadata[];
68
85
  };
69
86
 
70
- function normalizedSessionMcpCredentialHeaders(headers: Record<string, string> | undefined): Record<string, string> {
87
+ function normalizedSessionMcpCredentialHeaders(
88
+ headers: Record<string, string> | undefined,
89
+ ): Record<string, string> {
71
90
  if (!headers) {
72
91
  return {};
73
92
  }
74
- const entries = Object.entries(headers).map(([name, value]) => [name.trim(), value] as const).filter(([name]) => name.length > 0);
93
+ const entries = Object.entries(headers)
94
+ .map(([name, value]) => [name.trim(), value] as const)
95
+ .filter(([name]) => name.length > 0);
75
96
  if (entries.length > maxSessionMcpCredentialHeaders) {
76
- throw new HTTPException(422, { message: `a session MCP server supports at most ${maxSessionMcpCredentialHeaders} credential headers` });
97
+ throw new HTTPException(422, {
98
+ message: `a session MCP server supports at most ${maxSessionMcpCredentialHeaders} credential headers`,
99
+ });
77
100
  }
78
101
  const seen = new Set<string>();
79
102
  for (const [name, value] of entries) {
@@ -86,12 +109,16 @@ function normalizedSessionMcpCredentialHeaders(headers: Record<string, string> |
86
109
  }
87
110
  seen.add(lower);
88
111
  if (value.length === 0 || value.length > maxSessionMcpCredentialHeaderValueLength) {
89
- throw new HTTPException(422, { message: `credential header ${name} must be 1-${maxSessionMcpCredentialHeaderValueLength} characters` });
112
+ throw new HTTPException(422, {
113
+ message: `credential header ${name} must be 1-${maxSessionMcpCredentialHeaderValueLength} characters`,
114
+ });
90
115
  }
91
116
  // RFC 9110 §5.5: field values are HTAB / printable characters.
92
117
  // eslint-disable-next-line no-control-regex
93
118
  if (/[\u0000-\u0008\u000A-\u001F\u007F]/.test(value)) {
94
- throw new HTTPException(422, { message: `credential header ${name} contains forbidden control characters` });
119
+ throw new HTTPException(422, {
120
+ message: `credential header ${name} contains forbidden control characters`,
121
+ });
95
122
  }
96
123
  }
97
124
  return Object.fromEntries(entries);
@@ -109,7 +136,9 @@ function mcpServerConfigFromInput(server: SessionMcpServerInput): Settings["mcpS
109
136
  };
110
137
  }
111
138
 
112
- function mcpServerConfigFromMetadata(server: SessionMcpServerMetadata): Settings["mcpServers"][number] {
139
+ function mcpServerConfigFromMetadata(
140
+ server: SessionMcpServerMetadata,
141
+ ): Settings["mcpServers"][number] {
113
142
  return {
114
143
  id: server.id,
115
144
  ...(server.name ? { name: server.name } : {}),
@@ -118,21 +147,24 @@ function mcpServerConfigFromMetadata(server: SessionMcpServerMetadata): Settings
118
147
  };
119
148
  }
120
149
 
121
- function settingsWithSessionMcpServerConfigs(settings: Settings, servers: Settings["mcpServers"]): Settings {
150
+ function settingsWithSessionMcpServerConfigs(
151
+ settings: Settings,
152
+ servers: Settings["mcpServers"],
153
+ ): Settings {
122
154
  if (servers.length === 0) {
123
155
  return settings;
124
156
  }
125
157
  const sessionIds = new Set(servers.map((server) => server.id));
126
158
  return {
127
159
  ...settings,
128
- mcpServers: [
129
- ...settings.mcpServers.filter((server) => !sessionIds.has(server.id)),
130
- ...servers,
131
- ],
160
+ mcpServers: [...settings.mcpServers.filter((server) => !sessionIds.has(server.id)), ...servers],
132
161
  };
133
162
  }
134
163
 
135
- export function settingsWithSessionMcpServerMetadata(settings: Settings, servers: SessionMcpServerMetadata[]): Settings {
164
+ export function settingsWithSessionMcpServerMetadata(
165
+ settings: Settings,
166
+ servers: SessionMcpServerMetadata[],
167
+ ): Settings {
136
168
  return settingsWithSessionMcpServerConfigs(settings, servers.map(mcpServerConfigFromMetadata));
137
169
  }
138
170
 
@@ -145,7 +177,7 @@ function validateSessionMcpServersForCreate(
145
177
  return { runtimeServers: [], dbServers: [], metadata: [] };
146
178
  }
147
179
  requirePermission(grant, "mcp_servers:attach");
148
- const encryptionKey = requireEnvironmentEncryption(settings);
180
+ const encryptionKey = requireVariableSetEncryption(settings);
149
181
  const existingIds = new Set(settings.mcpServers.map((server) => server.id));
150
182
  const seenIds = new Set<string>();
151
183
  const runtimeServers: Settings["mcpServers"] = [];
@@ -161,7 +193,10 @@ function validateSessionMcpServersForCreate(
161
193
  }
162
194
  const headers = normalizedSessionMcpCredentialHeaders(server.headers);
163
195
  const headersEncrypted = Object.fromEntries(
164
- Object.entries(headers).map(([name, value]) => [name, encryptEnvironmentValue(encryptionKey, value)]),
196
+ Object.entries(headers).map(([name, value]) => [
197
+ name,
198
+ encryptVariableSetValue(encryptionKey, value),
199
+ ]),
165
200
  );
166
201
  runtimeServers.push(mcpServerConfigFromInput(server));
167
202
  dbServers.push({
@@ -195,12 +230,14 @@ function validateSessionMcpCredentialUpdates(input: {
195
230
  return [];
196
231
  }
197
232
  requirePermission(input.grant, "mcp_servers:attach");
198
- const encryptionKey = requireEnvironmentEncryption(input.settings);
233
+ const encryptionKey = requireVariableSetEncryption(input.settings);
199
234
  const knownIds = new Set(input.session.mcpServers.map((server) => server.id));
200
235
  const seenIds = new Set<string>();
201
236
  const encryptedUpdates = input.updates.map((update) => {
202
237
  if (seenIds.has(update.id)) {
203
- throw new HTTPException(422, { message: `duplicate session MCP credential update id: ${update.id}` });
238
+ throw new HTTPException(422, {
239
+ message: `duplicate session MCP credential update id: ${update.id}`,
240
+ });
204
241
  }
205
242
  seenIds.add(update.id);
206
243
  if (!knownIds.has(update.id)) {
@@ -210,7 +247,10 @@ function validateSessionMcpCredentialUpdates(input: {
210
247
  return {
211
248
  id: update.id,
212
249
  headersEncrypted: Object.fromEntries(
213
- Object.entries(headers).map(([name, value]) => [name, encryptEnvironmentValue(encryptionKey, value)]),
250
+ Object.entries(headers).map(([name, value]) => [
251
+ name,
252
+ encryptVariableSetValue(encryptionKey, value),
253
+ ]),
214
254
  ),
215
255
  };
216
256
  });
@@ -232,7 +272,12 @@ export async function createAndStartSession(input: {
232
272
  sandboxBackend: Settings["sandboxBackend"];
233
273
  metadata: Record<string, unknown>;
234
274
  // Names/ids only; the session.created payload never carries variable values.
235
- environment?: { id: string; name: string } | null;
275
+ variableSet?: { id: string; name: string } | null;
276
+ // The rig + frozen active rig version resolved at create (M3). Both null ⇒ a
277
+ // rig-less session (byte-for-byte today's behavior). Frozen here so a later
278
+ // rig promote never moves an existing session's version.
279
+ rigId?: string | null;
280
+ rigVersionId?: string | null;
236
281
  goal?: GoalSpec | null;
237
282
  // Per-session agent persona/system instructions (org-visible metadata, not a
238
283
  // secret). Persisted on the session row and composed system-level AFTER the
@@ -251,8 +296,8 @@ export async function createAndStartSession(input: {
251
296
  parentSessionId?: string | null;
252
297
  // Workspace-scoped CREATE idempotency key. When present, a double-fire with
253
298
  // 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.
299
+ // session. Every caller repairs or re-delivers the winner's one atomic start;
300
+ // the durable initializer prevents duplicate events or turns.
256
301
  createIdempotencyKey?: string | null;
257
302
  // The shared-sandbox group this session's box joins (addendum 05 §D). Null/
258
303
  // omitted ⇒ a singleton group (the new row's own id, today's 1:1 behavior); a
@@ -281,14 +326,23 @@ export async function createAndStartSession(input: {
281
326
  // Fast path with a key: return a session already created under this key
282
327
  // (the sequential retry / double-submit case) without inserting again.
283
328
  if (input.createIdempotencyKey) {
284
- const existing = await getSessionByCreateIdempotencyKey(input.db, input.workspaceId, input.createIdempotencyKey);
329
+ const existing = await getSessionByCreateIdempotencyKey(
330
+ input.db,
331
+ input.workspaceId,
332
+ input.createIdempotencyKey,
333
+ );
285
334
  if (existing) {
286
- return existing;
335
+ return await finishStartSession(
336
+ existing.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
337
+ existing,
338
+ );
287
339
  }
288
340
  // No prior session: insert under the key, racing concurrent creates. The
289
341
  // 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.
342
+ // winner's row with created=false. Both callers may enter the idempotent
343
+ // initializer; exactly one creates the first events/turn. Each retry
344
+ // advances the coalesced wake revision so an in-flight stale delivery can
345
+ // never acknowledge work committed by the other caller.
292
346
  const { session: keyed, created } = await createSessionWithIdempotencyKey(input.db, {
293
347
  accountId: input.accountId,
294
348
  workspaceId: input.workspaceId,
@@ -298,7 +352,9 @@ export async function createAndStartSession(input: {
298
352
  metadata: sessionMetadata,
299
353
  model: input.model,
300
354
  sandboxBackend: input.sandboxBackend,
301
- environmentId: input.environment?.id ?? null,
355
+ variableSetId: input.variableSet?.id ?? null,
356
+ rigId: input.rigId ?? null,
357
+ rigVersionId: input.rigVersionId ?? null,
302
358
  firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
303
359
  instructions: input.instructions ?? null,
304
360
  parentSessionId: input.parentSessionId ?? null,
@@ -308,7 +364,10 @@ export async function createAndStartSession(input: {
308
364
  mcpServers: input.mcpServers ?? [],
309
365
  });
310
366
  if (!created) {
311
- return keyed;
367
+ return await finishStartSession(
368
+ keyed.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
369
+ keyed,
370
+ );
312
371
  }
313
372
  return await finishStartSession(input, keyed);
314
373
  }
@@ -321,7 +380,9 @@ export async function createAndStartSession(input: {
321
380
  metadata: sessionMetadata,
322
381
  model: input.model,
323
382
  sandboxBackend: input.sandboxBackend,
324
- environmentId: input.environment?.id ?? null,
383
+ variableSetId: input.variableSet?.id ?? null,
384
+ rigId: input.rigId ?? null,
385
+ rigVersionId: input.rigVersionId ?? null,
325
386
  firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
326
387
  instructions: input.instructions ?? null,
327
388
  parentSessionId: input.parentSessionId ?? null,
@@ -333,81 +394,37 @@ export async function createAndStartSession(input: {
333
394
  }
334
395
 
335
396
  /**
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.
397
+ * Complete or repair the post-insert half of {@link createAndStartSession}.
398
+ * All durable initial state is installed by one idempotent transaction; every
399
+ * caller may then advance and deliver the coalesced wake revision without
400
+ * duplicating the goal, events, or first turn.
341
401
  */
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
- }
402
+ async function finishStartSession(
403
+ input: {
404
+ db: Database;
405
+ bus: EventBus;
406
+ workflowClient: SessionWorkflowClient;
407
+ initialMessage: string;
408
+ resources: ResourceRef[];
409
+ tools: ToolRef[];
410
+ clientEventId?: string;
411
+ model: string;
412
+ reasoningEffort: Settings["openaiReasoningEffort"];
413
+ sandboxBackend: Settings["sandboxBackend"];
414
+ variableSet?: { id: string; name: string } | null;
415
+ goal?: GoalSpec | null;
416
+ sessionMcpServers?: SessionMcpServerMetadata[];
417
+ seedTargetSandbox?: {
418
+ sandboxId: string;
419
+ settings: Settings;
420
+ workingDir?: string | null;
421
+ } | null;
422
+ },
423
+ session: Session,
424
+ ): Promise<Session> {
407
425
  // 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
426
+ // the atomic initial turn transaction, so the FIRST turn routes to the chosen
427
+ // machine. swapActiveSandbox does
411
428
  // the same ownership+liveness validation as the live swap; an invalid/unowned/
412
429
  // offline target FAILS the create (422) — never a silent fall-back to the box.
413
430
  if (input.seedTargetSandbox) {
@@ -437,29 +454,40 @@ async function finishStartSession(input: {
437
454
  });
438
455
  }
439
456
  }
440
- const workflowId = workflowIdForSession(session.id);
441
- await setTemporalWorkflowId(input.db, session.workspaceId, session.id, workflowId);
442
- const turn = await enqueueSessionTurn(input.db, {
457
+ const started = await initializeSessionStartAtomically(input.db, {
443
458
  accountId: session.accountId,
444
459
  workspaceId: session.workspaceId,
445
460
  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: {},
461
+ ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
462
+ reasoningEffortFallback: input.reasoningEffort,
463
+ createdEventPayload: {
464
+ ...(input.variableSet
465
+ ? { variableSetId: input.variableSet.id, variableSetName: input.variableSet.name }
466
+ : {}),
467
+ ...(input.sessionMcpServers?.length ? { mcpServers: input.sessionMcpServers } : {}),
468
+ },
469
+ goal: input.goal
470
+ ? {
471
+ text: input.goal.text,
472
+ ...(input.goal.successCriteria !== undefined
473
+ ? { successCriteria: input.goal.successCriteria }
474
+ : {}),
475
+ ...(input.goal.maxAutoContinuations !== undefined
476
+ ? { maxAutoContinuations: input.goal.maxAutoContinuations }
477
+ : {}),
478
+ }
479
+ : null,
456
480
  });
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 });
481
+ await publishDurableSessionEvents(input.bus, session.workspaceId, session.id, started.events);
482
+ if (started.workflowWakeRevision !== null) {
483
+ await input.workflowClient.wakeSessionWorkflow({
484
+ accountId: session.accountId,
485
+ workspaceId: session.workspaceId,
486
+ sessionId: session.id,
487
+ workflowId: started.temporalWorkflowId,
488
+ wakeRevision: started.workflowWakeRevision,
489
+ });
490
+ }
463
491
  return await requireSession(input.db, session.workspaceId, session.id);
464
492
  }
465
493
 
@@ -502,18 +530,64 @@ export function assertConfiguredModel(settings: Settings, model: string | null |
502
530
  throw new HTTPException(422, { message: `model is not available: ${model}` });
503
531
  }
504
532
 
505
- export async function requireQueuedTurnForApi(db: Database, workspaceId: string, sessionId: string, turnId: string): Promise<SessionTurn> {
533
+ /**
534
+ * Reject a model the WORKSPACE's model policy blocks, at the same choke points
535
+ * as assertConfiguredModel — a 422 at the edge instead of a queued turn the
536
+ * worker's authoritative post-resolution gate would fail. `model` is the
537
+ * EFFECTIVE value the caller is about to persist: pass the explicit value at
538
+ * message/turn-update/scheduled-task edges (omitted inherits an
539
+ * already-validated stored default), but at session CREATION pass
540
+ * `payload.model ?? settings.openaiModel` — an omitted model stamps the
541
+ * deployment default onto the session, and under a restricted policy that
542
+ * default may be exactly the provider the policy exists to block.
543
+ */
544
+ export async function assertWorkspaceModelPolicyAllows(
545
+ db: Database,
546
+ settings: Settings,
547
+ workspaceId: string,
548
+ model: string | null | undefined,
549
+ ): Promise<void> {
550
+ if (model === null || model === undefined) {
551
+ return;
552
+ }
553
+ const policy = await getWorkspaceModelPolicy(db, workspaceId);
554
+ if (!policy) {
555
+ return;
556
+ }
557
+ const providerId = policyProviderIdForModel(settings, model);
558
+ const verdict = evaluateWorkspaceModelPolicy(policy, { providerId, modelId: model });
559
+ if (!verdict.allowed) {
560
+ throw new HTTPException(422, {
561
+ message:
562
+ verdict.reason === "provider"
563
+ ? `model "${model}" is not allowed by this workspace's model policy: provider "${providerId}" is not in the allowed providers`
564
+ : `model "${model}" is not allowed by this workspace's model policy`,
565
+ });
566
+ }
567
+ }
568
+
569
+ export async function requireQueuedTurnForApi(
570
+ db: Database,
571
+ workspaceId: string,
572
+ sessionId: string,
573
+ turnId: string,
574
+ ): Promise<SessionTurn> {
506
575
  const turn = await getSessionTurn(db, workspaceId, turnId);
507
576
  if (!turn || turn.sessionId !== sessionId) {
508
577
  throw new HTTPException(404, { message: "session turn not found" });
509
578
  }
510
579
  if (turn.status !== "queued") {
511
- throw new HTTPException(409, { message: `turn is ${turn.status}; only queued turns can be changed` });
580
+ throw new HTTPException(409, {
581
+ message: `turn is ${turn.status}; only queued turns can be changed`,
582
+ });
512
583
  }
513
584
  return turn;
514
585
  }
515
586
 
516
- export function reasoningEffortForSession(metadata: Record<string, unknown>, fallback: Settings["openaiReasoningEffort"]): Settings["openaiReasoningEffort"] {
587
+ export function reasoningEffortForSession(
588
+ metadata: Record<string, unknown>,
589
+ fallback: Settings["openaiReasoningEffort"],
590
+ ): Settings["openaiReasoningEffort"] {
517
591
  return reasoningEffortForMetadata(metadata, fallback);
518
592
  }
519
593
 
@@ -527,7 +601,7 @@ export function reasoningEffortForSession(metadata: Record<string, unknown>, fal
527
601
  export async function postUserMessageTurn(input: {
528
602
  db: Database;
529
603
  bus: EventBus;
530
- workflowClient: SessionWorkflowClient;
604
+ workflowClient: Pick<SessionWorkflowClient, "wakeSessionWorkflow">;
531
605
  settings: Settings;
532
606
  accountId: string;
533
607
  workspaceId: string;
@@ -539,6 +613,11 @@ export async function postUserMessageTurn(input: {
539
613
  reasoningEffort?: Settings["openaiReasoningEffort"] | null;
540
614
  clientEventId?: string;
541
615
  mcpCredentialUpdates?: UpdateSessionMcpServerCredentialsInput[];
616
+ delivery?: "send" | "steer";
617
+ origin?: "human" | "operator";
618
+ actor?: string;
619
+ controlEtag?: string | null;
620
+ expectedDraftRevision?: number | null;
542
621
  }): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
543
622
  const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
544
623
  const requestedModel = input.model ?? null;
@@ -546,85 +625,98 @@ export async function postUserMessageTurn(input: {
546
625
  // Reject an explicit per-message model the host does not expose; an omitted
547
626
  // model inherits the session's model downstream (always a configured id).
548
627
  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` });
628
+ await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, requestedModel);
629
+ const operationKey = input.clientEventId ?? crypto.randomUUID();
630
+ let result;
631
+ try {
632
+ result = await withWorkspaceSubjectRls(db, workspaceId, input.actor ?? accountId, (scoped) =>
633
+ scoped.transaction((tx) =>
634
+ submitHumanPromptInTransaction(tx as unknown as Database, {
635
+ accountId,
636
+ workspaceId,
637
+ sessionId,
638
+ subjectId: input.actor ?? accountId,
639
+ actor: { type: "human", subjectId: input.actor ?? accountId },
640
+ operationKey,
641
+ delivery: input.delivery ?? "send",
642
+ controlEtag: input.controlEtag ?? null,
643
+ expectedDraftRevision: input.expectedDraftRevision ?? null,
644
+ text: input.text,
645
+ resources: input.resources,
646
+ tools: input.tools,
647
+ model: requestedModel,
648
+ reasoningEffort: requestedReasoningEffort,
649
+ reasoningEffortFallback: settings.openaiReasoningEffort,
650
+ source: input.origin === "operator" ? "api" : "user",
651
+ mcpCredentialUpdates: input.mcpCredentialUpdates ?? [],
652
+ }),
653
+ ),
654
+ );
655
+ } catch (error) {
656
+ if (
657
+ error instanceof QueueCommandConflictError ||
658
+ error instanceof SessionControlConflictError
659
+ ) {
660
+ throw new HTTPException(409, { message: error.message });
559
661
  }
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]}` });
662
+ if (error instanceof Error && error.message.includes("cancelled")) {
663
+ throw new HTTPException(409, { message: error.message });
565
664
  }
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,
665
+ if (error instanceof Error && error.message.startsWith("Unknown session MCP server")) {
666
+ throw new HTTPException(422, { message: error.message });
667
+ }
668
+ throw error;
669
+ }
670
+ const events = await Promise.all(
671
+ result.eventIds.map((eventId) => getSessionEvent(db, workspaceId, eventId)),
672
+ );
673
+ if (events.some((event) => event === null)) {
674
+ throw new Error("Committed prompt events could not be reloaded");
675
+ }
676
+ const turn = await getSessionTurn(db, workspaceId, result.turnId);
677
+ if (!turn) throw new Error("Committed prompt turn could not be reloaded");
678
+ const accepted = events.find((event) => event?.id === result.acceptedEventId);
679
+ if (!accepted) throw new Error("Committed user.message event could not be reloaded");
680
+ await publishDurableSessionEvents(
681
+ bus,
603
682
  workspaceId,
604
683
  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 });
684
+ events.filter((event): event is SessionEvent => event !== null),
685
+ );
686
+ if (result.workspaceControlEventId) {
687
+ const controlEvent = await getWorkspaceControlEvent(
688
+ db,
689
+ workspaceId,
690
+ result.workspaceControlEventId,
691
+ );
692
+ if (!controlEvent) {
693
+ throw new Error(
694
+ `Committed workspace control event disappeared: ${result.workspaceControlEventId}`,
695
+ );
696
+ }
697
+ await publishDurableWorkspaceControlEvent(bus, workspaceId, controlEvent);
698
+ }
699
+ try {
700
+ await workflowClient.wakeSessionWorkflow({
701
+ accountId,
702
+ workspaceId,
703
+ sessionId,
704
+ workflowId: turn.temporalWorkflowId,
705
+ wakeRevision: result.wakeRevision,
706
+ ...(result.interruptionCount > 0 ? { interruptionRequested: true } : {}),
707
+ });
708
+ } catch (error) {
709
+ console.warn(
710
+ `[sessions] workflow wake failed for committed prompt ${workspaceId}/${sessionId}; durable outbox will retry`,
711
+ error,
712
+ );
713
+ }
622
714
  return { accepted, turn };
623
715
  }
624
716
 
625
717
  /**
626
718
  * Full create-session flow shared by `POST /sessions` and the first-party MCP
627
- * `session_create` tool: payload validation, resource/tool/environment
719
+ * `session_create` tool: payload validation, resource/tool/variableSet
628
720
  * checks, usage limits, session start, and usage recording. `rawPayload` is
629
721
  * the unparsed request body so absent-vs-empty `tools` keeps its meaning
630
722
  * (absent applies the workspace's default capability MCP tools).
@@ -637,9 +729,20 @@ export async function createSessionForRequest(
637
729
  ): Promise<Session> {
638
730
  const { settings, db, bus, workflowClient, objectStorage } = deps;
639
731
  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);
732
+ const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
733
+ db,
734
+ workspaceId,
735
+ settings,
736
+ );
737
+ const sessionMcpServers = validateSessionMcpServersForCreate(
738
+ capabilityRuntimeSettings,
739
+ grant,
740
+ payload.mcpServers,
741
+ );
742
+ const runtimeSettings = settingsWithSessionMcpServerConfigs(
743
+ capabilityRuntimeSettings,
744
+ sessionMcpServers.runtimeServers,
745
+ );
643
746
  const resources = normalizeResources(payload.resources);
644
747
  const requestedTools = validateToolRefs(payload.tools, runtimeSettings);
645
748
  const defaultedTools = hasOwnProperty(rawPayload, "tools")
@@ -647,7 +750,7 @@ export async function createSessionForRequest(
647
750
  : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, capabilityRuntimeSettings);
648
751
  // The first-party MCP server is attached to EVERY session. It hosts the
649
752
  // session's own metadata tool (set_session_title) + goal tools, and — only
650
- // when the grant carries the permission — the orchestration/environment/
753
+ // when the grant carries the permission — the orchestration/variableSet/
651
754
  // github tools. Capability is gated per-tool by permission, never by whether
652
755
  // the server is attached, so a bare chat still gets titling while the
653
756
  // dangerous tools stay off by default.
@@ -657,13 +760,57 @@ export async function createSessionForRequest(
657
760
  throw new HTTPException(503, { message: "object storage is not configured" });
658
761
  }
659
762
  await validateFileResources(db, workspaceId, resources);
660
- // Environment attachment requires environments:use on the calling grant
661
- // (validateEnvironmentAttachment enforces it), preserving the invariant
763
+ // VariableSet attachment requires variable-sets:use on the calling grant
764
+ // (validateVariableSetAttachment enforces it), preserving the invariant
662
765
  // that sandboxed agents cannot self-attach workspace secrets.
663
- const environment = payload.environmentId
664
- ? await validateEnvironmentAttachment({ settings, db }, grant, workspaceId, payload.environmentId)
766
+ const variableSet = payload.variableSetId
767
+ ? await validateVariableSetAttachment(
768
+ { settings, db },
769
+ grant,
770
+ workspaceId,
771
+ payload.variableSetId,
772
+ )
665
773
  : null;
774
+ // RIG BINDING (M3). Resolve the rig this session rides — the EXPLICIT payload
775
+ // rigId when given, else the workspace default rig (workspaces.default_rig_id)
776
+ // — and FREEZE both the rig id and its currently-ACTIVE version onto the row.
777
+ // The session then rides that exact version for its whole life; a later
778
+ // promote never moves it. Rig-less (both null) when neither resolves, which is
779
+ // byte-for-byte today's behavior (zero extra work, zero row change).
780
+ // - An EXPLICIT unknown/inactive rigId is a caller error → 422.
781
+ // - A stale workspace-default rig (deleted → FK-nulled, or somehow with no
782
+ // active version) degrades SILENTLY to rig-less: an operator-side default
783
+ // must never brick every create in the workspace.
784
+ const requestedRigId = payload.rigId ?? (await getWorkspaceDefaultRigId(db, workspaceId));
785
+ let frozenRigId: string | null = null;
786
+ let frozenRigVersionId: string | null = null;
787
+ if (requestedRigId) {
788
+ const rig = await getRig(db, workspaceId, requestedRigId);
789
+ if (!rig || !rig.activeVersion) {
790
+ if (payload.rigId) {
791
+ throw new HTTPException(422, {
792
+ message: rig
793
+ ? `rig ${payload.rigId} has no active version to bind`
794
+ : `unknown rigId: ${payload.rigId}`,
795
+ });
796
+ }
797
+ // else: workspace-default fallback that no longer resolves → rig-less.
798
+ } else {
799
+ frozenRigId = rig.id;
800
+ frozenRigVersionId = rig.activeVersion.id;
801
+ }
802
+ }
666
803
  assertConfiguredModel(settings, payload.model);
804
+ // Session creation persists the EFFECTIVE model — an omitted payload.model
805
+ // stamps the deployment default onto the session — so the policy must vet
806
+ // that effective value, not just explicit ones (a restricted workspace's
807
+ // default-model session would otherwise be born blocked).
808
+ await assertWorkspaceModelPolicyAllows(
809
+ db,
810
+ settings,
811
+ workspaceId,
812
+ payload.model ?? settings.openaiModel,
813
+ );
667
814
  const model = payload.model ?? settings.openaiModel;
668
815
  const reasoningEffort = payload.reasoningEffort ?? settings.openaiReasoningEffort;
669
816
  // A session's first-party MCP token can carry a non-default permission set
@@ -674,11 +821,16 @@ export async function createSessionForRequest(
674
821
  if (firstPartyMcpPermissions && firstPartyMcpPermissions.length === 0) {
675
822
  // An empty set would sign an unusable zero-permission token; the default
676
823
  // 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" });
824
+ throw new HTTPException(422, {
825
+ message:
826
+ "firstPartyMcpPermissions must not be empty; omit it for the default worker permission set",
827
+ });
678
828
  }
679
829
  for (const permission of firstPartyMcpPermissions ?? []) {
680
830
  if (!hasPermission(grant.permissions, permission)) {
681
- throw new HTTPException(403, { message: `cannot grant first-party MCP permission beyond the creating grant: ${permission}` });
831
+ throw new HTTPException(403, {
832
+ message: `cannot grant first-party MCP permission beyond the creating grant: ${permission}`,
833
+ });
682
834
  }
683
835
  }
684
836
  // Invariant: a goal-bearing session always carries goals:manage in its
@@ -690,7 +842,11 @@ export async function createSessionForRequest(
690
842
  // spawned session itself via the worker-signed sessionId claim, so a
691
843
  // worker managing its OWN goal is not an escalation of the spawner's
692
844
  // authority.
693
- if (payload.goal && firstPartyMcpPermissions && !firstPartyMcpPermissions.includes("goals:manage")) {
845
+ if (
846
+ payload.goal &&
847
+ firstPartyMcpPermissions &&
848
+ !firstPartyMcpPermissions.includes("goals:manage")
849
+ ) {
694
850
  firstPartyMcpPermissions = [...firstPartyMcpPermissions, "goals:manage"];
695
851
  }
696
852
  // Parent linkage: a worker is linked to its manager ONLY from the
@@ -704,7 +860,10 @@ export async function createSessionForRequest(
704
860
  // its completion wake injects a user.message + queued turn into that session
705
861
  // without holding sessions:control on it (a cross-session write escalation).
706
862
  // The claim is the only trustworthy parent source.
707
- const parentSessionId = typeof grant.metadata?.["sessionId"] === "string" ? grant.metadata["sessionId"] as string : null;
863
+ const parentSessionId =
864
+ typeof grant.metadata?.["sessionId"] === "string"
865
+ ? (grant.metadata["sessionId"] as string)
866
+ : null;
708
867
  // Shared-sandbox placement (addendum 05 §D.2/§D.3, decision I10/OD-S1).
709
868
  //
710
869
  // The DEFAULT rule is context-dependent and resolved server-side from the
@@ -724,39 +883,75 @@ export async function createSessionForRequest(
724
883
  const sandboxChoice = payload.sandbox ?? (parentSessionId ? "shared" : "new");
725
884
  let sandboxGroupId: string | null = null;
726
885
  let inheritedBackend: Session["sandboxBackend"] | undefined;
727
- // ENV-AWARE GROUPING: under the CURRENT mechanics the workspace Environment is
886
+ // ENV-AWARE GROUPING: under the CURRENT mechanics the workspace VariableSet is
728
887
  // creation-time box state — the box's manifest env is fixed when it is cold-
729
888
  // 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
889
+ // at attach. A session carrying a DIFFERENT VariableSet than the box it joins
731
890
  // 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
891
+ // box dies with "Live sandbox sessions cannot change manifest variableSet
892
+ // variables" (proven live, sessions 5aee77e9 + 63d18823). Until the VariableSet
734
893
  // is evicted from the manifest (per-exec, like the git token), grouping must be
735
894
  // env-aware: the INHERITED default falls back to an own box on mismatch (a
736
895
  // credentialed worker spawned from a credential-less manager just works), and
737
- // an EXPLICIT shared/{groupId} request with a mismatched Environment fails
896
+ // an EXPLICIT shared/{groupId} request with a mismatched VariableSet fails
738
897
  // fast at create (422) instead of poisoning the session's first turn.
739
898
  // The env conflict is a BOX property, so a boxless group is exempt: a
740
899
  // backend:"none" session runs in-process with no sandbox, no manifest, and no
741
900
  // provided-session attach — no shared box state exists to conflict, and
742
901
  // env-differing spawns from such parents shared safely before the env-aware
743
902
  // check. They keep sharing (and keep inheriting "none").
744
- const requestedEnvironmentId = payload.environmentId ?? null;
745
- const environmentMatchesGroup = (memberEnvironmentId: string | null): boolean =>
746
- memberEnvironmentId === requestedEnvironmentId;
903
+ const requestedVariableSetId = payload.variableSetId ?? null;
904
+ const variableSetMatchesGroup = (memberVariableSetId: string | null): boolean =>
905
+ memberVariableSetId === requestedVariableSetId;
906
+ // RIG-AWARE GROUPING (M3), the exact sibling of the env-aware gate above: the
907
+ // box's rig-baked setup/tooling is fixed at cold-create, so a session joining a
908
+ // shared box must ride the SAME frozen rig_version_id. A mismatch is a genuine
909
+ // shared-state conflict (the box was set up for a different rig) — the INHERITED
910
+ // default falls back to an own box, an EXPLICIT shared/{groupId} request 422s at
911
+ // create rather than poisoning the first turn on the lease's rig-conflict guard.
912
+ // null on either side = compatible (a rig-less session shares with a rig-less
913
+ // box exactly as today); the boxless backend:'none' exemption is shared with the
914
+ // env gate (no box state to conflict).
915
+ const rigVersionMatchesGroup = (memberRigVersionId: string | null): boolean =>
916
+ memberRigVersionId === frozenRigVersionId;
747
917
  if (sandboxChoice === "shared") {
748
918
  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." });
919
+ throw new HTTPException(422, {
920
+ message:
921
+ "sandbox:'shared' requires a parent session (spawn from inside a session); use 'new' for a top-level create.",
922
+ });
750
923
  }
751
924
  const parent = await getSession(db, workspaceId, parentSessionId);
752
925
  if (!parent) {
753
- throw new HTTPException(404, { message: `parent session not found in workspace: ${parentSessionId}` });
926
+ throw new HTTPException(404, {
927
+ message: `parent session not found in workspace: ${parentSessionId}`,
928
+ });
754
929
  }
755
- if (parent.sandboxBackend !== "none" && !environmentMatchesGroup(parent.environmentId ?? null)) {
930
+ const parentBoxed = parent.sandboxBackend !== "none";
931
+ const variableSetMismatch =
932
+ parentBoxed && !variableSetMatchesGroup(parent.variableSetId ?? null);
933
+ let rigMismatch = parentBoxed && !rigVersionMatchesGroup(parent.rigVersionId ?? null);
934
+ if (parentBoxed && !rigMismatch) {
935
+ const memberRigVersionIds = await listDistinctRigVersionIdsInGroup(
936
+ db,
937
+ workspaceId,
938
+ parent.sandboxGroupId,
939
+ );
940
+ rigMismatch = !memberRigVersionIds.every((memberRigVersionId) =>
941
+ rigVersionMatchesGroup(memberRigVersionId),
942
+ );
943
+ }
944
+ if (variableSetMismatch || rigMismatch) {
756
945
  if (payload.sandbox === "shared") {
757
946
  // 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." });
947
+ // VariableSet / rig — surface the conflict at create time, not turn time.
948
+ // VariableSet is checked first so its (pre-rig) message is unchanged for
949
+ // the env-only mismatch the existing gate already covered.
950
+ throw new HTTPException(422, {
951
+ message: variableSetMismatch
952
+ ? "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."
953
+ : "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.",
954
+ });
760
955
  }
761
956
  // Inherited default: deterministic separation on the genuine shared-state
762
957
  // conflict — the worker gets its own box (resolved like a top-level
@@ -769,17 +964,46 @@ export async function createSessionForRequest(
769
964
  } else if (typeof sandboxChoice === "object") {
770
965
  const member = await getAnySessionInGroup(db, workspaceId, sandboxChoice.groupId);
771
966
  if (!member) {
772
- throw new HTTPException(404, { message: `sandbox group not found in workspace: ${sandboxChoice.groupId}` });
967
+ throw new HTTPException(404, {
968
+ message: `sandbox group not found in workspace: ${sandboxChoice.groupId}`,
969
+ });
773
970
  }
774
971
  if (member.sandboxBackend !== "none") {
775
972
  // 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
973
+ // group can carry mixed variableSetIds, and an any-member read would make
777
974
  // the join verdict nondeterministic. Post-env-aware groups are homogeneous
778
975
  // (both join paths enforce equality), so this reads one distinct value in
779
976
  // 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.` });
977
+ const memberVariableSetIds = await listDistinctVariableSetIdsInGroup(
978
+ db,
979
+ workspaceId,
980
+ sandboxChoice.groupId,
981
+ );
982
+ if (
983
+ !memberVariableSetIds.every((memberVariableSetId) =>
984
+ variableSetMatchesGroup(memberVariableSetId),
985
+ )
986
+ ) {
987
+ throw new HTTPException(422, {
988
+ 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.`,
989
+ });
990
+ }
991
+ // Same deterministic all-members check for the frozen rig version (M3): the
992
+ // box's rig setup is fixed at creation, so every member must ride the rig
993
+ // this create resolved (or the group is rig-less and so is this create).
994
+ const memberRigVersionIds = await listDistinctRigVersionIdsInGroup(
995
+ db,
996
+ workspaceId,
997
+ sandboxChoice.groupId,
998
+ );
999
+ if (
1000
+ !memberRigVersionIds.every((memberRigVersionId) =>
1001
+ rigVersionMatchesGroup(memberRigVersionId),
1002
+ )
1003
+ ) {
1004
+ throw new HTTPException(422, {
1005
+ 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.`,
1006
+ });
783
1007
  }
784
1008
  }
785
1009
  sandboxGroupId = sandboxChoice.groupId;
@@ -791,7 +1015,10 @@ export async function createSessionForRequest(
791
1015
  // — reject it at the edge (mirrors the backend:'none' guard) rather than silently
792
1016
  // dropping it, since the default group box has no working-dir seam yet.
793
1017
  if (payload.workingDir !== undefined && !payload.targetSandboxId) {
794
- throw new HTTPException(422, { message: "workingDir requires targetSandboxId (it is the targeted machine's working directory)" });
1018
+ throw new HTTPException(422, {
1019
+ message:
1020
+ "workingDir requires targetSandboxId (it is the targeted machine's working directory)",
1021
+ });
795
1022
  }
796
1023
  // Honest-label (Stage-D closure): a top-level session TARGETED at a Connected
797
1024
  // Machine (a selfhosted sandbox) runs machine-primary every turn, so its HOME
@@ -821,23 +1048,32 @@ export async function createSessionForRequest(
821
1048
  let machineHomeBackend: Session["sandboxBackend"] | undefined;
822
1049
  let machineHomeOs: Session["sandboxOs"] | undefined;
823
1050
  if (
824
- payload.targetSandboxId
825
- && inheritedBackend === undefined
826
- && settings.sandboxOwnershipEnabled
827
- && settings.sandboxSelfhostedEnabled
1051
+ payload.targetSandboxId &&
1052
+ inheritedBackend === undefined &&
1053
+ settings.sandboxOwnershipEnabled &&
1054
+ settings.sandboxSelfhostedEnabled
828
1055
  ) {
829
1056
  const targetSandbox = await getSandbox(db, workspaceId, payload.targetSandboxId);
830
1057
  if (targetSandbox?.kind === "selfhosted") {
831
1058
  machineHomeBackend = "selfhosted";
832
1059
  if (targetSandbox.enrollmentId) {
833
1060
  const enrollment = await getEnrollment(db, workspaceId, targetSandbox.enrollmentId);
834
- if (enrollment && (enrollment.os === "macos" || enrollment.os === "windows" || enrollment.os === "linux")) {
1061
+ if (
1062
+ enrollment &&
1063
+ (enrollment.os === "macos" || enrollment.os === "windows" || enrollment.os === "linux")
1064
+ ) {
835
1065
  machineHomeOs = enrollment.os;
836
1066
  }
837
1067
  }
838
1068
  }
839
1069
  }
840
- await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "agent_run:create", quantity: 1, model });
1070
+ await requireLimit(deps, {
1071
+ accountId: grant.accountId,
1072
+ workspaceId,
1073
+ action: "agent_run:create",
1074
+ quantity: 1,
1075
+ model,
1076
+ });
841
1077
  const session = await createAndStartSession({
842
1078
  db,
843
1079
  bus,
@@ -855,14 +1091,18 @@ export async function createSessionForRequest(
855
1091
  // machine-targeted top-level create labels the home "selfhosted"
856
1092
  // (machineHomeBackend), overriding the caller/deployment default so the row
857
1093
  // matches where the session actually runs.
858
- sandboxBackend: inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
1094
+ sandboxBackend:
1095
+ inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
859
1096
  // Mirror the backend relabel on the OS axis: only a machine-targeted
860
1097
  // top-level create carries a derived OS; everything else is omitted and the
861
1098
  // "linux" default holds (shared spawns keep the parent-box behavior).
862
1099
  ...(machineHomeOs ? { sandboxOs: machineHomeOs } : {}),
863
1100
  sandboxGroupId,
864
1101
  metadata: payload.metadata,
865
- environment: environment ? { id: environment.id, name: environment.name } : null,
1102
+ variableSet: variableSet ? { id: variableSet.id, name: variableSet.name } : null,
1103
+ // Frozen rig binding (M3): both null for a rig-less session (today's path).
1104
+ rigId: frozenRigId,
1105
+ rigVersionId: frozenRigVersionId,
866
1106
  goal: payload.goal ?? null,
867
1107
  // Per-session persona instructions (already trimmed/validated by the
868
1108
  // contracts schema). Persisted on the row; composed system-level at turn
@@ -903,7 +1143,7 @@ export async function createSessionForRequest(
903
1143
  * workspace's default capability MCP tools, matching an absent `tools` key.
904
1144
  */
905
1145
  export async function acceptSessionUserMessage(
906
- deps: ApiRouteDeps,
1146
+ deps: AcceptSessionUserMessageDependencies,
907
1147
  grant: AccessGrant,
908
1148
  workspaceId: string,
909
1149
  sessionId: string,
@@ -916,15 +1156,26 @@ export async function acceptSessionUserMessage(
916
1156
  reasoningEffort?: ReasoningEffort | null;
917
1157
  clientEventId?: string;
918
1158
  mcpCredentialUpdates?: SessionMcpCredentialUpdateInput[];
1159
+ delivery?: "send" | "steer";
1160
+ origin?: "human" | "operator";
1161
+ controlEtag?: string | null;
1162
+ expectedDraftRevision?: number | null;
919
1163
  },
920
1164
  ): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
921
1165
  const { settings, db, bus, workflowClient, objectStorage } = deps;
922
- const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
1166
+ const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
1167
+ db,
1168
+ workspaceId,
1169
+ settings,
1170
+ );
923
1171
  // Hoisted above requireLimit so the codex-billed predicate can resolve the
924
1172
  // turn's effective model (a follow-up turn inherits the session's model). A
925
1173
  // pure read with no side effects.
926
1174
  const existingSession = await requireSession(db, workspaceId, sessionId);
927
- const runtimeSettings = settingsWithSessionMcpServerMetadata(capabilityRuntimeSettings, existingSession.mcpServers);
1175
+ const runtimeSettings = settingsWithSessionMcpServerMetadata(
1176
+ capabilityRuntimeSettings,
1177
+ existingSession.mcpServers,
1178
+ );
928
1179
  const requestedResources = normalizeResources(input.resources ?? []);
929
1180
  const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
930
1181
  const requestedTools = input.toolsProvided
@@ -941,7 +1192,10 @@ export async function acceptSessionUserMessage(
941
1192
  throw new HTTPException(503, { message: "object storage is not configured" });
942
1193
  }
943
1194
  await validateFileResources(db, workspaceId, requestedResources);
944
- await validateGitHubRepositorySelection(db, workspaceId, [...existingSession.resources, ...requestedResources]);
1195
+ await validateGitHubRepositorySelection(db, workspaceId, [
1196
+ ...existingSession.resources,
1197
+ ...requestedResources,
1198
+ ]);
945
1199
  const mcpCredentialUpdates = validateSessionMcpCredentialUpdates({
946
1200
  settings,
947
1201
  grant,
@@ -962,6 +1216,13 @@ export async function acceptSessionUserMessage(
962
1216
  model: input.model ?? null,
963
1217
  reasoningEffort: input.reasoningEffort ?? null,
964
1218
  mcpCredentialUpdates,
1219
+ delivery: input.delivery ?? "send",
1220
+ origin: input.origin ?? "human",
1221
+ actor: grant.subjectId,
1222
+ ...(input.controlEtag !== undefined ? { controlEtag: input.controlEtag } : {}),
1223
+ ...(input.expectedDraftRevision !== undefined
1224
+ ? { expectedDraftRevision: input.expectedDraftRevision }
1225
+ : {}),
965
1226
  ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
966
1227
  });
967
1228
  await recordWorkspaceUsage(deps, {
@@ -997,18 +1258,31 @@ export async function updateSessionTitle(
997
1258
  const { db, bus } = deps;
998
1259
  const result = await updateSessionTitleRow(db, { workspaceId, sessionId, title, source });
999
1260
  if (result.updated) {
1000
- await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
1001
- type: "session.title_set",
1002
- payload: {
1003
- title: result.title ?? title,
1004
- source,
1261
+ await appendAndPublishEvents(db, bus, workspaceId, sessionId, [
1262
+ {
1263
+ type: "session.title_set",
1264
+ payload: {
1265
+ title: result.title ?? title,
1266
+ source,
1267
+ },
1005
1268
  },
1006
- }]);
1269
+ ]);
1007
1270
  }
1008
1271
  return result;
1009
1272
  }
1010
1273
 
1011
- function withFirstPartyTools(tools: ToolRef[], runtimeSettings: { mcpServers: Array<{ id: string }> }): ToolRef[] {
1274
+ export async function readSessionLineage(db: Database, workspaceId: string, sessionId: string) {
1275
+ const lineage = await getSessionLineage(db, workspaceId, sessionId);
1276
+ if (!lineage) {
1277
+ throw new HTTPException(404, { message: "session not found" });
1278
+ }
1279
+ return lineage;
1280
+ }
1281
+
1282
+ function withFirstPartyTools(
1283
+ tools: ToolRef[],
1284
+ runtimeSettings: { mcpServers: Array<{ id: string }> },
1285
+ ): ToolRef[] {
1012
1286
  if (!runtimeSettings.mcpServers.some((server) => server.id === "opengeni")) {
1013
1287
  return tools;
1014
1288
  }
@@ -1016,5 +1290,7 @@ function withFirstPartyTools(tools: ToolRef[], runtimeSettings: { mcpServers: Ar
1016
1290
  }
1017
1291
 
1018
1292
  function hasOwnProperty(value: unknown, key: string): boolean {
1019
- return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
1293
+ return Boolean(
1294
+ value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key),
1295
+ );
1020
1296
  }