@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.
- package/dist/index.d.ts +293 -32
- package/dist/index.js +1750 -472
- package/dist/index.js.map +1 -1
- package/package.json +17 -17
- package/src/access/index.ts +136 -57
- package/src/application/session-commands.ts +527 -0
- package/src/billing/limits.ts +76 -34
- package/src/dependencies.ts +67 -14
- package/src/domain/capabilities.ts +380 -181
- package/src/domain/environments.ts +58 -38
- package/src/domain/packs.ts +49 -16
- package/src/domain/resources.ts +71 -28
- package/src/domain/scheduled-tasks.ts +107 -41
- package/src/domain/sessions.ts +531 -255
- package/src/domain/workspace-members.ts +6 -2
- package/src/index.ts +3 -0
- package/src/rigs/index.ts +540 -0
- package/src/sandbox/fleet.ts +97 -18
- package/src/sandbox/routing.ts +6 -1
- package/src/sandbox-types.ts +17 -5
- package/src/workflow-wake-contract.ts +6 -0
package/src/domain/sessions.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
encryptEnvironmentValue,
|
|
23
|
+
encryptVariableSetValue,
|
|
26
24
|
getAnySessionInGroup,
|
|
27
25
|
getEnrollment,
|
|
28
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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(
|
|
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)
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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]) => [
|
|
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 =
|
|
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, {
|
|
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]) => [
|
|
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
|
-
|
|
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
|
|
255
|
-
//
|
|
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(
|
|
329
|
+
const existing = await getSessionByCreateIdempotencyKey(
|
|
330
|
+
input.db,
|
|
331
|
+
input.workspaceId,
|
|
332
|
+
input.createIdempotencyKey,
|
|
333
|
+
);
|
|
285
334
|
if (existing) {
|
|
286
|
-
return
|
|
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
|
|
291
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
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(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
409
|
-
//
|
|
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
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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, {
|
|
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(
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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/
|
|
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(
|
|
641
|
-
|
|
642
|
-
|
|
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/
|
|
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
|
-
//
|
|
661
|
-
// (
|
|
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
|
|
664
|
-
? await
|
|
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, {
|
|
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, {
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
733
|
-
// variables" (proven live, sessions 5aee77e9 + 63d18823). Until the
|
|
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
|
|
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
|
|
745
|
-
const
|
|
746
|
-
|
|
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, {
|
|
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, {
|
|
926
|
+
throw new HTTPException(404, {
|
|
927
|
+
message: `parent session not found in workspace: ${parentSessionId}`,
|
|
928
|
+
});
|
|
754
929
|
}
|
|
755
|
-
|
|
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
|
-
//
|
|
759
|
-
|
|
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, {
|
|
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
|
|
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
|
|
781
|
-
|
|
782
|
-
|
|
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, {
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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 (
|
|
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, {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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, [
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
|
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(
|
|
1293
|
+
return Boolean(
|
|
1294
|
+
value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key),
|
|
1295
|
+
);
|
|
1020
1296
|
}
|