@opengeni/core 0.21.2 → 0.21.10

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.
@@ -4,6 +4,9 @@ import type {
4
4
  McpPersonalConnectionDelegation,
5
5
  ScheduledTask,
6
6
  ScheduledTaskAgentConfig,
7
+ Session,
8
+ SessionAuthorizationPort,
9
+ SessionAuthorizationSurface,
7
10
  CreateScheduledTaskRequest as CreateScheduledTaskPayload,
8
11
  UpdateScheduledTaskRequest as UpdateScheduledTaskPayload,
9
12
  } from "@opengeni/contracts";
@@ -15,6 +18,7 @@ import {
15
18
  getRig,
16
19
  getScheduledTask,
17
20
  getScheduledTaskPersonalConnectionDelegations,
21
+ getSession,
18
22
  requireWorkspace,
19
23
  updateScheduledTask,
20
24
  type Database,
@@ -22,6 +26,11 @@ import {
22
26
  } from "@opengeni/db";
23
27
  import { HTTPException } from "hono/http-exception";
24
28
  import { hasPermission, requirePermission } from "../access";
29
+ import {
30
+ requireSessionAuthorization,
31
+ SessionAuthorizationDeniedError,
32
+ SessionAuthorizationUnavailableError,
33
+ } from "../session-authorization";
25
34
  import type { SessionWorkflowClient } from "../dependencies";
26
35
  import type { ObjectStorageDependency } from "../dependencies";
27
36
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
@@ -38,6 +47,7 @@ import {
38
47
  } from "./sessions";
39
48
  import {
40
49
  hasReservedOpenGeniSlackBotSessionMetadata,
50
+ scheduledSlackBotConnectionId,
41
51
  validateOpenGeniSlackBotConnectionSelection,
42
52
  } from "./slack-bot";
43
53
  import {
@@ -81,6 +91,8 @@ export async function createValidatedScheduledTask(input: {
81
91
  // Set for pack-installation-inherited attachments that were already
82
92
  // authorized with variable-sets:use when the pack was enabled.
83
93
  variableSetPreauthorized?: boolean;
94
+ sessionAuthorization?: SessionAuthorizationPort | null | undefined;
95
+ authorizationSurface?: SessionAuthorizationSurface | undefined;
84
96
  }): Promise<ScheduledTask> {
85
97
  const agentConfig = await validateScheduledTaskAgentConfig({
86
98
  ...input,
@@ -88,6 +100,17 @@ export async function createValidatedScheduledTask(input: {
88
100
  });
89
101
  const id = crypto.randomUUID();
90
102
  validateScheduledTaskSchedule(input.payload.schedule);
103
+ const target = await validateScheduledTaskTarget({
104
+ db: input.db,
105
+ sessionAuthorization: input.sessionAuthorization,
106
+ authorizationSurface: input.authorizationSurface,
107
+ grant: input.grant,
108
+ targetSessionId: input.payload.targetSessionId,
109
+ runMode: input.payload.runMode,
110
+ variableSetId: input.payload.variableSetId,
111
+ rigId: input.payload.rigId,
112
+ agentConfig,
113
+ });
91
114
  if (input.payload.variableSetId) {
92
115
  await validateVariableSetAttachment(
93
116
  { settings: input.settings, db: input.db },
@@ -132,12 +155,126 @@ export async function createValidatedScheduledTask(input: {
132
155
  ...(creationInitiator.context ? { createdByContext: creationInitiator.context } : {}),
133
156
  createdByActor: creationInitiator.actor ?? null,
134
157
  personalConnectionDelegations,
158
+ targetSessionId: target?.id ?? null,
135
159
  variableSetId: input.payload.variableSetId ?? null,
136
160
  rigId: input.payload.rigId ?? null,
137
161
  metadata: input.payload.metadata,
138
162
  });
139
163
  }
140
164
 
165
+ export async function validateScheduledTaskTarget(input: {
166
+ db: Database;
167
+ sessionAuthorization?: SessionAuthorizationPort | null | undefined;
168
+ authorizationSurface?: SessionAuthorizationSurface | undefined;
169
+ grant: AccessGrant;
170
+ targetSessionId: string | null | undefined;
171
+ runMode: ScheduledTask["runMode"];
172
+ variableSetId: string | null | undefined;
173
+ rigId: string | null | undefined;
174
+ agentConfig: ScheduledTaskAgentConfig;
175
+ missingTargetStatus?: 404 | 422;
176
+ }): Promise<Session | null> {
177
+ if (input.runMode !== "existing_session") {
178
+ if (input.targetSessionId) {
179
+ throw new HTTPException(422, {
180
+ message: "targetSessionId requires runMode=existing_session",
181
+ });
182
+ }
183
+ return null;
184
+ }
185
+ if (!input.targetSessionId) {
186
+ throw new HTTPException(input.missingTargetStatus ?? 422, {
187
+ message:
188
+ input.missingTargetStatus === 404
189
+ ? "target session not found"
190
+ : "targetSessionId is required when runMode=existing_session",
191
+ });
192
+ }
193
+ requirePermission(input.grant, "sessions:control");
194
+ if (input.agentConfig.goal) {
195
+ throw new HTTPException(422, {
196
+ message: "agentConfig.goal cannot be used with an existing-session target",
197
+ });
198
+ }
199
+ try {
200
+ await requireSessionAuthorization(
201
+ {
202
+ db: input.db,
203
+ ...(input.sessionAuthorization !== undefined
204
+ ? { sessionAuthorization: input.sessionAuthorization }
205
+ : {}),
206
+ },
207
+ input.grant,
208
+ {
209
+ sessionId: input.targetSessionId,
210
+ operation: "session.control",
211
+ surface: input.authorizationSurface ?? "http",
212
+ },
213
+ );
214
+ } catch (error) {
215
+ if (error instanceof SessionAuthorizationDeniedError) {
216
+ throw new HTTPException(404, { message: "target session not found" });
217
+ }
218
+ if (error instanceof SessionAuthorizationUnavailableError) {
219
+ throw new HTTPException(503, { message: "session authorization is unavailable" });
220
+ }
221
+ throw error;
222
+ }
223
+ const session = await getSession(input.db, input.grant.workspaceId, input.targetSessionId);
224
+ if (!session || session.accountId !== input.grant.accountId) {
225
+ throw new HTTPException(404, { message: "target session not found" });
226
+ }
227
+ if (session.status === "cancelled") {
228
+ throw new HTTPException(409, {
229
+ message: "target session is cancelled; choose a revivable session",
230
+ });
231
+ }
232
+ if ((session.variableSetId ?? null) !== (input.variableSetId ?? null)) {
233
+ throw new HTTPException(422, {
234
+ message: "target session variableSet attachment does not match the scheduled task",
235
+ });
236
+ }
237
+ if (input.rigId && input.rigId !== session.rigId) {
238
+ throw new HTTPException(422, {
239
+ message: "target session rig does not match the scheduled task",
240
+ });
241
+ }
242
+ if (
243
+ input.agentConfig.sandboxBackend !== undefined &&
244
+ input.agentConfig.sandboxBackend !== session.sandboxBackend
245
+ ) {
246
+ throw new HTTPException(422, {
247
+ message: "target session sandbox backend does not match the scheduled task",
248
+ });
249
+ }
250
+ if (
251
+ scheduledSlackBotConnectionId(session.metadata) !==
252
+ (input.agentConfig.slackBotConnectionId ?? null)
253
+ ) {
254
+ throw new HTTPException(422, {
255
+ message: "target session OpenGeni Slack bot binding does not match the scheduled task",
256
+ });
257
+ }
258
+ return session;
259
+ }
260
+
261
+ export function scheduledTaskForGrant(task: ScheduledTask, grant: AccessGrant): ScheduledTask {
262
+ if (hasPermission(grant.permissions, "sessions:control") || task.targetSessionId === null) {
263
+ return task;
264
+ }
265
+ return { ...task, targetSessionId: null };
266
+ }
267
+
268
+ export function scheduledTaskRunForGrant<T extends { sessionId: string | null }>(
269
+ run: T,
270
+ grant: AccessGrant,
271
+ ): T {
272
+ if (hasPermission(grant.permissions, "sessions:control") || run.sessionId === null) {
273
+ return run;
274
+ }
275
+ return { ...run, sessionId: null };
276
+ }
277
+
141
278
  // Validate a scheduled task's rig reference: it must name a rig in the
142
279
  // workspace. A missing/cross-workspace id is a 422 (RLS-invisible == missing).
143
280
  async function requireScheduledTaskRig(
@@ -160,8 +297,28 @@ export async function validatedScheduledTaskUpdate(input: {
160
297
  payload: UpdateScheduledTaskPayload;
161
298
  /** See createValidatedScheduledTask; only consulted when agentConfig is updated. */
162
299
  toolsProvided?: boolean;
300
+ sessionAuthorization?: SessionAuthorizationPort | null | undefined;
301
+ authorizationSurface?: SessionAuthorizationSurface | undefined;
163
302
  }): Promise<UpdateScheduledTaskInput> {
164
303
  const update: UpdateScheduledTaskInput = {};
304
+ const existingTarget = input.existing.targetSessionId;
305
+ const nextRunMode = input.payload.runMode ?? input.existing.runMode;
306
+ const nextTargetSessionId =
307
+ input.payload.targetSessionId !== undefined
308
+ ? input.payload.targetSessionId
309
+ : nextRunMode === "existing_session"
310
+ ? existingTarget
311
+ : null;
312
+ if (
313
+ input.existing.runMode === "reusable_session" &&
314
+ input.existing.reusableSessionId &&
315
+ nextRunMode === "existing_session"
316
+ ) {
317
+ throw new HTTPException(409, {
318
+ message:
319
+ "cannot target an existing session after this task created its reusable session; create a new task",
320
+ });
321
+ }
165
322
  if (input.payload.name !== undefined) {
166
323
  update.name = trimmedScheduledTaskName(input.payload.name);
167
324
  }
@@ -278,6 +435,43 @@ export async function validatedScheduledTaskUpdate(input: {
278
435
  }
279
436
  update.personalConnectionDelegations = personalConnectionDelegations;
280
437
  }
438
+ if (
439
+ existingTarget &&
440
+ (nextRunMode !== "existing_session" || nextTargetSessionId !== existingTarget)
441
+ ) {
442
+ await validateScheduledTaskTarget({
443
+ db: input.db,
444
+ sessionAuthorization: input.sessionAuthorization,
445
+ authorizationSurface: input.authorizationSurface,
446
+ grant: input.grant,
447
+ targetSessionId: existingTarget,
448
+ runMode: "existing_session",
449
+ variableSetId: input.existing.variableSetId,
450
+ rigId: input.existing.rigId,
451
+ agentConfig: input.existing.agentConfig,
452
+ });
453
+ }
454
+ await validateScheduledTaskTarget({
455
+ db: input.db,
456
+ sessionAuthorization: input.sessionAuthorization,
457
+ authorizationSurface: input.authorizationSurface,
458
+ grant: input.grant,
459
+ targetSessionId: nextTargetSessionId,
460
+ runMode: nextRunMode,
461
+ variableSetId:
462
+ input.payload.variableSetId !== undefined
463
+ ? input.payload.variableSetId
464
+ : input.existing.variableSetId,
465
+ rigId: input.payload.rigId !== undefined ? input.payload.rigId : input.existing.rigId,
466
+ agentConfig: update.agentConfig ?? input.existing.agentConfig,
467
+ });
468
+ if (
469
+ input.payload.targetSessionId !== undefined ||
470
+ input.existing.runMode === "existing_session" ||
471
+ nextRunMode === "existing_session"
472
+ ) {
473
+ update.targetSessionId = nextTargetSessionId;
474
+ }
281
475
  return update;
282
476
  }
283
477
 
@@ -325,13 +519,25 @@ export async function restoreScheduledTask(
325
519
  overlapPolicy: task.overlapPolicy,
326
520
  agentConfig: task.agentConfig,
327
521
  personalConnectionDelegations: previous.personalConnectionDelegations,
328
- reusableSessionId: task.reusableSessionId,
522
+ ...(task.runMode === "existing_session"
523
+ ? { targetSessionId: task.targetSessionId }
524
+ : { reusableSessionId: task.reusableSessionId }),
329
525
  variableSetId: task.variableSetId,
330
526
  rigId: task.rigId,
331
527
  metadata: task.metadata,
332
528
  });
333
529
  }
334
530
 
531
+ export class ScheduledTaskSyncError extends Error {
532
+ readonly persistenceRestored: boolean;
533
+
534
+ constructor(cause: unknown, persistenceRestored: boolean) {
535
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
536
+ this.name = "ScheduledTaskSyncError";
537
+ this.persistenceRestored = persistenceRestored;
538
+ }
539
+ }
540
+
335
541
  export async function syncCreatedScheduledTask(input: {
336
542
  db: Database;
337
543
  workflowClient: SessionWorkflowClient;
@@ -340,10 +546,13 @@ export async function syncCreatedScheduledTask(input: {
340
546
  try {
341
547
  await input.workflowClient.syncScheduledTask({ task: input.task });
342
548
  } catch (error) {
343
- await deleteScheduledTask(input.db, input.task.workspaceId, input.task.id).catch(
344
- () => undefined,
345
- );
346
- throw error;
549
+ let persistenceRestored = true;
550
+ try {
551
+ await deleteScheduledTask(input.db, input.task.workspaceId, input.task.id);
552
+ } catch {
553
+ persistenceRestored = false;
554
+ }
555
+ throw new ScheduledTaskSyncError(error, persistenceRestored);
347
556
  }
348
557
  }
349
558
 
@@ -356,8 +565,13 @@ export async function syncUpdatedScheduledTask(input: {
356
565
  try {
357
566
  await input.workflowClient.syncScheduledTask({ task: input.task });
358
567
  } catch (error) {
359
- await restoreScheduledTask(input.db, input.previous).catch(() => undefined);
360
- throw error;
568
+ let persistenceRestored = true;
569
+ try {
570
+ await restoreScheduledTask(input.db, input.previous);
571
+ } catch {
572
+ persistenceRestored = false;
573
+ }
574
+ throw new ScheduledTaskSyncError(error, persistenceRestored);
361
575
  }
362
576
  }
363
577
 
@@ -506,7 +506,22 @@ function validateSessionMcpCredentialUpdates(input: {
506
506
  return encryptedUpdates;
507
507
  }
508
508
 
509
- export async function createAndStartSession(input: {
509
+ export type CreateSessionOutcome = {
510
+ session: CreateSessionResponse;
511
+ /** The committed create/start effect represented by this request. */
512
+ outcome: "created" | "repaired" | "replayed";
513
+ /** Backward-compatible replay flag for existing entity-oriented callers. */
514
+ replay: boolean;
515
+ /** True when the request created/repaired start state or committed a new wake revision. */
516
+ changed: boolean;
517
+ };
518
+
519
+ export type CreateSessionRequestOutcome = CreateSessionOutcome & {
520
+ /** Billing telemetry is recorded after the committed session start. */
521
+ usageRecording: "recorded" | "failed";
522
+ };
523
+
524
+ export async function createAndStartSessionWithOutcome(input: {
510
525
  requestedSessionId?: string;
511
526
  db: Database;
512
527
  bus: EventBus;
@@ -597,7 +612,7 @@ export async function createAndStartSession(input: {
597
612
  maxNestedAgentDepthOverride?: number | null;
598
613
  allowNestedAgentDepthIncrease?: boolean;
599
614
  subjectId?: string | null;
600
- }): Promise<CreateSessionResponse> {
615
+ }): Promise<CreateSessionOutcome> {
601
616
  const sessionMetadata = {
602
617
  ...input.metadata,
603
618
  model: input.model,
@@ -647,12 +662,24 @@ export async function createAndStartSession(input: {
647
662
  }
648
663
  const { session: keyed, created } = keyedResult;
649
664
  if (!created) {
650
- return await finishStartSession(
665
+ const finished = await finishStartSession(
651
666
  keyed.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
652
667
  keyed,
653
668
  );
669
+ return {
670
+ session: finished.session,
671
+ outcome: finished.changed ? "repaired" : "replayed",
672
+ replay: !finished.changed,
673
+ changed: finished.changed,
674
+ };
654
675
  }
655
- return await finishStartSession(input, keyed);
676
+ const finished = await finishStartSession(input, keyed);
677
+ return {
678
+ session: finished.session,
679
+ outcome: "created",
680
+ replay: false,
681
+ changed: true,
682
+ };
656
683
  }
657
684
  let session: Session;
658
685
  try {
@@ -694,7 +721,20 @@ export async function createAndStartSession(input: {
694
721
  }
695
722
  throw error;
696
723
  }
697
- return await finishStartSession(input, session);
724
+ const finished = await finishStartSession(input, session);
725
+ return {
726
+ session: finished.session,
727
+ outcome: "created",
728
+ replay: false,
729
+ changed: true,
730
+ };
731
+ }
732
+
733
+ /** Backward-compatible entity-returning create path used by existing callers. */
734
+ export async function createAndStartSession(
735
+ input: Parameters<typeof createAndStartSessionWithOutcome>[0],
736
+ ): Promise<CreateSessionResponse> {
737
+ return (await createAndStartSessionWithOutcome(input)).session;
698
738
  }
699
739
 
700
740
  /**
@@ -730,7 +770,7 @@ async function finishStartSession(
730
770
  consumeNewSessionDraft?: { subjectId: string; expectedRevision: number } | null;
731
771
  },
732
772
  session: Session,
733
- ): Promise<CreateSessionResponse> {
773
+ ): Promise<{ session: CreateSessionResponse; changed: boolean }> {
734
774
  // Create-time machine targeting (A-2a): seed the active-sandbox pointer BEFORE
735
775
  // the atomic initial turn transaction, so the FIRST turn routes to the chosen
736
776
  // machine. swapActiveSandbox does
@@ -806,7 +846,10 @@ async function finishStartSession(
806
846
  started.turn?.id ??
807
847
  (await listSessionTurns(input.db, session.workspaceId, session.id, 1))[0]?.id ??
808
848
  null;
809
- return { ...persisted, initialTurnId };
849
+ return {
850
+ session: { ...persisted, initialTurnId },
851
+ changed: started.changed,
852
+ };
810
853
  }
811
854
 
812
855
  export function workflowIdForSession(sessionId: string): string {
@@ -997,7 +1040,7 @@ export async function postUserMessageTurn(input: {
997
1040
  expectedDraftRevision?: number | null;
998
1041
  reasoningEffortFallback?: Settings["openaiReasoningEffort"];
999
1042
  turnExecutionPolicy: TurnExecutionPolicyV1;
1000
- }): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
1043
+ }): Promise<{ accepted: SessionEvent; turn: SessionTurn; replay: boolean }> {
1001
1044
  const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
1002
1045
  const requestedModel = canonicalConfiguredModel(settings, input.model ?? null) ?? null;
1003
1046
  const requestedReasoningEffort = input.reasoningEffort ?? null;
@@ -1103,13 +1146,14 @@ export async function postUserMessageTurn(input: {
1103
1146
  ? { interruptionRequested: true }
1104
1147
  : {}),
1105
1148
  });
1106
- } catch (error) {
1107
- console.warn(
1108
- `[sessions] workflow wake failed for committed prompt ${workspaceId}/${sessionId}; durable outbox will retry`,
1109
- error,
1110
- );
1149
+ } catch {
1150
+ console.warn("[sessions] workflow wake failed; durable outbox will retry", {
1151
+ errorClass: "WorkflowWakeOperationError",
1152
+ errorCode: "session_workflow_wake_failed",
1153
+ origin: "core",
1154
+ });
1111
1155
  }
1112
- return { accepted, turn };
1156
+ return { accepted, turn, replay: result.replay };
1113
1157
  }
1114
1158
 
1115
1159
  /**
@@ -1121,12 +1165,12 @@ export async function postUserMessageTurn(input: {
1121
1165
  * trusted immediate parent, while explicit arrays (including []) win. A
1122
1166
  * top-level create with omitted tools applies workspace-default capability MCPs.
1123
1167
  */
1124
- export async function createSessionForRequest(
1168
+ export async function createSessionForRequestWithOutcome(
1125
1169
  deps: ApiRouteDeps,
1126
1170
  grant: AccessGrant,
1127
1171
  workspaceId: string,
1128
1172
  rawPayload: unknown,
1129
- ): Promise<Session> {
1173
+ ): Promise<CreateSessionRequestOutcome> {
1130
1174
  const { settings, db, bus, workflowClient, objectStorage } = deps;
1131
1175
  const payload = CreateSessionRequest.parse(rawPayload);
1132
1176
  if (hasReservedOpenGeniSlackBotSessionMetadata(payload.metadata)) {
@@ -1621,9 +1665,9 @@ export async function createSessionForRequest(
1621
1665
  });
1622
1666
  }
1623
1667
  const creationInitiator = creationInitiatorForGrant(grant);
1624
- let session: CreateSessionResponse;
1668
+ let createOutcome: CreateSessionOutcome;
1625
1669
  try {
1626
- session = await createAndStartSession({
1670
+ createOutcome = await createAndStartSessionWithOutcome({
1627
1671
  ...(payload.requestedSessionId ? { requestedSessionId: payload.requestedSessionId } : {}),
1628
1672
  db,
1629
1673
  bus,
@@ -1704,24 +1748,52 @@ export async function createSessionForRequest(
1704
1748
  }
1705
1749
  throw error;
1706
1750
  }
1751
+ let usageRecording: CreateSessionRequestOutcome["usageRecording"] = "recorded";
1707
1752
  if (payload.startMode !== "realtime") {
1708
- await recordWorkspaceUsage(deps, {
1709
- accountId: grant.accountId,
1710
- workspaceId,
1711
- subjectId: grant.subjectId,
1712
- eventType: "agent_run.created",
1713
- quantity: 1,
1714
- unit: "run",
1715
- sourceResourceType: "session",
1716
- sourceResourceId: session.id,
1717
- sessionId: session.id,
1718
- initiator: session.createdBy,
1719
- initiatorContext: session.createdByContext,
1720
- origin: creationInitiator.actor ? "system" : "user",
1721
- idempotencyKey: `agent_run.created:${workspaceId}:${session.id}`,
1722
- });
1753
+ try {
1754
+ await recordWorkspaceUsage(deps, {
1755
+ accountId: grant.accountId,
1756
+ workspaceId,
1757
+ subjectId: grant.subjectId,
1758
+ eventType: "agent_run.created",
1759
+ quantity: 1,
1760
+ unit: "run",
1761
+ sourceResourceType: "session",
1762
+ sourceResourceId: createOutcome.session.id,
1763
+ sessionId: createOutcome.session.id,
1764
+ initiator: createOutcome.session.createdBy,
1765
+ initiatorContext: createOutcome.session.createdByContext,
1766
+ origin: creationInitiator.actor ? "system" : "user",
1767
+ idempotencyKey: `agent_run.created:${workspaceId}:${createOutcome.session.id}`,
1768
+ });
1769
+ } catch (error) {
1770
+ usageRecording = "failed";
1771
+ reportSessionUsageRecordingFailure(error);
1772
+ }
1723
1773
  }
1724
- return session;
1774
+ return { ...createOutcome, usageRecording };
1775
+ }
1776
+
1777
+ /** @internal Fixed public projection; the committed session outcome remains authoritative. */
1778
+ export function reportSessionUsageRecordingFailure(_error: unknown): void {
1779
+ console.warn(
1780
+ "[sessions] usage recording failed after committed session create; returning committed outcome",
1781
+ {
1782
+ errorClass: "UsageRecordingError",
1783
+ errorCode: "session_create_usage_recording_failed",
1784
+ origin: "core",
1785
+ },
1786
+ );
1787
+ }
1788
+
1789
+ /** Backward-compatible entity-returning request path for REST and core callers. */
1790
+ export async function createSessionForRequest(
1791
+ deps: ApiRouteDeps,
1792
+ grant: AccessGrant,
1793
+ workspaceId: string,
1794
+ rawPayload: unknown,
1795
+ ): Promise<CreateSessionResponse> {
1796
+ return (await createSessionForRequestWithOutcome(deps, grant, workspaceId, rawPayload)).session;
1725
1797
  }
1726
1798
 
1727
1799
  /**
@@ -1731,7 +1803,7 @@ export async function createSessionForRequest(
1731
1803
  * enqueue, and usage recording. `toolsProvided: false` durably preserves an
1732
1804
  * Tool selection is durable session state and never rides a follow-up prompt.
1733
1805
  */
1734
- export async function acceptSessionUserMessage(
1806
+ export async function acceptSessionUserMessageWithOutcome(
1735
1807
  deps: AcceptSessionUserMessageDependencies,
1736
1808
  grant: AccessGrant,
1737
1809
  workspaceId: string,
@@ -1750,7 +1822,7 @@ export async function acceptSessionUserMessage(
1750
1822
  controlEtag?: string | null;
1751
1823
  expectedDraftRevision?: number | null;
1752
1824
  },
1753
- ): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
1825
+ ): Promise<{ accepted: SessionEvent; turn: SessionTurn; replay: boolean }> {
1754
1826
  const { settings, db, bus, workflowClient, objectStorage } = deps;
1755
1827
  await requireSessionAuthorization(deps, grant, {
1756
1828
  sessionId,
@@ -1822,7 +1894,7 @@ export async function acceptSessionUserMessage(
1822
1894
  source: personalConnectionDelegationSourceForGrant(grant),
1823
1895
  });
1824
1896
  const delegatedServiceInitiator = serviceInitiatorForGrant(grant);
1825
- const { accepted, turn } = await postUserMessageTurn({
1897
+ const { accepted, turn, replay } = await postUserMessageTurn({
1826
1898
  db,
1827
1899
  bus,
1828
1900
  workflowClient,
@@ -1878,6 +1950,24 @@ export async function acceptSessionUserMessage(
1878
1950
  origin: turn.source,
1879
1951
  idempotencyKey: `agent_run.created:${workspaceId}:${turn.id}`,
1880
1952
  });
1953
+ return { accepted, turn, replay };
1954
+ }
1955
+
1956
+ /** Backward-compatible entity-returning path used by existing REST callers. */
1957
+ export async function acceptSessionUserMessage(
1958
+ deps: Parameters<typeof acceptSessionUserMessageWithOutcome>[0],
1959
+ grant: Parameters<typeof acceptSessionUserMessageWithOutcome>[1],
1960
+ workspaceId: Parameters<typeof acceptSessionUserMessageWithOutcome>[2],
1961
+ sessionId: Parameters<typeof acceptSessionUserMessageWithOutcome>[3],
1962
+ input: Parameters<typeof acceptSessionUserMessageWithOutcome>[4],
1963
+ ): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
1964
+ const { accepted, turn } = await acceptSessionUserMessageWithOutcome(
1965
+ deps,
1966
+ grant,
1967
+ workspaceId,
1968
+ sessionId,
1969
+ input,
1970
+ );
1881
1971
  return { accepted, turn };
1882
1972
  }
1883
1973
 
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from "./workflow-wake-contract";
35
35
  // structural TYPES live here.
36
36
  export * from "./sandbox-types";
37
37
  export * from "./managed-auth-type";
38
+ export * from "./managed-session";
38
39
  export * from "./transcription";
39
40
 
40
41
  // Sandbox fleet/routing service — the closure of `domain/sessions.ts`
@@ -1,9 +1,10 @@
1
1
  // @opengeni/core ManagedAuth TYPE alias.
2
2
  //
3
3
  // WHY THIS MODULE LIVES IN CORE: `dependencies.ts` carries a `managedAuth?:
4
- // ManagedAuth | null` passthrough slot and the access layer calls
5
- // `managedAuth.api.getSession({ headers })`. Both are framework-agnostic, so
6
- // they belong in @opengeni/core. The CONSTRUCTION of a real Better Auth
4
+ // ManagedAuth | null` passthrough slot and the access layer resolves managed
5
+ // sessions through the cookie-renewing bridge in `managed-session.ts`. The
6
+ // auth type is framework-agnostic, so it belongs in @opengeni/core. The
7
+ // CONSTRUCTION of a real Better Auth
7
8
  // instance — `createManagedAuth`, which opens its own `pg.Pool` and wires
8
9
  // Resend — stays in `apps/api/src/auth/managed-auth.ts` (it pulls the `pg`
9
10
  // driver, which must NEVER enter @opengeni/core).
@@ -0,0 +1,36 @@
1
+ import type { Context } from "hono";
2
+ import type { ManagedAuth } from "./managed-auth-type";
3
+
4
+ /**
5
+ * Read a Better Auth session without bypassing its sliding-cookie renewal.
6
+ *
7
+ * Better Auth can refresh the durable session while resolving `getSession`.
8
+ * Programmatic callers must explicitly request and forward the returned cookie
9
+ * headers; the HTTP handler does this automatically, but direct API calls do not.
10
+ */
11
+ export async function getManagedSession(c: Context, auth: ManagedAuth) {
12
+ const result = await auth.api.getSession({
13
+ headers: c.req.raw.headers,
14
+ returnHeaders: true,
15
+ });
16
+
17
+ for (const cookie of setCookieHeaders(result.headers)) {
18
+ c.header("set-cookie", cookie, { append: true });
19
+ }
20
+
21
+ return result.response;
22
+ }
23
+
24
+ function setCookieHeaders(headers: Headers): string[] {
25
+ const getSetCookie = (
26
+ headers as Headers & {
27
+ getSetCookie?: () => string[];
28
+ }
29
+ ).getSetCookie;
30
+ if (getSetCookie) {
31
+ return getSetCookie.call(headers);
32
+ }
33
+
34
+ const cookie = headers.get("set-cookie");
35
+ return cookie ? [cookie] : [];
36
+ }