@opengeni/core 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/index.d.ts +735 -0
- package/dist/index.js +2627 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/access/index.ts +186 -0
- package/src/billing/limits.ts +207 -0
- package/src/dependencies.ts +70 -0
- package/src/domain/capabilities.ts +959 -0
- package/src/domain/environments.ts +115 -0
- package/src/domain/packs.ts +241 -0
- package/src/domain/resources.ts +221 -0
- package/src/domain/scheduled-tasks.ts +321 -0
- package/src/domain/sessions.ts +812 -0
- package/src/domain/workspace-members.ts +80 -0
- package/src/index.ts +59 -0
- package/src/managed-auth-type.ts +20 -0
- package/src/sandbox/fleet.ts +460 -0
- package/src/sandbox/routing.ts +127 -0
- package/src/sandbox-types.ts +61 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import { CODEX_MODEL_ID_PREFIX } from "@opengeni/codex";
|
|
2
|
+
import { configuredAllowedModels, type Settings } from "@opengeni/config";
|
|
3
|
+
import {
|
|
4
|
+
CreateSessionRequest,
|
|
5
|
+
reasoningEffortForMetadata,
|
|
6
|
+
type AccessGrant,
|
|
7
|
+
type GoalSpec,
|
|
8
|
+
type Permission,
|
|
9
|
+
type ReasoningEffort,
|
|
10
|
+
type ResourceRef,
|
|
11
|
+
type Session,
|
|
12
|
+
type SessionEvent,
|
|
13
|
+
type SessionTurn,
|
|
14
|
+
type ToolRef,
|
|
15
|
+
} from "@opengeni/contracts";
|
|
16
|
+
import {
|
|
17
|
+
appendSessionEventsWithLockedSessionUpdate,
|
|
18
|
+
createSession,
|
|
19
|
+
createSessionGoal,
|
|
20
|
+
createSessionWithIdempotencyKey,
|
|
21
|
+
enqueueSessionTurn,
|
|
22
|
+
getAnySessionInGroup,
|
|
23
|
+
getEnrollment,
|
|
24
|
+
listDistinctEnvironmentIdsInGroup,
|
|
25
|
+
getSandbox,
|
|
26
|
+
getSession,
|
|
27
|
+
getSessionByCreateIdempotencyKey,
|
|
28
|
+
getSessionTurn,
|
|
29
|
+
requireSession,
|
|
30
|
+
setTemporalWorkflowId,
|
|
31
|
+
updateSessionTitle as updateSessionTitleRow,
|
|
32
|
+
type Database,
|
|
33
|
+
} from "@opengeni/db";
|
|
34
|
+
import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
|
|
35
|
+
import { HTTPException } from "hono/http-exception";
|
|
36
|
+
import { hasPermission } from "../access";
|
|
37
|
+
import { recordWorkspaceUsage, requireLimit } from "../billing/limits";
|
|
38
|
+
import type { ApiRouteDeps, SessionWorkflowClient } from "../dependencies";
|
|
39
|
+
import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
|
|
40
|
+
import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
|
|
41
|
+
import { validateEnvironmentAttachment } from "./environments";
|
|
42
|
+
import {
|
|
43
|
+
mergeResourceRefs,
|
|
44
|
+
mergeToolRefs,
|
|
45
|
+
normalizeResources,
|
|
46
|
+
validateFileResources,
|
|
47
|
+
validateGitHubRepositorySelection,
|
|
48
|
+
validateToolRefs,
|
|
49
|
+
withDefaultEnabledCapabilityMcpTools,
|
|
50
|
+
} from "./resources";
|
|
51
|
+
|
|
52
|
+
export async function createAndStartSession(input: {
|
|
53
|
+
db: Database;
|
|
54
|
+
bus: EventBus;
|
|
55
|
+
workflowClient: SessionWorkflowClient;
|
|
56
|
+
accountId: string;
|
|
57
|
+
workspaceId: string;
|
|
58
|
+
initialMessage: string;
|
|
59
|
+
resources: ResourceRef[];
|
|
60
|
+
tools: ToolRef[];
|
|
61
|
+
clientEventId?: string;
|
|
62
|
+
model: string;
|
|
63
|
+
reasoningEffort: Settings["openaiReasoningEffort"];
|
|
64
|
+
sandboxBackend: Settings["sandboxBackend"];
|
|
65
|
+
metadata: Record<string, unknown>;
|
|
66
|
+
// Names/ids only; the session.created payload never carries variable values.
|
|
67
|
+
environment?: { id: string; name: string } | null;
|
|
68
|
+
goal?: GoalSpec | null;
|
|
69
|
+
// Validated against the creating grant before this is called.
|
|
70
|
+
firstPartyMcpPermissions?: Permission[] | null;
|
|
71
|
+
// The manager session spawning this worker (a worker-signed sessionId claim
|
|
72
|
+
// on the creating grant); null for direct API creates and scheduled runs.
|
|
73
|
+
// When set, the worker's terminal-for-now transitions wake this parent.
|
|
74
|
+
parentSessionId?: string | null;
|
|
75
|
+
// Workspace-scoped CREATE idempotency key. When present, a double-fire with
|
|
76
|
+
// the same key (sequential retry OR concurrent race) collapses to a single
|
|
77
|
+
// session: a prior winner is returned as-is and the start flow below is
|
|
78
|
+
// skipped, so the dup never re-emits events / re-enqueues a turn.
|
|
79
|
+
createIdempotencyKey?: string | null;
|
|
80
|
+
// The shared-sandbox group this session's box joins (addendum 05 §D). Null/
|
|
81
|
+
// omitted ⇒ a singleton group (the new row's own id, today's 1:1 behavior); a
|
|
82
|
+
// shared/{groupId} spawn passes the resolved group so both run in ONE box.
|
|
83
|
+
sandboxGroupId?: string | null;
|
|
84
|
+
// The OS axis of the session's box (sessions.sandbox_os). Omitted ⇒ the
|
|
85
|
+
// "linux" default; set only for a machine-targeted top-level create, where the
|
|
86
|
+
// targeted machine's enrollment OS is threaded in so the row + resume path +
|
|
87
|
+
// OS-labeling surfaces honestly reflect the machine.
|
|
88
|
+
sandboxOs?: Session["sandboxOs"];
|
|
89
|
+
// Create-time machine targeting (A-2a, RACE-FREE): the enrolled machine (a
|
|
90
|
+
// sandbox id) to run this session on. When set, the active-sandbox pointer is
|
|
91
|
+
// resolved+validated+seeded (epoch-fenced) INSIDE finishStartSession, AFTER the
|
|
92
|
+
// session row exists but BEFORE the first turn is enqueued/the workflow woken,
|
|
93
|
+
// so the FIRST turn routes to the chosen machine. An invalid/unowned/offline
|
|
94
|
+
// target fails the create (422) — never a silent fall-back to the default box.
|
|
95
|
+
// `workingDir` (optional) is the path/cwd base the chosen machine runs under,
|
|
96
|
+
// seeded alongside the pointer through the epoch-fenced CAS.
|
|
97
|
+
seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
|
|
98
|
+
}) {
|
|
99
|
+
const sessionMetadata = {
|
|
100
|
+
...input.metadata,
|
|
101
|
+
model: input.model,
|
|
102
|
+
reasoningEffort: input.reasoningEffort,
|
|
103
|
+
};
|
|
104
|
+
// Fast path with a key: return a session already created under this key
|
|
105
|
+
// (the sequential retry / double-submit case) without inserting again.
|
|
106
|
+
if (input.createIdempotencyKey) {
|
|
107
|
+
const existing = await getSessionByCreateIdempotencyKey(input.db, input.workspaceId, input.createIdempotencyKey);
|
|
108
|
+
if (existing) {
|
|
109
|
+
return existing;
|
|
110
|
+
}
|
|
111
|
+
// No prior session: insert under the key, racing concurrent creates. The
|
|
112
|
+
// partial unique index lets exactly one insert win; a loser gets back the
|
|
113
|
+
// winner's row with created=false and must NOT run the start flow (the
|
|
114
|
+
// winner owns the events/turn/workflow), so we return it as-is.
|
|
115
|
+
const { session: keyed, created } = await createSessionWithIdempotencyKey(input.db, {
|
|
116
|
+
accountId: input.accountId,
|
|
117
|
+
workspaceId: input.workspaceId,
|
|
118
|
+
initialMessage: input.initialMessage,
|
|
119
|
+
resources: input.resources,
|
|
120
|
+
tools: input.tools,
|
|
121
|
+
metadata: sessionMetadata,
|
|
122
|
+
model: input.model,
|
|
123
|
+
sandboxBackend: input.sandboxBackend,
|
|
124
|
+
environmentId: input.environment?.id ?? null,
|
|
125
|
+
firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
|
|
126
|
+
parentSessionId: input.parentSessionId ?? null,
|
|
127
|
+
createIdempotencyKey: input.createIdempotencyKey,
|
|
128
|
+
sandboxGroupId: input.sandboxGroupId ?? null,
|
|
129
|
+
...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
|
|
130
|
+
});
|
|
131
|
+
if (!created) {
|
|
132
|
+
return keyed;
|
|
133
|
+
}
|
|
134
|
+
return await finishStartSession(input, keyed);
|
|
135
|
+
}
|
|
136
|
+
const session = await createSession(input.db, {
|
|
137
|
+
accountId: input.accountId,
|
|
138
|
+
workspaceId: input.workspaceId,
|
|
139
|
+
initialMessage: input.initialMessage,
|
|
140
|
+
resources: input.resources,
|
|
141
|
+
tools: input.tools,
|
|
142
|
+
metadata: sessionMetadata,
|
|
143
|
+
model: input.model,
|
|
144
|
+
sandboxBackend: input.sandboxBackend,
|
|
145
|
+
environmentId: input.environment?.id ?? null,
|
|
146
|
+
firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
|
|
147
|
+
parentSessionId: input.parentSessionId ?? null,
|
|
148
|
+
sandboxGroupId: input.sandboxGroupId ?? null,
|
|
149
|
+
...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
|
|
150
|
+
});
|
|
151
|
+
return await finishStartSession(input, session);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The post-insert half of {@link createAndStartSession}: durable goal row,
|
|
156
|
+
* the initial event batch (session.created / goal.set / user.message /
|
|
157
|
+
* status.changed), turn enqueue, and the workflow wake. Split out so the
|
|
158
|
+
* idempotency-key winner and the key-less create share one body, and the
|
|
159
|
+
* idempotency-key loser/dup can skip it entirely.
|
|
160
|
+
*/
|
|
161
|
+
async function finishStartSession(input: {
|
|
162
|
+
db: Database;
|
|
163
|
+
bus: EventBus;
|
|
164
|
+
workflowClient: SessionWorkflowClient;
|
|
165
|
+
initialMessage: string;
|
|
166
|
+
resources: ResourceRef[];
|
|
167
|
+
tools: ToolRef[];
|
|
168
|
+
clientEventId?: string;
|
|
169
|
+
model: string;
|
|
170
|
+
reasoningEffort: Settings["openaiReasoningEffort"];
|
|
171
|
+
sandboxBackend: Settings["sandboxBackend"];
|
|
172
|
+
environment?: { id: string; name: string } | null;
|
|
173
|
+
goal?: GoalSpec | null;
|
|
174
|
+
seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
|
|
175
|
+
}, session: Session): Promise<Session> {
|
|
176
|
+
// The goal row is durable session state; the workflow picks it up from the
|
|
177
|
+
// database once the first turn completes — no extra workflow plumbing here.
|
|
178
|
+
const goal = input.goal
|
|
179
|
+
? await createSessionGoal(input.db, {
|
|
180
|
+
accountId: session.accountId,
|
|
181
|
+
workspaceId: session.workspaceId,
|
|
182
|
+
sessionId: session.id,
|
|
183
|
+
text: input.goal.text,
|
|
184
|
+
successCriteria: input.goal.successCriteria ?? null,
|
|
185
|
+
maxAutoContinuations: input.goal.maxAutoContinuations ?? null,
|
|
186
|
+
createdBy: "api",
|
|
187
|
+
})
|
|
188
|
+
: null;
|
|
189
|
+
const initialPayload = {
|
|
190
|
+
text: input.initialMessage,
|
|
191
|
+
...(input.resources.length ? { resources: input.resources } : {}),
|
|
192
|
+
...(input.tools.length ? { tools: input.tools } : {}),
|
|
193
|
+
};
|
|
194
|
+
const events = await appendAndPublishEvents(input.db, input.bus, session.workspaceId, session.id, [
|
|
195
|
+
{
|
|
196
|
+
type: "session.created",
|
|
197
|
+
payload: {
|
|
198
|
+
status: "queued",
|
|
199
|
+
...(input.environment ? { environmentId: input.environment.id, environmentName: input.environment.name } : {}),
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
...(goal ? [{
|
|
203
|
+
type: "goal.set" as const,
|
|
204
|
+
payload: {
|
|
205
|
+
goalId: goal.id,
|
|
206
|
+
text: goal.text,
|
|
207
|
+
...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
|
|
208
|
+
version: goal.version,
|
|
209
|
+
actor: "api",
|
|
210
|
+
replaced: false,
|
|
211
|
+
},
|
|
212
|
+
}] : []),
|
|
213
|
+
{
|
|
214
|
+
type: "user.message",
|
|
215
|
+
payload: initialPayload,
|
|
216
|
+
...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
|
|
217
|
+
},
|
|
218
|
+
{ type: "session.status.changed", payload: { status: "queued" } },
|
|
219
|
+
]);
|
|
220
|
+
const userEvent = events.find((event) => event.type === "user.message");
|
|
221
|
+
if (!userEvent) {
|
|
222
|
+
throw new HTTPException(500, { message: "failed to append initial user event" });
|
|
223
|
+
}
|
|
224
|
+
// Create-time machine targeting (A-2a): seed the active-sandbox pointer BEFORE
|
|
225
|
+
// the first turn is enqueued + the workflow woken, so the FIRST turn routes to
|
|
226
|
+
// the chosen machine. Race-free: the epoch-fenced setActiveSandbox commits here,
|
|
227
|
+
// before wakeSessionWorkflow below signals the worker. swapActiveSandbox does
|
|
228
|
+
// the same ownership+liveness validation as the live swap; an invalid/unowned/
|
|
229
|
+
// offline target FAILS the create (422) — never a silent fall-back to the box.
|
|
230
|
+
if (input.seedTargetSandbox) {
|
|
231
|
+
if (session.sandboxBackend === "none") {
|
|
232
|
+
throw new HTTPException(422, {
|
|
233
|
+
message: "cannot target a machine for a session with no sandbox (backend: none)",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const ctx: FleetContext = {
|
|
237
|
+
accountId: session.accountId,
|
|
238
|
+
workspaceId: session.workspaceId,
|
|
239
|
+
sessionId: session.id,
|
|
240
|
+
sessionBackend: session.sandboxBackend,
|
|
241
|
+
sessionGroupId: session.sandboxGroupId,
|
|
242
|
+
};
|
|
243
|
+
const seeded = await swapActiveSandbox(
|
|
244
|
+
{ db: input.db, settings: input.seedTargetSandbox.settings, bus: input.bus },
|
|
245
|
+
ctx,
|
|
246
|
+
input.seedTargetSandbox.sandboxId,
|
|
247
|
+
// The working dir is committed in the SAME epoch-fenced CAS that seeds the
|
|
248
|
+
// pointer, so the first turn routes to the machine AND lands in working_dir.
|
|
249
|
+
input.seedTargetSandbox.workingDir ?? null,
|
|
250
|
+
);
|
|
251
|
+
if (!seeded.swapped) {
|
|
252
|
+
throw new HTTPException(422, {
|
|
253
|
+
message: `cannot target sandbox ${input.seedTargetSandbox.sandboxId}: ${seeded.reason ?? "target is not attachable"}`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const workflowId = workflowIdForSession(session.id);
|
|
258
|
+
await setTemporalWorkflowId(input.db, session.workspaceId, session.id, workflowId);
|
|
259
|
+
const turn = await enqueueSessionTurn(input.db, {
|
|
260
|
+
accountId: session.accountId,
|
|
261
|
+
workspaceId: session.workspaceId,
|
|
262
|
+
sessionId: session.id,
|
|
263
|
+
triggerEventId: userEvent.id,
|
|
264
|
+
temporalWorkflowId: workflowId,
|
|
265
|
+
source: "user",
|
|
266
|
+
prompt: input.initialMessage,
|
|
267
|
+
resources: input.resources,
|
|
268
|
+
tools: input.tools,
|
|
269
|
+
model: input.model,
|
|
270
|
+
reasoningEffort: input.reasoningEffort,
|
|
271
|
+
sandboxBackend: input.sandboxBackend,
|
|
272
|
+
metadata: {},
|
|
273
|
+
});
|
|
274
|
+
await appendAndPublishEvents(input.db, input.bus, session.workspaceId, session.id, [{
|
|
275
|
+
type: "turn.queued",
|
|
276
|
+
turnId: turn.id,
|
|
277
|
+
payload: { turnId: turn.id, triggerEventId: userEvent.id, source: turn.source },
|
|
278
|
+
}]);
|
|
279
|
+
await input.workflowClient.wakeSessionWorkflow({ accountId: session.accountId, workspaceId: session.workspaceId, sessionId: session.id, workflowId });
|
|
280
|
+
return await requireSession(input.db, session.workspaceId, session.id);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function workflowIdForSession(sessionId: string): string {
|
|
284
|
+
return `session-${sessionId}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Reject an explicit model that the host does not expose. The set of usable
|
|
289
|
+
* models is the union surfaced by `configuredAllowedModels` (the built-in
|
|
290
|
+
* provider's allow-list plus every registry provider's ids); a `model` outside
|
|
291
|
+
* it cannot be resolved to a provider at run time, so we fail the request at
|
|
292
|
+
* the API edge with 422 rather than enqueuing a turn the worker can't honor.
|
|
293
|
+
*
|
|
294
|
+
* `model` is the explicit, caller-supplied value (null/undefined when omitted).
|
|
295
|
+
* An omitted model defaults to `settings.openaiModel` downstream — which is
|
|
296
|
+
* always first in `configuredAllowedModels` — so only an explicit value is
|
|
297
|
+
* checked. Centralized here so every model-carrying choke point
|
|
298
|
+
* (create-session, user-message/turn-accept, queued-turn update, and
|
|
299
|
+
* scheduled-task agentConfig — a scheduled task is a session the worker runs
|
|
300
|
+
* later) and the MCP surfaces that share them validate identically and cannot
|
|
301
|
+
* drift.
|
|
302
|
+
*/
|
|
303
|
+
export function assertConfiguredModel(settings: Settings, model: string | null | undefined): void {
|
|
304
|
+
if (model === null || model === undefined) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (configuredAllowedModels(settings).includes(model)) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// Codex subscription models (codex/<slug>) are injected per-workspace by the
|
|
311
|
+
// worker overlay at turn time, so they are never in the deployment-global
|
|
312
|
+
// allow-list. Accept them at the edge when the feature is enabled — the picker
|
|
313
|
+
// only surfaces them for a connected workspace, and the worker enforces the
|
|
314
|
+
// actual connection (an unconnected workspace fails the turn with a clear
|
|
315
|
+
// "no Codex subscription connected" error rather than a misleading 422 here).
|
|
316
|
+
if (settings.codexSubscriptionEnabled && model.startsWith(CODEX_MODEL_ID_PREFIX)) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
throw new HTTPException(422, { message: `model is not available: ${model}` });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function requireQueuedTurnForApi(db: Database, workspaceId: string, sessionId: string, turnId: string): Promise<SessionTurn> {
|
|
323
|
+
const turn = await getSessionTurn(db, workspaceId, turnId);
|
|
324
|
+
if (!turn || turn.sessionId !== sessionId) {
|
|
325
|
+
throw new HTTPException(404, { message: "session turn not found" });
|
|
326
|
+
}
|
|
327
|
+
if (turn.status !== "queued") {
|
|
328
|
+
throw new HTTPException(409, { message: `turn is ${turn.status}; only queued turns can be changed` });
|
|
329
|
+
}
|
|
330
|
+
return turn;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function reasoningEffortForSession(metadata: Record<string, unknown>, fallback: Settings["openaiReasoningEffort"]): Settings["openaiReasoningEffort"] {
|
|
334
|
+
return reasoningEffortForMetadata(metadata, fallback);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Appends a `user.message` to an existing session and enqueues the resulting
|
|
339
|
+
* turn, merging requested resources/tools into the session and waking the
|
|
340
|
+
* workflow. Shared by the public events route and the first-party MCP
|
|
341
|
+
* `session_send_message` tool so the two surfaces cannot drift. Callers own
|
|
342
|
+
* resource/tool validation and the per-message usage limit before calling.
|
|
343
|
+
*/
|
|
344
|
+
export async function postUserMessageTurn(input: {
|
|
345
|
+
db: Database;
|
|
346
|
+
bus: EventBus;
|
|
347
|
+
workflowClient: SessionWorkflowClient;
|
|
348
|
+
settings: Settings;
|
|
349
|
+
accountId: string;
|
|
350
|
+
workspaceId: string;
|
|
351
|
+
sessionId: string;
|
|
352
|
+
text: string;
|
|
353
|
+
resources: ResourceRef[];
|
|
354
|
+
tools: ToolRef[];
|
|
355
|
+
model?: string | null;
|
|
356
|
+
reasoningEffort?: Settings["openaiReasoningEffort"] | null;
|
|
357
|
+
clientEventId?: string;
|
|
358
|
+
}): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
|
|
359
|
+
const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
|
|
360
|
+
const requestedModel = input.model ?? null;
|
|
361
|
+
const requestedReasoningEffort = input.reasoningEffort ?? null;
|
|
362
|
+
// Reject an explicit per-message model the host does not expose; an omitted
|
|
363
|
+
// model inherits the session's model downstream (always a configured id).
|
|
364
|
+
assertConfiguredModel(settings, requestedModel);
|
|
365
|
+
const appended = await appendSessionEventsWithLockedSessionUpdate(db, workspaceId, sessionId, (lockedSession) => {
|
|
366
|
+
// Cancelled is the one terminal state: an explicit user act. A FAILED
|
|
367
|
+
// session stays revivable by talking to it — conversation truth lives in
|
|
368
|
+
// session_history_items, so a failed turn does not invalidate history,
|
|
369
|
+
// and the manager channel of record must always answer when spoken to.
|
|
370
|
+
// The new message transitions failed -> queued (clearing the stale
|
|
371
|
+
// activeTurnId) and the signalWithStart below starts a fresh workflow
|
|
372
|
+
// run for the completed (failed) one, exactly as for idle sessions.
|
|
373
|
+
if (lockedSession.status === "cancelled") {
|
|
374
|
+
throw new HTTPException(409, { message: `session is ${lockedSession.status}; cannot accept a new user message` });
|
|
375
|
+
}
|
|
376
|
+
const nextResources = mergeResourceRefs(lockedSession.resources, input.resources);
|
|
377
|
+
const nextTools = mergeToolRefs(lockedSession.tools, input.tools);
|
|
378
|
+
const shouldQueueSession = lockedSession.status === "idle" || lockedSession.status === "failed";
|
|
379
|
+
return {
|
|
380
|
+
events: [
|
|
381
|
+
{
|
|
382
|
+
type: "user.message",
|
|
383
|
+
payload: {
|
|
384
|
+
text: input.text,
|
|
385
|
+
...(input.resources.length ? { resources: input.resources } : {}),
|
|
386
|
+
...(input.tools.length ? { tools: input.tools } : {}),
|
|
387
|
+
...(requestedModel ? { model: requestedModel } : {}),
|
|
388
|
+
...(requestedReasoningEffort ? { reasoningEffort: requestedReasoningEffort } : {}),
|
|
389
|
+
},
|
|
390
|
+
...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
|
|
391
|
+
},
|
|
392
|
+
...(shouldQueueSession ? [{ type: "session.status.changed" as const, payload: { status: "queued" } }] : []),
|
|
393
|
+
],
|
|
394
|
+
update: {
|
|
395
|
+
resources: nextResources,
|
|
396
|
+
tools: nextTools,
|
|
397
|
+
...(shouldQueueSession ? { status: "queued" as const, activeTurnId: null } : {}),
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}).then(async (events) => {
|
|
401
|
+
await bus.publish(workspaceId, sessionId, events);
|
|
402
|
+
return events;
|
|
403
|
+
});
|
|
404
|
+
const accepted = appended[0];
|
|
405
|
+
if (!accepted) {
|
|
406
|
+
throw new HTTPException(500, { message: "failed to append client event" });
|
|
407
|
+
}
|
|
408
|
+
const workflowId = workflowIdForSession(sessionId);
|
|
409
|
+
const session = await requireSession(db, workspaceId, sessionId);
|
|
410
|
+
const turn = await enqueueSessionTurn(db, {
|
|
411
|
+
accountId,
|
|
412
|
+
workspaceId,
|
|
413
|
+
sessionId,
|
|
414
|
+
triggerEventId: accepted.id,
|
|
415
|
+
temporalWorkflowId: workflowId,
|
|
416
|
+
source: "user",
|
|
417
|
+
prompt: input.text,
|
|
418
|
+
resources: input.resources,
|
|
419
|
+
tools: input.tools,
|
|
420
|
+
model: requestedModel ?? session.model,
|
|
421
|
+
reasoningEffort: requestedReasoningEffort ?? reasoningEffortForSession(session.metadata, settings.openaiReasoningEffort),
|
|
422
|
+
sandboxBackend: session.sandboxBackend,
|
|
423
|
+
metadata: {},
|
|
424
|
+
});
|
|
425
|
+
await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
|
|
426
|
+
type: "turn.queued",
|
|
427
|
+
turnId: turn.id,
|
|
428
|
+
payload: { turnId: turn.id, triggerEventId: accepted.id, source: turn.source },
|
|
429
|
+
}]);
|
|
430
|
+
await workflowClient.wakeSessionWorkflow({ accountId, workspaceId, sessionId, workflowId });
|
|
431
|
+
return { accepted, turn };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Full create-session flow shared by `POST /sessions` and the first-party MCP
|
|
436
|
+
* `session_create` tool: payload validation, resource/tool/environment
|
|
437
|
+
* checks, usage limits, session start, and usage recording. `rawPayload` is
|
|
438
|
+
* the unparsed request body so absent-vs-empty `tools` keeps its meaning
|
|
439
|
+
* (absent applies the workspace's default capability MCP tools).
|
|
440
|
+
*/
|
|
441
|
+
export async function createSessionForRequest(
|
|
442
|
+
deps: ApiRouteDeps,
|
|
443
|
+
grant: AccessGrant,
|
|
444
|
+
workspaceId: string,
|
|
445
|
+
rawPayload: unknown,
|
|
446
|
+
): Promise<Session> {
|
|
447
|
+
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
448
|
+
const payload = CreateSessionRequest.parse(rawPayload);
|
|
449
|
+
const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
|
|
450
|
+
const resources = normalizeResources(payload.resources);
|
|
451
|
+
const requestedTools = validateToolRefs(payload.tools, runtimeSettings);
|
|
452
|
+
const defaultedTools = hasOwnProperty(rawPayload, "tools")
|
|
453
|
+
? requestedTools
|
|
454
|
+
: withDefaultEnabledCapabilityMcpTools(requestedTools, settings, runtimeSettings);
|
|
455
|
+
// The first-party MCP server is attached to EVERY session. It hosts the
|
|
456
|
+
// session's own metadata tool (set_session_title) + goal tools, and — only
|
|
457
|
+
// when the grant carries the permission — the orchestration/environment/
|
|
458
|
+
// github tools. Capability is gated per-tool by permission, never by whether
|
|
459
|
+
// the server is attached, so a bare chat still gets titling while the
|
|
460
|
+
// dangerous tools stay off by default.
|
|
461
|
+
const tools = withFirstPartyTools(defaultedTools, runtimeSettings);
|
|
462
|
+
await validateGitHubRepositorySelection(db, workspaceId, resources);
|
|
463
|
+
if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
|
|
464
|
+
throw new HTTPException(503, { message: "object storage is not configured" });
|
|
465
|
+
}
|
|
466
|
+
await validateFileResources(db, workspaceId, resources);
|
|
467
|
+
// Environment attachment requires environments:use on the calling grant
|
|
468
|
+
// (validateEnvironmentAttachment enforces it), preserving the invariant
|
|
469
|
+
// that sandboxed agents cannot self-attach workspace secrets.
|
|
470
|
+
const environment = payload.environmentId
|
|
471
|
+
? await validateEnvironmentAttachment({ settings, db }, grant, workspaceId, payload.environmentId)
|
|
472
|
+
: null;
|
|
473
|
+
assertConfiguredModel(settings, payload.model);
|
|
474
|
+
const model = payload.model ?? settings.openaiModel;
|
|
475
|
+
const reasoningEffort = payload.reasoningEffort ?? settings.openaiReasoningEffort;
|
|
476
|
+
// A session's first-party MCP token can carry a non-default permission set
|
|
477
|
+
// (how an operator hands a manager-style session the orchestration tools),
|
|
478
|
+
// but never one out-ranking its creator: every requested permission must be
|
|
479
|
+
// held by the creating grant.
|
|
480
|
+
let firstPartyMcpPermissions = payload.firstPartyMcpPermissions ?? null;
|
|
481
|
+
if (firstPartyMcpPermissions && firstPartyMcpPermissions.length === 0) {
|
|
482
|
+
// An empty set would sign an unusable zero-permission token; the default
|
|
483
|
+
// worker set is expressed by omitting the field.
|
|
484
|
+
throw new HTTPException(422, { message: "firstPartyMcpPermissions must not be empty; omit it for the default worker permission set" });
|
|
485
|
+
}
|
|
486
|
+
for (const permission of firstPartyMcpPermissions ?? []) {
|
|
487
|
+
if (!hasPermission(grant.permissions, permission)) {
|
|
488
|
+
throw new HTTPException(403, { message: `cannot grant first-party MCP permission beyond the creating grant: ${permission}` });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// Invariant: a goal-bearing session always carries goals:manage in its
|
|
492
|
+
// effective first-party permissions. Without it the worker's delegated
|
|
493
|
+
// token never sees the goal tools (goal_complete/goal_pause/...), so the
|
|
494
|
+
// agent cannot stop its own goal and the continuation loop runs until an
|
|
495
|
+
// operator intervenes. The auto-added permission is deliberately exempt
|
|
496
|
+
// from the creating-grant check above: goal tools are scoped to the
|
|
497
|
+
// spawned session itself via the worker-signed sessionId claim, so a
|
|
498
|
+
// worker managing its OWN goal is not an escalation of the spawner's
|
|
499
|
+
// authority.
|
|
500
|
+
if (payload.goal && firstPartyMcpPermissions && !firstPartyMcpPermissions.includes("goals:manage")) {
|
|
501
|
+
firstPartyMcpPermissions = [...firstPartyMcpPermissions, "goals:manage"];
|
|
502
|
+
}
|
|
503
|
+
// Parent linkage: a worker is linked to its manager ONLY from the
|
|
504
|
+
// worker-signed sessionId claim on the creating grant — the manager
|
|
505
|
+
// session's own id, signed into the delegated token by the worker and never
|
|
506
|
+
// agent- or caller-controlled. A grant without that claim (a workspace API
|
|
507
|
+
// key, any non-delegated grant) creates a parentless top-level session.
|
|
508
|
+
//
|
|
509
|
+
// We deliberately do NOT honor a caller-supplied parentSessionId: it would
|
|
510
|
+
// let any sessions:create grant aim a worker at an arbitrary session's id so
|
|
511
|
+
// its completion wake injects a user.message + queued turn into that session
|
|
512
|
+
// without holding sessions:control on it (a cross-session write escalation).
|
|
513
|
+
// The claim is the only trustworthy parent source.
|
|
514
|
+
const parentSessionId = typeof grant.metadata?.["sessionId"] === "string" ? grant.metadata["sessionId"] as string : null;
|
|
515
|
+
// Shared-sandbox placement (addendum 05 §D.2/§D.3, decision I10/OD-S1).
|
|
516
|
+
//
|
|
517
|
+
// The DEFAULT rule is context-dependent and resolved server-side from the
|
|
518
|
+
// TRUSTED claim, never caller-supplied: when `sandbox` is omitted, a session
|
|
519
|
+
// spawned FROM INSIDE a session (parentSessionId present ⇒ a worker-signed
|
|
520
|
+
// sessionId claim) defaults to "shared" (join the creator's box); a top-level
|
|
521
|
+
// create (no parent) defaults to "new" (a private singleton box). Explicit
|
|
522
|
+
// values always win.
|
|
523
|
+
//
|
|
524
|
+
// null sandboxGroupId ⇒ createSession seeds the new row's own id (singleton,
|
|
525
|
+
// today's 1:1 behavior). A shared/{groupId} spawn inherits the box's backend
|
|
526
|
+
// (it is literally the same box; the child cannot pick its own). Cross-
|
|
527
|
+
// workspace sharing is forbidden by construction: getSession/
|
|
528
|
+
// getAnySessionInGroup are RLS-workspace-scoped, so a foreign parent/group
|
|
529
|
+
// returns null → 404; the group uuid is NOT an access boundary, the workspace
|
|
530
|
+
// filter is (stress (e)).
|
|
531
|
+
const sandboxChoice = payload.sandbox ?? (parentSessionId ? "shared" : "new");
|
|
532
|
+
let sandboxGroupId: string | null = null;
|
|
533
|
+
let inheritedBackend: Session["sandboxBackend"] | undefined;
|
|
534
|
+
// ENV-AWARE GROUPING: under the CURRENT mechanics the workspace Environment is
|
|
535
|
+
// creation-time box state — the box's manifest env is fixed when it is cold-
|
|
536
|
+
// created, and the SDK's provided-session guard rejects any manifest-env delta
|
|
537
|
+
// at attach. A session carrying a DIFFERENT Environment than the box it joins
|
|
538
|
+
// is therefore a genuine shared-state conflict TODAY: its first turn on a warm
|
|
539
|
+
// box dies with "Live sandbox sessions cannot change manifest environment
|
|
540
|
+
// variables" (proven live, sessions 5aee77e9 + 63d18823). Until the Environment
|
|
541
|
+
// is evicted from the manifest (per-exec, like the git token), grouping must be
|
|
542
|
+
// env-aware: the INHERITED default falls back to an own box on mismatch (a
|
|
543
|
+
// credentialed worker spawned from a credential-less manager just works), and
|
|
544
|
+
// an EXPLICIT shared/{groupId} request with a mismatched Environment fails
|
|
545
|
+
// fast at create (422) instead of poisoning the session's first turn.
|
|
546
|
+
// The env conflict is a BOX property, so a boxless group is exempt: a
|
|
547
|
+
// backend:"none" session runs in-process with no sandbox, no manifest, and no
|
|
548
|
+
// provided-session attach — no shared box state exists to conflict, and
|
|
549
|
+
// env-differing spawns from such parents shared safely before the env-aware
|
|
550
|
+
// check. They keep sharing (and keep inheriting "none").
|
|
551
|
+
const requestedEnvironmentId = payload.environmentId ?? null;
|
|
552
|
+
const environmentMatchesGroup = (memberEnvironmentId: string | null): boolean =>
|
|
553
|
+
memberEnvironmentId === requestedEnvironmentId;
|
|
554
|
+
if (sandboxChoice === "shared") {
|
|
555
|
+
if (!parentSessionId) {
|
|
556
|
+
throw new HTTPException(422, { message: "sandbox:'shared' requires a parent session (spawn from inside a session); use 'new' for a top-level create." });
|
|
557
|
+
}
|
|
558
|
+
const parent = await getSession(db, workspaceId, parentSessionId);
|
|
559
|
+
if (!parent) {
|
|
560
|
+
throw new HTTPException(404, { message: `parent session not found in workspace: ${parentSessionId}` });
|
|
561
|
+
}
|
|
562
|
+
if (parent.sandboxBackend !== "none" && !environmentMatchesGroup(parent.environmentId ?? null)) {
|
|
563
|
+
if (payload.sandbox === "shared") {
|
|
564
|
+
// The caller explicitly asked to share while carrying a different
|
|
565
|
+
// Environment — surface the conflict at create time, not turn time.
|
|
566
|
+
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." });
|
|
567
|
+
}
|
|
568
|
+
// Inherited default: deterministic separation on the genuine shared-state
|
|
569
|
+
// conflict — the worker gets its own box (resolved like a top-level
|
|
570
|
+
// create: payload.sandboxBackend, else the deployment default) and its
|
|
571
|
+
// turn runs.
|
|
572
|
+
} else {
|
|
573
|
+
sandboxGroupId = parent.sandboxGroupId;
|
|
574
|
+
inheritedBackend = parent.sandboxBackend;
|
|
575
|
+
}
|
|
576
|
+
} else if (typeof sandboxChoice === "object") {
|
|
577
|
+
const member = await getAnySessionInGroup(db, workspaceId, sandboxChoice.groupId);
|
|
578
|
+
if (!member) {
|
|
579
|
+
throw new HTTPException(404, { message: `sandbox group not found in workspace: ${sandboxChoice.groupId}` });
|
|
580
|
+
}
|
|
581
|
+
if (member.sandboxBackend !== "none") {
|
|
582
|
+
// Compare against EVERY member, not one arbitrary row: a legacy env-blind
|
|
583
|
+
// group can carry mixed environmentIds, and an any-member read would make
|
|
584
|
+
// the join verdict nondeterministic. Post-env-aware groups are homogeneous
|
|
585
|
+
// (both join paths enforce equality), so this reads one distinct value in
|
|
586
|
+
// the common case; a mixed legacy group deterministically rejects.
|
|
587
|
+
const memberEnvironmentIds = await listDistinctEnvironmentIdsInGroup(db, workspaceId, sandboxChoice.groupId);
|
|
588
|
+
if (!memberEnvironmentIds.every((memberEnvironmentId) => environmentMatchesGroup(memberEnvironmentId))) {
|
|
589
|
+
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.` });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
sandboxGroupId = sandboxChoice.groupId;
|
|
593
|
+
inheritedBackend = member.sandboxBackend;
|
|
594
|
+
}
|
|
595
|
+
// else "new": leave sandboxGroupId null → own singleton group (group ≡ id).
|
|
596
|
+
// A working dir is only meaningful for a TARGETED machine (it is the chosen
|
|
597
|
+
// box's path/cwd base). Present without a targetSandboxId is a malformed request
|
|
598
|
+
// — reject it at the edge (mirrors the backend:'none' guard) rather than silently
|
|
599
|
+
// dropping it, since the default group box has no working-dir seam yet.
|
|
600
|
+
if (payload.workingDir !== undefined && !payload.targetSandboxId) {
|
|
601
|
+
throw new HTTPException(422, { message: "workingDir requires targetSandboxId (it is the targeted machine's working directory)" });
|
|
602
|
+
}
|
|
603
|
+
// Honest-label (Stage-D closure): a top-level session TARGETED at a Connected
|
|
604
|
+
// Machine (a selfhosted sandbox) runs machine-primary every turn, so its HOME
|
|
605
|
+
// sandbox_backend must read "selfhosted" — not the deployment cloud default —
|
|
606
|
+
// so the session row + first turn honestly reflect where the agent runs (the
|
|
607
|
+
// Machines dashboard, the turn's warm-metering, and the file-download plane all
|
|
608
|
+
// key off this). GUARDS: (1) only at a TOP-LEVEL create (inheritedBackend
|
|
609
|
+
// undefined) — a shared/{groupId} spawn is literally the creator's box and must
|
|
610
|
+
// NOT be relabeled; (2) only when the target's kind is actually "selfhosted" —
|
|
611
|
+
// targetSandboxId also accepts a first-class MODAL sandbox id (resolveTarget),
|
|
612
|
+
// which must never be mislabeled. A not-found / non-selfhosted / modal target
|
|
613
|
+
// falls through to the default; the seed swap in createAndStartSession still
|
|
614
|
+
// validates ownership/liveness and 422s a bad target. (3) only when the feature
|
|
615
|
+
// flags that make the worker actually take the machine-primary path are ON
|
|
616
|
+
// (sandboxOwnershipEnabled + sandboxSelfhostedEnabled/routing) — otherwise the
|
|
617
|
+
// worker ignores the active pointer and a home="selfhosted" turn would fall to
|
|
618
|
+
// the registry client with no bound agentId and throw; with the flags off we
|
|
619
|
+
// keep the cloud default and the machine layers as a (pre-honest-label) overlay.
|
|
620
|
+
// sandbox_os (the OS axis the worker's group-box resume + the OS-labeling
|
|
621
|
+
// surfaces key off) must ALSO reflect the targeted machine, not the "linux"
|
|
622
|
+
// schema default — a session run on a macOS Connected Machine that labels
|
|
623
|
+
// itself linux lies to those surfaces. Derived under the SAME guards as the
|
|
624
|
+
// backend relabel; the enrollment (joined via the sandbox's enrollmentId)
|
|
625
|
+
// carries the OS. enrollmentOsValues and the sessions.sandbox_os value set are
|
|
626
|
+
// both ("linux","macos","windows"), so a known value maps 1:1; any other value
|
|
627
|
+
// is left to the "linux" default (never write a value no reader understands).
|
|
628
|
+
let machineHomeBackend: Session["sandboxBackend"] | undefined;
|
|
629
|
+
let machineHomeOs: Session["sandboxOs"] | undefined;
|
|
630
|
+
if (
|
|
631
|
+
payload.targetSandboxId
|
|
632
|
+
&& inheritedBackend === undefined
|
|
633
|
+
&& settings.sandboxOwnershipEnabled
|
|
634
|
+
&& settings.sandboxSelfhostedEnabled
|
|
635
|
+
) {
|
|
636
|
+
const targetSandbox = await getSandbox(db, workspaceId, payload.targetSandboxId);
|
|
637
|
+
if (targetSandbox?.kind === "selfhosted") {
|
|
638
|
+
machineHomeBackend = "selfhosted";
|
|
639
|
+
if (targetSandbox.enrollmentId) {
|
|
640
|
+
const enrollment = await getEnrollment(db, workspaceId, targetSandbox.enrollmentId);
|
|
641
|
+
if (enrollment && (enrollment.os === "macos" || enrollment.os === "windows" || enrollment.os === "linux")) {
|
|
642
|
+
machineHomeOs = enrollment.os;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "agent_run:create", quantity: 1, model });
|
|
648
|
+
const session = await createAndStartSession({
|
|
649
|
+
db,
|
|
650
|
+
bus,
|
|
651
|
+
workflowClient,
|
|
652
|
+
accountId: grant.accountId,
|
|
653
|
+
workspaceId,
|
|
654
|
+
initialMessage: payload.initialMessage,
|
|
655
|
+
resources,
|
|
656
|
+
tools,
|
|
657
|
+
...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
|
|
658
|
+
model,
|
|
659
|
+
reasoningEffort,
|
|
660
|
+
// A shared spawn inherits the box's backend; a caller-supplied
|
|
661
|
+
// sandboxBackend on a shared spawn is ignored (it is the same box). A
|
|
662
|
+
// machine-targeted top-level create labels the home "selfhosted"
|
|
663
|
+
// (machineHomeBackend), overriding the caller/deployment default so the row
|
|
664
|
+
// matches where the session actually runs.
|
|
665
|
+
sandboxBackend: inheritedBackend ?? machineHomeBackend ?? payload.sandboxBackend ?? settings.sandboxBackend,
|
|
666
|
+
// Mirror the backend relabel on the OS axis: only a machine-targeted
|
|
667
|
+
// top-level create carries a derived OS; everything else is omitted and the
|
|
668
|
+
// "linux" default holds (shared spawns keep the parent-box behavior).
|
|
669
|
+
...(machineHomeOs ? { sandboxOs: machineHomeOs } : {}),
|
|
670
|
+
sandboxGroupId,
|
|
671
|
+
metadata: payload.metadata,
|
|
672
|
+
environment: environment ? { id: environment.id, name: environment.name } : null,
|
|
673
|
+
goal: payload.goal ?? null,
|
|
674
|
+
firstPartyMcpPermissions,
|
|
675
|
+
parentSessionId,
|
|
676
|
+
createIdempotencyKey: payload.idempotencyKey ?? null,
|
|
677
|
+
// Create-time machine targeting (A-2a): when a target sandbox is named, the
|
|
678
|
+
// active-sandbox pointer is seeded race-free inside createAndStartSession
|
|
679
|
+
// (after the row exists, before the first turn dispatches). Validation
|
|
680
|
+
// (ownership/liveness) lives in swapActiveSandbox; an invalid target 422s.
|
|
681
|
+
seedTargetSandbox: payload.targetSandboxId
|
|
682
|
+
? { sandboxId: payload.targetSandboxId, settings, workingDir: payload.workingDir ?? null }
|
|
683
|
+
: null,
|
|
684
|
+
});
|
|
685
|
+
await recordWorkspaceUsage(deps, {
|
|
686
|
+
accountId: grant.accountId,
|
|
687
|
+
workspaceId,
|
|
688
|
+
subjectId: grant.subjectId,
|
|
689
|
+
eventType: "agent_run.created",
|
|
690
|
+
quantity: 1,
|
|
691
|
+
unit: "run",
|
|
692
|
+
sourceResourceType: "session",
|
|
693
|
+
sourceResourceId: session.id,
|
|
694
|
+
idempotencyKey: `agent_run.created:${workspaceId}:${session.id}`,
|
|
695
|
+
});
|
|
696
|
+
return session;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Full accept-user-message flow shared by the `user.message` branch of
|
|
701
|
+
* `POST /sessions/:id/events` and the first-party MCP `session_send_message`
|
|
702
|
+
* tool: resource/tool validation, usage limits, the locked append + turn
|
|
703
|
+
* enqueue, and usage recording. `toolsProvided: false` applies the
|
|
704
|
+
* workspace's default capability MCP tools, matching an absent `tools` key.
|
|
705
|
+
*/
|
|
706
|
+
export async function acceptSessionUserMessage(
|
|
707
|
+
deps: ApiRouteDeps,
|
|
708
|
+
grant: AccessGrant,
|
|
709
|
+
workspaceId: string,
|
|
710
|
+
sessionId: string,
|
|
711
|
+
input: {
|
|
712
|
+
text: string;
|
|
713
|
+
resources?: ResourceRef[];
|
|
714
|
+
tools?: ToolRef[];
|
|
715
|
+
toolsProvided: boolean;
|
|
716
|
+
model?: string | null;
|
|
717
|
+
reasoningEffort?: ReasoningEffort | null;
|
|
718
|
+
clientEventId?: string;
|
|
719
|
+
},
|
|
720
|
+
): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
|
|
721
|
+
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
722
|
+
const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
|
|
723
|
+
const requestedResources = normalizeResources(input.resources ?? []);
|
|
724
|
+
const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
|
|
725
|
+
const requestedTools = input.toolsProvided
|
|
726
|
+
? validatedTools
|
|
727
|
+
: withDefaultEnabledCapabilityMcpTools(validatedTools, settings, runtimeSettings);
|
|
728
|
+
// Hoisted above requireLimit so the codex-billed predicate can resolve the
|
|
729
|
+
// turn's effective model (a follow-up turn inherits the session's model). A
|
|
730
|
+
// pure read with no side effects.
|
|
731
|
+
const existingSession = await requireSession(db, workspaceId, sessionId);
|
|
732
|
+
await requireLimit(deps, {
|
|
733
|
+
accountId: grant.accountId,
|
|
734
|
+
workspaceId,
|
|
735
|
+
action: "agent_run:create",
|
|
736
|
+
quantity: 1,
|
|
737
|
+
model: input.model ?? existingSession.model,
|
|
738
|
+
});
|
|
739
|
+
if (requestedResources.some((resource) => resource.kind === "file") && !objectStorage) {
|
|
740
|
+
throw new HTTPException(503, { message: "object storage is not configured" });
|
|
741
|
+
}
|
|
742
|
+
await validateFileResources(db, workspaceId, requestedResources);
|
|
743
|
+
await validateGitHubRepositorySelection(db, workspaceId, [...existingSession.resources, ...requestedResources]);
|
|
744
|
+
const { accepted, turn } = await postUserMessageTurn({
|
|
745
|
+
db,
|
|
746
|
+
bus,
|
|
747
|
+
workflowClient,
|
|
748
|
+
settings,
|
|
749
|
+
accountId: grant.accountId,
|
|
750
|
+
workspaceId,
|
|
751
|
+
sessionId,
|
|
752
|
+
text: input.text,
|
|
753
|
+
resources: requestedResources,
|
|
754
|
+
tools: requestedTools,
|
|
755
|
+
model: input.model ?? null,
|
|
756
|
+
reasoningEffort: input.reasoningEffort ?? null,
|
|
757
|
+
...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
|
|
758
|
+
});
|
|
759
|
+
await recordWorkspaceUsage(deps, {
|
|
760
|
+
accountId: grant.accountId,
|
|
761
|
+
workspaceId,
|
|
762
|
+
subjectId: grant.subjectId,
|
|
763
|
+
eventType: "agent_run.created",
|
|
764
|
+
quantity: 1,
|
|
765
|
+
unit: "run",
|
|
766
|
+
sourceResourceType: "session_turn",
|
|
767
|
+
sourceResourceId: turn.id,
|
|
768
|
+
idempotencyKey: `agent_run.created:${workspaceId}:${turn.id}`,
|
|
769
|
+
});
|
|
770
|
+
return { accepted, turn };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Shared title-write path for the manual rename route AND both MCP tools
|
|
775
|
+
* (set_session_title / set_other_session_title). The clobber guard lives in
|
|
776
|
+
* the db `updateSessionTitle` UPDATE: an agent write is skipped when a user
|
|
777
|
+
* title already pinned the session. On a real write we emit `session.title_set`
|
|
778
|
+
* exactly like goal mutations emit their events; when nothing changed (agent
|
|
779
|
+
* write blocked by the user lock) we emit nothing. Returns whether a write
|
|
780
|
+
* happened so callers can avoid double work.
|
|
781
|
+
*/
|
|
782
|
+
export async function updateSessionTitle(
|
|
783
|
+
deps: { db: Database; bus: EventBus },
|
|
784
|
+
workspaceId: string,
|
|
785
|
+
sessionId: string,
|
|
786
|
+
title: string,
|
|
787
|
+
source: "user" | "agent",
|
|
788
|
+
): Promise<{ updated: boolean; title: string | null }> {
|
|
789
|
+
const { db, bus } = deps;
|
|
790
|
+
const result = await updateSessionTitleRow(db, { workspaceId, sessionId, title, source });
|
|
791
|
+
if (result.updated) {
|
|
792
|
+
await appendAndPublishEvents(db, bus, workspaceId, sessionId, [{
|
|
793
|
+
type: "session.title_set",
|
|
794
|
+
payload: {
|
|
795
|
+
title: result.title ?? title,
|
|
796
|
+
source,
|
|
797
|
+
},
|
|
798
|
+
}]);
|
|
799
|
+
}
|
|
800
|
+
return result;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function withFirstPartyTools(tools: ToolRef[], runtimeSettings: { mcpServers: Array<{ id: string }> }): ToolRef[] {
|
|
804
|
+
if (!runtimeSettings.mcpServers.some((server) => server.id === "opengeni")) {
|
|
805
|
+
return tools;
|
|
806
|
+
}
|
|
807
|
+
return mergeToolRefs(tools, [{ kind: "mcp", id: "opengeni" }]);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function hasOwnProperty(value: unknown, key: string): boolean {
|
|
811
|
+
return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
|
|
812
|
+
}
|