@opengeni/api-router 0.16.5 → 0.20.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.
@@ -1,11 +1,16 @@
1
1
  import {
2
2
  AcknowledgeStreamRequest,
3
+ ActivateCodexRealtimeConnectionRequest,
3
4
  AttachViewerRequest,
5
+ BeginSessionRealtimeRequest,
4
6
  ClearSessionContextRequest,
7
+ CodexRealtimeWebrtcRequest,
8
+ GatewayRealtimeConnectRequest,
5
9
  ClientSessionEvent,
6
10
  CompactSessionContextRequest,
7
11
  DeleteSessionQueueItemRequest,
8
12
  EditSessionQueueItemRequest,
13
+ EndSessionRealtimeRequest,
9
14
  FsDeleteRequest,
10
15
  FsListRequest,
11
16
  FsMkdirRequest,
@@ -22,6 +27,8 @@ import {
22
27
  PtyOpenRequest,
23
28
  PtyResizeRequest,
24
29
  PtyWriteRequest,
30
+ RenewSessionRealtimeRequest,
31
+ SyncSessionRealtimeLedgerRequest,
25
32
  SessionControlRequest,
26
33
  SESSION_EVENT_RAW_DELTA_TYPES,
27
34
  SessionEventPayloadMode,
@@ -49,6 +56,7 @@ import {
49
56
  type SandboxBackend,
50
57
  type LineageNode,
51
58
  type Session,
59
+ type ErrorCode,
52
60
  type SessionAuthorizationOperation,
53
61
  type SessionQueueSnapshot,
54
62
  type TerminalPtyExitedPayload,
@@ -65,6 +73,7 @@ import {
65
73
  getRetainedProcess,
66
74
  getSandbox,
67
75
  getSession,
76
+ getSessionEvent,
68
77
  getSessionForSubject,
69
78
  getSessionGoal,
70
79
  getSessionHumanInputRequest,
@@ -96,19 +105,30 @@ import {
96
105
  setSessionGoalStatusWithEvent,
97
106
  updatePtySessionActivity,
98
107
  QueueCommandConflictError,
108
+ beginSessionRealtimeInTransaction,
109
+ activateSessionRealtimeConnectionInTransaction,
110
+ claimSessionRealtimeConnectionInTransaction,
111
+ completeSessionRealtimeConnectionInTransaction,
112
+ endSessionRealtimeInTransaction,
113
+ failSessionRealtimeConnectionInTransaction,
99
114
  NewSessionDraftConflictError,
100
115
  SessionCommandIdempotencyError,
101
116
  SessionControlConflictError,
117
+ SessionRealtimeConflictError,
102
118
  SessionToolPolicyVersionConflictError,
103
119
  SessionContextBusyError,
104
120
  HumanInputResponseValidationError,
105
121
  latestWorkspaceCapture,
106
122
  sessionLatestWorkspaceCapture,
123
+ renewSessionRealtimeInTransaction,
124
+ syncSessionRealtimeLedgerInTransaction,
125
+ withWorkspaceRls,
107
126
  workspaceCaptureAtRevision,
108
127
  type AppendEventInput,
109
128
  type SandboxOpenPtySessionRow,
110
129
  type SandboxPtyProcessIdentity,
111
130
  type SandboxRetainedProcess,
131
+ type Database,
112
132
  } from "@opengeni/db";
113
133
  import {
114
134
  appendAndPublishEvents,
@@ -116,11 +136,16 @@ import {
116
136
  coalesceSessionEventDeltas,
117
137
  publishDurableSessionEvents,
118
138
  } from "@opengeni/events";
139
+ import {
140
+ createGatewayRealtimeConnectionSecret,
141
+ GatewayRealtimeBrokerError,
142
+ } from "../gateway-realtime";
119
143
  import { z, ZodError } from "zod";
120
144
  import { withChannelA, type ChannelAContext, type ChannelAHandle } from "../sandbox/channel-a";
121
145
  import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
122
146
  import type { Context, Hono, MiddlewareHandler } from "hono";
123
147
  import { HTTPException } from "hono/http-exception";
148
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
124
149
  import {
125
150
  requireAccessGrant,
126
151
  requireSessionAuthorization,
@@ -144,6 +169,7 @@ import {
144
169
  type TerminalStreamMint,
145
170
  type ViewerServices,
146
171
  } from "../sandbox/viewer";
172
+ import { buildSessionCodexRealtimeBroker, CodexRealtimeBrokerError } from "../codex-realtime";
147
173
  import {
148
174
  acceptSessionUserMessage,
149
175
  controlHumanSessionWorkstream,
@@ -517,6 +543,507 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
517
543
  return c.json(await withEffectivePolicy(deps, workspaceId, session));
518
544
  });
519
545
 
546
+ const publishRealtimeMutation = async (
547
+ accountId: string,
548
+ workspaceId: string,
549
+ sessionId: string,
550
+ result: {
551
+ eventIds: string[];
552
+ workflowWakeRevision: number | null;
553
+ },
554
+ ): Promise<void> => {
555
+ const events = (
556
+ await Promise.all(result.eventIds.map((eventId) => getSessionEvent(db, workspaceId, eventId)))
557
+ ).filter((event) => event !== null);
558
+ await publishDurableSessionEvents(bus, workspaceId, sessionId, events);
559
+ if (result.workflowWakeRevision !== null) {
560
+ await workflowClient.wakeSessionWorkflow({
561
+ accountId,
562
+ workspaceId,
563
+ sessionId,
564
+ workflowId: workflowIdForSession(sessionId),
565
+ wakeRevision: result.workflowWakeRevision,
566
+ });
567
+ }
568
+ };
569
+
570
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/realtime", async (c) => {
571
+ const workspaceId = c.req.param("workspaceId");
572
+ const sessionId = c.req.param("sessionId");
573
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
574
+ if (!z.string().uuid().safeParse(sessionId).success) {
575
+ throw new HTTPException(400, { message: "invalid session id" });
576
+ }
577
+ const parsed = BeginSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
578
+ if (!parsed.success) {
579
+ throw new HTTPException(400, { message: "invalid session realtime request" });
580
+ }
581
+ try {
582
+ const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
583
+ scopedDb.transaction(async (tx) =>
584
+ beginSessionRealtimeInTransaction(tx as unknown as Database, {
585
+ accountId: grant.accountId,
586
+ workspaceId,
587
+ sessionId,
588
+ ownerSubjectId: grant.subjectId,
589
+ ...parsed.data,
590
+ }),
591
+ ),
592
+ );
593
+ await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
594
+ c.header("cache-control", "private, no-store");
595
+ return c.json({ mode: result.mode, replay: result.replay }, result.replay ? 200 : 201);
596
+ } catch (error) {
597
+ throw sessionRealtimeHttpError(error);
598
+ }
599
+ });
600
+
601
+ app.patch(
602
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/:realtimeId/heartbeat",
603
+ async (c) => {
604
+ const workspaceId = c.req.param("workspaceId");
605
+ const sessionId = c.req.param("sessionId");
606
+ const realtimeId = c.req.param("realtimeId");
607
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
608
+ if (
609
+ !z.string().uuid().safeParse(sessionId).success ||
610
+ !z.string().uuid().safeParse(realtimeId).success
611
+ ) {
612
+ throw new HTTPException(400, { message: "invalid realtime lifecycle id" });
613
+ }
614
+ const parsed = RenewSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
615
+ if (!parsed.success) {
616
+ throw new HTTPException(400, { message: "invalid realtime heartbeat request" });
617
+ }
618
+ try {
619
+ const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
620
+ scopedDb.transaction(async (tx) =>
621
+ renewSessionRealtimeInTransaction(tx as unknown as Database, {
622
+ workspaceId,
623
+ sessionId,
624
+ realtimeId,
625
+ ownerSubjectId: grant.subjectId,
626
+ ...parsed.data,
627
+ }),
628
+ ),
629
+ );
630
+ await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
631
+ c.header("cache-control", "private, no-store");
632
+ return c.json({ mode: result.mode, replay: result.replay });
633
+ } catch (error) {
634
+ throw sessionRealtimeHttpError(error);
635
+ }
636
+ },
637
+ );
638
+
639
+ app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/:realtimeId", async (c) => {
640
+ const workspaceId = c.req.param("workspaceId");
641
+ const sessionId = c.req.param("sessionId");
642
+ const realtimeId = c.req.param("realtimeId");
643
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
644
+ if (
645
+ !z.string().uuid().safeParse(sessionId).success ||
646
+ !z.string().uuid().safeParse(realtimeId).success
647
+ ) {
648
+ throw new HTTPException(400, { message: "invalid realtime lifecycle id" });
649
+ }
650
+ const parsed = EndSessionRealtimeRequest.safeParse(await c.req.json().catch(() => null));
651
+ if (!parsed.success) {
652
+ throw new HTTPException(400, { message: "invalid realtime end request" });
653
+ }
654
+ try {
655
+ const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
656
+ scopedDb.transaction(async (tx) =>
657
+ endSessionRealtimeInTransaction(tx as unknown as Database, {
658
+ workspaceId,
659
+ sessionId,
660
+ realtimeId,
661
+ ownerSubjectId: grant.subjectId,
662
+ ...parsed.data,
663
+ }),
664
+ ),
665
+ );
666
+ await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
667
+ c.header("cache-control", "private, no-store");
668
+ return c.json({ mode: result.mode, replay: result.replay });
669
+ } catch (error) {
670
+ throw sessionRealtimeHttpError(error);
671
+ }
672
+ });
673
+
674
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/webrtc", async (c) => {
675
+ const workspaceId = c.req.param("workspaceId");
676
+ const sessionId = c.req.param("sessionId");
677
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
678
+ if (!z.string().uuid().safeParse(sessionId).success) {
679
+ throw new HTTPException(404, { message: "session not found" });
680
+ }
681
+ const parsed = CodexRealtimeWebrtcRequest.safeParse(await c.req.json().catch(() => null));
682
+ if (!parsed.success) {
683
+ throw new HTTPException(422, {
684
+ message: "invalid Codex realtime WebRTC request",
685
+ });
686
+ }
687
+
688
+ c.header("cache-control", "private, no-store");
689
+ try {
690
+ const {
691
+ realtimeId,
692
+ operationId,
693
+ browserInstanceId,
694
+ ownerKey,
695
+ expectedVersion,
696
+ expectedConnectionEpoch,
697
+ rotate,
698
+ browserActivation,
699
+ ...providerRequest
700
+ } = parsed.data;
701
+ const claim = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
702
+ scopedDb.transaction(async (tx) =>
703
+ claimSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
704
+ workspaceId,
705
+ sessionId,
706
+ realtimeId,
707
+ operationId,
708
+ ownerSubjectId: grant.subjectId,
709
+ browserInstanceId,
710
+ ownerKey,
711
+ expectedVersion,
712
+ expectedConnectionEpoch,
713
+ rotate,
714
+ promotionMode: browserActivation === "required" ? "staged" : "legacy",
715
+ }),
716
+ ),
717
+ );
718
+ if (claim.replay) {
719
+ if (
720
+ (claim.connection.state !== "ready" && claim.connection.state !== "active") ||
721
+ !claim.connection.sdpAnswer
722
+ ) {
723
+ throw new SessionRealtimeConflictError(
724
+ "REALTIME_CONNECTION_STATE_CHANGED",
725
+ "Realtime connection operation cannot be replayed; rotate with a new operation",
726
+ );
727
+ }
728
+ const legacyActivation =
729
+ browserActivation !== "required" && claim.connection.state === "ready"
730
+ ? await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
731
+ scopedDb.transaction(async (tx) =>
732
+ activateSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
733
+ workspaceId,
734
+ sessionId,
735
+ realtimeId,
736
+ connectionId: claim.connection.id,
737
+ operationId,
738
+ ownerSubjectId: grant.subjectId,
739
+ browserInstanceId,
740
+ ownerKey,
741
+ expectedVersion,
742
+ expectedConnectionEpoch,
743
+ connectionEpoch: claim.connection.connectionEpoch,
744
+ }),
745
+ ),
746
+ )
747
+ : null;
748
+ return c.json({
749
+ sdp: claim.connection.sdpAnswer,
750
+ version: "v3" as const,
751
+ model: "gpt-live-1-boulder-alpha" as const,
752
+ connectionId: claim.connection.id,
753
+ connectionEpoch: claim.connection.connectionEpoch,
754
+ startupFenceSequence: claim.connection.startupFenceSequence,
755
+ modeVersion: legacyActivation?.mode.version ?? claim.modeVersion,
756
+ replay: true,
757
+ });
758
+ }
759
+ const broker = buildSessionCodexRealtimeBroker(
760
+ db,
761
+ settings,
762
+ workspaceId,
763
+ sessionId,
764
+ deps.codexFetch,
765
+ );
766
+ try {
767
+ const answer = await broker({ request: providerRequest, signal: c.req.raw.signal });
768
+ const completed = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
769
+ scopedDb.transaction(async (tx) =>
770
+ completeSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
771
+ workspaceId,
772
+ sessionId,
773
+ realtimeId,
774
+ connectionId: claim.connection.id,
775
+ operationId,
776
+ connectionEpoch: claim.connection.connectionEpoch,
777
+ sdpAnswer: answer.sdp,
778
+ }),
779
+ ),
780
+ );
781
+ const legacyActivation =
782
+ browserActivation !== "required"
783
+ ? await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
784
+ scopedDb.transaction(async (tx) =>
785
+ activateSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
786
+ workspaceId,
787
+ sessionId,
788
+ realtimeId,
789
+ connectionId: completed.connection.id,
790
+ operationId,
791
+ ownerSubjectId: grant.subjectId,
792
+ browserInstanceId,
793
+ ownerKey,
794
+ expectedVersion,
795
+ expectedConnectionEpoch,
796
+ connectionEpoch: completed.connection.connectionEpoch,
797
+ }),
798
+ ),
799
+ )
800
+ : null;
801
+ return c.json({
802
+ ...answer,
803
+ connectionId: completed.connection.id,
804
+ connectionEpoch: completed.connection.connectionEpoch,
805
+ startupFenceSequence: completed.connection.startupFenceSequence,
806
+ modeVersion: legacyActivation?.mode.version ?? claim.modeVersion,
807
+ replay: false,
808
+ });
809
+ } catch (error) {
810
+ if (error instanceof CodexRealtimeBrokerError) {
811
+ await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
812
+ scopedDb.transaction(async (tx) =>
813
+ failSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
814
+ workspaceId,
815
+ sessionId,
816
+ realtimeId,
817
+ connectionId: claim.connection.id,
818
+ operationId,
819
+ connectionEpoch: claim.connection.connectionEpoch,
820
+ failureCode: error.reason,
821
+ }),
822
+ ),
823
+ ).catch(() => undefined);
824
+ }
825
+ throw error;
826
+ }
827
+ } catch (error) {
828
+ if (error instanceof SessionRealtimeConflictError) {
829
+ throw sessionRealtimeHttpError(error);
830
+ }
831
+ if (!(error instanceof CodexRealtimeBrokerError)) throw error;
832
+ const failure = codexRealtimeHttpFailure(error);
833
+ return c.json(
834
+ {
835
+ error: {
836
+ status: failure.status,
837
+ code: failure.code,
838
+ message: error.message,
839
+ retryable: failure.retryable,
840
+ details: {
841
+ reason: error.reason,
842
+ ...(error.providerStatus === null ? {} : { providerStatus: error.providerStatus }),
843
+ },
844
+ },
845
+ },
846
+ failure.status,
847
+ );
848
+ }
849
+ });
850
+
851
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/gateway", async (c) => {
852
+ const workspaceId = c.req.param("workspaceId");
853
+ const sessionId = c.req.param("sessionId");
854
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
855
+ if (!z.string().uuid().safeParse(sessionId).success) {
856
+ throw new HTTPException(404, { message: "session not found" });
857
+ }
858
+ const parsed = GatewayRealtimeConnectRequest.safeParse(await c.req.json().catch(() => null));
859
+ if (!parsed.success) {
860
+ throw new HTTPException(422, { message: "invalid Gateway realtime request" });
861
+ }
862
+ c.header("cache-control", "private, no-store");
863
+ const {
864
+ realtimeId,
865
+ operationId,
866
+ browserInstanceId,
867
+ ownerKey,
868
+ expectedVersion,
869
+ expectedConnectionEpoch,
870
+ rotate,
871
+ } = parsed.data;
872
+ let claim: Awaited<ReturnType<typeof claimSessionRealtimeConnectionInTransaction>> | null =
873
+ null;
874
+ let connectionCompleted = false;
875
+ try {
876
+ claim = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
877
+ scopedDb.transaction(async (tx) =>
878
+ claimSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
879
+ workspaceId,
880
+ sessionId,
881
+ realtimeId,
882
+ operationId,
883
+ ownerSubjectId: grant.subjectId,
884
+ browserInstanceId,
885
+ ownerKey,
886
+ expectedVersion,
887
+ expectedConnectionEpoch,
888
+ rotate,
889
+ promotionMode: "staged",
890
+ }),
891
+ ),
892
+ );
893
+ if (claim.replay) {
894
+ throw new SessionRealtimeConflictError(
895
+ "REALTIME_CONNECTION_STATE_CHANGED",
896
+ "Realtime Gateway tokens are single-use; reconnect with a new operation",
897
+ );
898
+ }
899
+ const secret = await createGatewayRealtimeConnectionSecret({
900
+ db,
901
+ settings,
902
+ workspaceId,
903
+ sessionId,
904
+ model: claim.mode.model,
905
+ fetchImpl: deps.codexFetch ?? fetch,
906
+ });
907
+ const claimed = claim;
908
+ const completed = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
909
+ scopedDb.transaction(async (tx) =>
910
+ completeSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
911
+ workspaceId,
912
+ sessionId,
913
+ realtimeId,
914
+ connectionId: claimed.connection.id,
915
+ operationId,
916
+ connectionEpoch: claimed.connection.connectionEpoch,
917
+ sdpAnswer: "gateway-client-secret-minted",
918
+ }),
919
+ ),
920
+ );
921
+ connectionCompleted = true;
922
+ return c.json({
923
+ ...secret,
924
+ connectionId: completed.connection.id,
925
+ connectionEpoch: completed.connection.connectionEpoch,
926
+ startupFenceSequence: completed.connection.startupFenceSequence,
927
+ modeVersion: claimed.modeVersion,
928
+ replay: false as const,
929
+ });
930
+ } catch (error) {
931
+ if (claim !== null && !claim.replay && !connectionCompleted) {
932
+ const claimed = claim;
933
+ await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
934
+ scopedDb.transaction(async (tx) =>
935
+ failSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
936
+ workspaceId,
937
+ sessionId,
938
+ realtimeId,
939
+ connectionId: claimed.connection.id,
940
+ operationId,
941
+ connectionEpoch: claimed.connection.connectionEpoch,
942
+ failureCode:
943
+ error instanceof GatewayRealtimeBrokerError ? error.code : "gateway_error",
944
+ }),
945
+ ),
946
+ ).catch(() => undefined);
947
+ }
948
+ if (error instanceof SessionRealtimeConflictError) throw sessionRealtimeHttpError(error);
949
+ if (!(error instanceof GatewayRealtimeBrokerError)) throw error;
950
+ const status = error.code === "credential_unavailable" ? 409 : 502;
951
+ return c.json(
952
+ {
953
+ error: {
954
+ status,
955
+ code: `GATEWAY_REALTIME_${error.code.toUpperCase()}`,
956
+ message: error.message,
957
+ retryable: error.code === "provider_error",
958
+ },
959
+ },
960
+ status,
961
+ );
962
+ }
963
+ });
964
+
965
+ app.post(
966
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/:realtimeId/connections/:connectionId/activate",
967
+ async (c) => {
968
+ const workspaceId = c.req.param("workspaceId");
969
+ const sessionId = c.req.param("sessionId");
970
+ const realtimeId = c.req.param("realtimeId");
971
+ const connectionId = c.req.param("connectionId");
972
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
973
+ if (
974
+ !z.string().uuid().safeParse(sessionId).success ||
975
+ !z.string().uuid().safeParse(realtimeId).success ||
976
+ !z.string().uuid().safeParse(connectionId).success
977
+ ) {
978
+ throw new HTTPException(400, { message: "invalid realtime connection id" });
979
+ }
980
+ const parsed = ActivateCodexRealtimeConnectionRequest.safeParse(
981
+ await c.req.json().catch(() => null),
982
+ );
983
+ if (!parsed.success) {
984
+ throw new HTTPException(422, { message: "invalid realtime connection activation" });
985
+ }
986
+ try {
987
+ const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
988
+ scopedDb.transaction(async (tx) =>
989
+ activateSessionRealtimeConnectionInTransaction(tx as unknown as Database, {
990
+ workspaceId,
991
+ sessionId,
992
+ realtimeId,
993
+ connectionId,
994
+ ownerSubjectId: grant.subjectId,
995
+ ...parsed.data,
996
+ }),
997
+ ),
998
+ );
999
+ c.header("cache-control", "private, no-store");
1000
+ return c.json({ mode: result.mode, replay: result.replay });
1001
+ } catch (error) {
1002
+ throw sessionRealtimeHttpError(error);
1003
+ }
1004
+ },
1005
+ );
1006
+
1007
+ app.post(
1008
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/realtime/:realtimeId/sync",
1009
+ async (c) => {
1010
+ const workspaceId = c.req.param("workspaceId");
1011
+ const sessionId = c.req.param("sessionId");
1012
+ const realtimeId = c.req.param("realtimeId");
1013
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1014
+ if (
1015
+ !z.string().uuid().safeParse(sessionId).success ||
1016
+ !z.string().uuid().safeParse(realtimeId).success
1017
+ ) {
1018
+ throw new HTTPException(400, { message: "invalid realtime ledger id" });
1019
+ }
1020
+ const parsed = SyncSessionRealtimeLedgerRequest.safeParse(
1021
+ await c.req.json().catch(() => null),
1022
+ );
1023
+ if (!parsed.success) {
1024
+ throw new HTTPException(422, { message: "invalid realtime ledger sync request" });
1025
+ }
1026
+ try {
1027
+ const result = await withWorkspaceRls(db, workspaceId, async (scopedDb) =>
1028
+ scopedDb.transaction(async (tx) =>
1029
+ syncSessionRealtimeLedgerInTransaction(tx as unknown as Database, {
1030
+ workspaceId,
1031
+ sessionId,
1032
+ realtimeId,
1033
+ ownerSubjectId: grant.subjectId,
1034
+ ...parsed.data,
1035
+ }),
1036
+ ),
1037
+ );
1038
+ await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
1039
+ c.header("cache-control", "private, no-store");
1040
+ return c.json({ accepted: result.accepted, outbound: result.outbound });
1041
+ } catch (error) {
1042
+ throw sessionRealtimeHttpError(error);
1043
+ }
1044
+ },
1045
+ );
1046
+
520
1047
  // Personal pin only: this is organization state for the authenticated member,
