@opengeni/api-router 0.16.4 → 0.17.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/dist/app.js +1 -1
- package/dist/{chunk-TFHWQL2W.js → chunk-MWBF2GXL.js} +1875 -70
- package/dist/chunk-MWBF2GXL.js.map +1 -0
- package/dist/codex-realtime.d.ts +43 -0
- package/dist/gateway-realtime.d.ts +24 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-bot.d.ts +122 -0
- package/dist/integrations/slack-interactions.d.ts +5 -0
- package/dist/mcp/server.d.ts +1 -1
- package/dist/model-catalog.d.ts +1 -0
- package/dist/session-realtime-context.d.ts +19 -0
- package/package.json +12 -12
- package/src/app.ts +1 -1
- package/src/codex-realtime.ts +367 -0
- package/src/gateway-realtime.ts +143 -0
- package/src/integrations/google-drive.ts +22 -12
- package/src/integrations/slack-bot.ts +536 -0
- package/src/integrations/slack-interactions.ts +467 -2
- package/src/model-catalog.ts +31 -6
- package/src/routes/connections.ts +2 -2
- package/src/routes/sessions.ts +622 -11
- package/src/routes/workspaces.ts +60 -2
- package/src/session-realtime-context.ts +134 -0
- package/dist/chunk-TFHWQL2W.js.map +0 -1
package/src/routes/sessions.ts
CHANGED
|
@@ -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, {
|
|
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 ?? {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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)
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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) {
|