521
1048
  // not a mutation of the shared session. It deliberately requires read access
522
1049
  // (not session control) and returns 404 for a foreign/inaccessible session.
@@ -685,7 +1212,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
685
1212
  await c.req.json().catch(() => null),
686
1213
  );
687
1214
  if (!parsedServerId.success || !payload.success) {
688
- throw new HTTPException(400, { message: "invalid MCP approval-policy request" });
1215
+ throw new HTTPException(400, {
1216
+ message: "invalid MCP approval-policy request",
1217
+ });
689
1218
  }
690
1219
  await assertSessionExists(db, workspaceId, sessionId);
691
1220
  return c.json(
@@ -1011,7 +1540,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1011
1540
  const result = compactSessionEventResult(
1012
1541
  event,
1013
1542
  latestClass!,
1014
- dbPage.coveredSequence ?? { first: event.sequence, last: event.sequence },
1543
+ dbPage.coveredSequence ?? {
1544
+ first: event.sequence,
1545
+ last: event.sequence,
1546
+ },
1015
1547
  );
1016
1548
  c.header("X-OpenGeni-Covered-First", String(result.coveredSequence.first));
1017
1549
  c.header("X-OpenGeni-Covered-Last", String(result.coveredSequence.last));
@@ -1262,12 +1794,16 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1262
1794
  const workspaceId = c.req.param("workspaceId");
1263
1795
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
1264
1796
  if (workspaceControlUtf8Bytes(grant.subjectId) > WORKSPACE_CONTROL_ACTOR_MAX_BYTES) {
1265
- throw new HTTPException(400, { message: "workspace-control actor is too large" });
1797
+ throw new HTTPException(400, {
1798
+ message: "workspace-control actor is too large",
1799
+ });
1266
1800
  }
1267
1801
  const sessionId = c.req.param("sessionId");
1268
1802
  const parsed = SessionControlRequest.safeParse(await c.req.json().catch(() => null));
1269
1803
  if (!parsed.success) {
1270
- throw new HTTPException(400, { message: "invalid session control request" });
1804
+ throw new HTTPException(400, {
1805
+ message: "invalid session control request",
1806
+ });
1271
1807
  }
1272
1808
  try {
1273
1809
  const response = await controlHumanSessionWorkstream(
@@ -1410,7 +1946,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1410
1946
  throw error;
1411
1947
  }
1412
1948
  if (accepted.action === "not_found") {
1413
- throw new HTTPException(404, { message: "human-input request not found" });
1949
+ throw new HTTPException(404, {
1950
+ message: "human-input request not found",
1951
+ });
1414
1952
  }
1415
1953
  await publishDurableSessionEvents(bus, workspaceId, sessionId, accepted.events);
1416
1954
  if (accepted.workflowWakeRevision !== null) {
@@ -1440,7 +1978,9 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1440
1978
  const rawStatus = c.req.query("status");
1441
1979
  const status = rawStatus ? HumanInputRequestStatus.safeParse(rawStatus) : null;
1442
1980
  if (status && !status.success) {
1443
- throw new HTTPException(400, { message: "invalid human-input request status" });
1981
+ throw new HTTPException(400, {
1982
+ message: "invalid human-input request status",
1983
+ });
1444
1984
  }
1445
1985
  const requests = await listSessionHumanInputRequests(db, workspaceId, sessionId, {
1446
1986
  ...(status?.success ? { status: status.data } : {}),
@@ -1460,7 +2000,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1460
2000
  sessionId,
1461
2001
  c.req.param("requestId"),
1462
2002
  );
1463
- if (!request) throw new HTTPException(404, { message: "human-input request not found" });
2003
+ if (!request)
2004
+ throw new HTTPException(404, {
2005
+ message: "human-input request not found",
2006
+ });
1464
2007
  return c.json(request);
1465
2008
  },
1466
2009
  );
@@ -2428,6 +2971,45 @@ function eventListLimit(raw: string | undefined, max = 2000, fallback = 500): nu
2428
2971
  return Math.min(max, Math.max(1, Math.floor(limit)));
2429
2972
  }
2430
2973
 
2974
+ function codexRealtimeHttpFailure(error: CodexRealtimeBrokerError): {
2975
+ status: ContentfulStatusCode;
2976
+ code: ErrorCode;
2977
+ retryable: boolean;
2978
+ } {
2979
+ switch (error.reason) {
2980
+ case "invalid_request":
2981
+ case "incompatible":
2982
+ return { status: 422, code: "validation_failed", retryable: false };
2983
+ case "entitlement_denied":
2984
+ return { status: 403, code: "forbidden", retryable: false };
2985
+ case "rate_limited":
2986
+ return { status: 429, code: "limit_exceeded", retryable: true };
2987
+ case "timeout":
2988
+ return { status: 504, code: "upstream_unavailable", retryable: true };
2989
+ case "cancelled":
2990
+ return { status: 408, code: "upstream_unavailable", retryable: true };
2991
+ case "provider_error":
2992
+ case "invalid_provider_response":
2993
+ case "network_error":
2994
+ return { status: 502, code: "upstream_unavailable", retryable: true };
2995
+ case "subscription_disabled":
2996
+ case "credential_unavailable":
2997
+ case "reconnect_required":
2998
+ return { status: 409, code: "conflict", retryable: false };
2999
+ }
3000
+ }
3001
+
3002
+ function sessionRealtimeHttpError(error: unknown): HTTPException {
3003
+ if (error instanceof HTTPException) return error;
3004
+ if (error instanceof SessionRealtimeConflictError) {
3005
+ return new HTTPException(error.code === "REALTIME_NOT_FOUND" ? 404 : 409, {
3006
+ message: error.message,
3007
+ cause: error,
3008
+ });
3009
+ }
3010
+ throw error;
3011
+ }
3012
+
2431
3013
  /**
2432
3014
  * Map every mounted session-addressed HTTP path to the host-neutral operation
2433
3015
  * the embedding port authorizes. Returning null is deliberately fail-closed in
@@ -2458,6 +3040,27 @@ export function sessionAuthorizationOperationForHttp(
2458
3040
  if (suffix === "/codex-account" && verb === "POST") {
2459
3041
  return "session.codex_account.write";
2460
3042
  }
3043
+ if (suffix === "/realtime/webrtc" && verb === "POST") {
3044
+ return "session.realtime.start";
3045
+ }
3046
+ if (suffix === "/realtime/gateway" && verb === "POST") {
3047
+ return "session.realtime.start";
3048
+ }
3049
+ if (suffix === "/realtime" && verb === "POST") {
3050
+ return "session.realtime.start";
3051
+ }
3052
+ if (/^\/realtime\/[^/]+\/heartbeat$/.test(suffix) && verb === "PATCH") {
3053
+ return "session.realtime.control";
3054
+ }
3055
+ if (/^\/realtime\/[^/]+\/sync$/.test(suffix) && verb === "POST") {
3056
+ return "session.realtime.control";
3057
+ }
3058
+ if (/^\/realtime\/[^/]+\/connections\/[^/]+\/activate$/.test(suffix) && verb === "POST") {
3059
+ return "session.realtime.control";
3060
+ }
3061
+ if (/^\/realtime\/[^/]+$/.test(suffix) && verb === "DELETE") {
3062
+ return "session.realtime.control";
3063
+ }
2461
3064
  if (suffix === "/goal") {
2462
3065
  return verb === "GET"
2463
3066
  ? "session.goal.read"
@@ -2521,7 +3124,9 @@ function sessionAuthorizationHttpError(error: unknown): HTTPException {
2521
3124
  return new HTTPException(404, { message: "session not found" });
2522
3125
  }
2523
3126
  if (error instanceof SessionAuthorizationUnavailableError) {
2524
- return new HTTPException(503, { message: "session authorization is unavailable" });
3127
+ return new HTTPException(503, {
3128
+ message: "session authorization is unavailable",
3129
+ });
2525
3130
  }
2526
3131
  if (error instanceof HTTPException) return error;
2527
3132
  throw error;
@@ -2564,12 +3169,16 @@ function eventEnumList<T extends string>(
2564
3169
  .map((value) => value.trim())
2565
3170
  .filter(Boolean);
2566
3171
  if (values.length > 100) {
2567
- throw new HTTPException(400, { message: `${name} accepts at most 100 values` });
3172
+ throw new HTTPException(400, {
3173
+ message: `${name} accepts at most 100 values`,
3174
+ });
2568
3175
  }
2569
3176
  return values.map((value) => {
2570
3177
  const parsed = schema.safeParse(value);
2571
3178
  if (!parsed.success) {
2572
- throw new HTTPException(400, { message: `${name} contains an invalid value` });
3179
+ throw new HTTPException(400, {
3180
+ message: `${name} contains an invalid value`,
3181
+ });
2573
3182
  }
2574
3183
  return parsed.data as T;
2575
3184
  });
@@ -2610,7 +3219,9 @@ function sessionListQuery(
2610
3219
  });
2611
3220
  }
2612
3221
  if (query.pinsOnly !== undefined && query.pinsOnly !== "true") {
2613
- throw new HTTPException(400, { message: 'pinsOnly must be the literal "true"' });
3222
+ throw new HTTPException(400, {
3223
+ message: 'pinsOnly must be the literal "true"',
3224
+ });
2614
3225
  }
2615
3226
  const pinsOnly = query.pinsOnly === "true";
2616
3227
  if (pinsOnly && !allowCursor) {