@opengeni/db 0.22.1 → 0.23.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/{chunk-CYGFLLMN.js → chunk-3UCHDMKG.js} +656 -282
- package/dist/chunk-3UCHDMKG.js.map +1 -0
- package/dist/{chunk-BNGEN5QZ.js → chunk-L6ADMZHE.js} +24 -2
- package/dist/chunk-L6ADMZHE.js.map +1 -0
- package/dist/index.d.ts +42 -3
- package/dist/index.js +6919 -3760
- package/dist/index.js.map +1 -1
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +3 -3
- package/dist/schema.d.ts +1416 -92
- package/dist/schema.js +11 -1
- package/dist/session-control.d.ts +7 -1
- package/dist/session-queue-commands.d.ts +8 -0
- package/dist/session-realtime-context.d.ts +56 -0
- package/dist/session-realtime-ledger.d.ts +188 -0
- package/dist/session-realtime-mirror.d.ts +30 -0
- package/dist/session-realtime-state.d.ts +2 -0
- package/dist/session-realtime-terminal.d.ts +40 -0
- package/dist/session-realtime.d.ts +59 -0
- package/dist/workspace-instruction-policies-schema.d.ts +239 -0
- package/dist/workspace-instruction-policies.d.ts +44 -0
- package/drizzle/0156_slack_reaction_trigger.sql +49 -0
- package/drizzle/0157_session_policy_role_snapshots.sql +1146 -0
- package/drizzle/0158_session_realtime_mode.sql +88 -0
- package/drizzle/0159_session_realtime_ledger.sql +198 -0
- package/drizzle/0160_session_realtime_delegation_terminal.sql +38 -0
- package/drizzle/0161_session_realtime_context_projection.sql +82 -0
- package/drizzle/0162_session_realtime_connection_promotion.sql +53 -0
- package/drizzle/0163_session_realtime_delegation_progress.sql +35 -0
- package/drizzle/0164_session_realtime_models.sql +28 -0
- package/package.json +4 -4
- package/src/index.ts +670 -79
- package/src/preference-registry.ts +11 -6
- package/src/provision-roles.ts +12 -0
- package/src/runtime-posture.ts +10 -0
- package/src/schema.ts +400 -43
- package/src/session-control.ts +596 -21
- package/src/session-queue-commands.ts +76 -7
- package/src/session-realtime-context.ts +393 -0
- package/src/session-realtime-ledger.ts +1790 -0
- package/src/session-realtime-mirror.ts +160 -0
- package/src/session-realtime-state.ts +25 -0
- package/src/session-realtime-terminal.ts +306 -0
- package/src/session-realtime.ts +611 -0
- package/src/workspace-instruction-policies-schema.ts +41 -0
- package/src/workspace-instruction-policies.ts +131 -2
- package/dist/chunk-BNGEN5QZ.js.map +0 -1
- package/dist/chunk-CYGFLLMN.js.map +0 -1
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
SessionEventType,
|
|
5
|
+
SessionRealtimeEndReason,
|
|
6
|
+
SessionRealtimeMode,
|
|
7
|
+
SessionRealtimeModel,
|
|
8
|
+
} from "@opengeni/contracts";
|
|
9
|
+
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
10
|
+
|
|
11
|
+
import { sanitizeEventPayload } from "./event-payload-sanitizer";
|
|
12
|
+
import type { Database } from "./index";
|
|
13
|
+
import { flushSessionRealtimeTranscriptTailInTransaction } from "./session-realtime-context";
|
|
14
|
+
import {
|
|
15
|
+
evaluateSessionControl,
|
|
16
|
+
lockSessionEventWriteRows,
|
|
17
|
+
registerSessionWorkflowWakeInTransaction,
|
|
18
|
+
} from "./session-control";
|
|
19
|
+
import * as schema from "./schema";
|
|
20
|
+
|
|
21
|
+
export { sessionRealtimeIsActiveInTransaction } from "./session-realtime-state";
|
|
22
|
+
|
|
23
|
+
export const SESSION_REALTIME_LEASE_MS = 30_000;
|
|
24
|
+
const SESSION_REALTIME_MIN_LEASE_MS = 5_000;
|
|
25
|
+
const SESSION_REALTIME_MAX_LEASE_MS = 120_000;
|
|
26
|
+
|
|
27
|
+
export type SessionRealtimeConflictCode =
|
|
28
|
+
| "SESSION_CANCELLED"
|
|
29
|
+
| "CONTROL_NOT_ACTIVE"
|
|
30
|
+
| "REALTIME_ACTIVE"
|
|
31
|
+
| "REALTIME_NOT_FOUND"
|
|
32
|
+
| "REALTIME_NOT_ACTIVE"
|
|
33
|
+
| "REALTIME_OWNER_MISMATCH"
|
|
34
|
+
| "REALTIME_VERSION_CHANGED"
|
|
35
|
+
| "REALTIME_CONNECTION_CHANGED"
|
|
36
|
+
| "REALTIME_CONNECTION_ACTIVE"
|
|
37
|
+
| "REALTIME_CONNECTION_NOT_FOUND"
|
|
38
|
+
| "REALTIME_CONNECTION_STATE_CHANGED"
|
|
39
|
+
| "REALTIME_PROVIDER_NOT_STARTED"
|
|
40
|
+
| "REALTIME_DELEGATION_CHANGED"
|
|
41
|
+
| "REALTIME_ENTRY_CHANGED"
|
|
42
|
+
| "REALTIME_ACK_INVALID";
|
|
43
|
+
|
|
44
|
+
export class SessionRealtimeConflictError extends Error {
|
|
45
|
+
readonly name = "SessionRealtimeConflictError";
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
readonly code: SessionRealtimeConflictCode,
|
|
49
|
+
message: string,
|
|
50
|
+
) {
|
|
51
|
+
super(message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type RealtimeRow = typeof schema.sessionRealtimeModes.$inferSelect;
|
|
56
|
+
type SessionRow = typeof schema.sessions.$inferSelect;
|
|
57
|
+
|
|
58
|
+
export type BeginSessionRealtimeInput = {
|
|
59
|
+
accountId: string;
|
|
60
|
+
workspaceId: string;
|
|
61
|
+
sessionId: string;
|
|
62
|
+
operationId: string;
|
|
63
|
+
ownerSubjectId: string;
|
|
64
|
+
browserInstanceId: string;
|
|
65
|
+
ownerKey: string;
|
|
66
|
+
model: SessionRealtimeModel;
|
|
67
|
+
now?: Date;
|
|
68
|
+
leaseMs?: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type RenewSessionRealtimeInput = {
|
|
72
|
+
workspaceId: string;
|
|
73
|
+
sessionId: string;
|
|
74
|
+
realtimeId: string;
|
|
75
|
+
ownerSubjectId: string;
|
|
76
|
+
browserInstanceId: string;
|
|
77
|
+
ownerKey: string;
|
|
78
|
+
expectedVersion: number;
|
|
79
|
+
now?: Date;
|
|
80
|
+
leaseMs?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type EndSessionRealtimeInput = Omit<RenewSessionRealtimeInput, "leaseMs"> & {
|
|
84
|
+
reason: Extract<SessionRealtimeEndReason, "user_stop" | "browser_unload">;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type AssertSessionRealtimeOwnerInput = Omit<RenewSessionRealtimeInput, "leaseMs">;
|
|
88
|
+
|
|
89
|
+
export type SessionRealtimeMutationResult = {
|
|
90
|
+
mode: SessionRealtimeMode;
|
|
91
|
+
replay: boolean;
|
|
92
|
+
eventIds: string[];
|
|
93
|
+
workflowWakeRevision: number | null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type RenewSessionRealtimeResult = SessionRealtimeMutationResult & {
|
|
97
|
+
expired: boolean;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function hashOwnerKey(ownerKey: string): string {
|
|
101
|
+
return createHash("sha256").update(ownerKey).digest("hex");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sameHash(left: string, right: string): boolean {
|
|
105
|
+
const leftBytes = Buffer.from(left, "hex");
|
|
106
|
+
const rightBytes = Buffer.from(right, "hex");
|
|
107
|
+
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function leaseExpiry(now: Date, leaseMs = SESSION_REALTIME_LEASE_MS): Date {
|
|
111
|
+
if (
|
|
112
|
+
!Number.isSafeInteger(leaseMs) ||
|
|
113
|
+
leaseMs < SESSION_REALTIME_MIN_LEASE_MS ||
|
|
114
|
+
leaseMs > SESSION_REALTIME_MAX_LEASE_MS
|
|
115
|
+
) {
|
|
116
|
+
throw new Error("Realtime lease duration is outside server bounds");
|
|
117
|
+
}
|
|
118
|
+
return new Date(now.getTime() + leaseMs);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mapRealtimeMode(row: RealtimeRow): SessionRealtimeMode {
|
|
122
|
+
return {
|
|
123
|
+
id: row.id,
|
|
124
|
+
sessionId: row.sessionId,
|
|
125
|
+
operationId: row.operationId,
|
|
126
|
+
browserInstanceId: row.browserInstanceId,
|
|
127
|
+
model: row.model as SessionRealtimeModel,
|
|
128
|
+
state: row.state as SessionRealtimeMode["state"],
|
|
129
|
+
version: row.version,
|
|
130
|
+
connectionEpoch: row.connectionEpoch,
|
|
131
|
+
leaseExpiresAt: row.leaseExpiresAt.toISOString(),
|
|
132
|
+
lastHeartbeatAt: row.lastHeartbeatAt.toISOString(),
|
|
133
|
+
startedAt: row.startedAt.toISOString(),
|
|
134
|
+
endedAt: row.endedAt?.toISOString() ?? null,
|
|
135
|
+
endReason: row.endReason as SessionRealtimeEndReason | null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function assertOwner(
|
|
140
|
+
row: RealtimeRow,
|
|
141
|
+
input: {
|
|
142
|
+
ownerSubjectId: string;
|
|
143
|
+
browserInstanceId: string;
|
|
144
|
+
ownerKey: string;
|
|
145
|
+
},
|
|
146
|
+
): void {
|
|
147
|
+
if (
|
|
148
|
+
row.ownerSubjectId !== input.ownerSubjectId ||
|
|
149
|
+
row.browserInstanceId !== input.browserInstanceId ||
|
|
150
|
+
!sameHash(row.ownerKeyHash, hashOwnerKey(input.ownerKey))
|
|
151
|
+
) {
|
|
152
|
+
throw new SessionRealtimeConflictError(
|
|
153
|
+
"REALTIME_OWNER_MISMATCH",
|
|
154
|
+
"Realtime mode belongs to another authenticated browser owner",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function appendRealtimeLifecycleEvent(
|
|
160
|
+
db: Database,
|
|
161
|
+
session: SessionRow,
|
|
162
|
+
sequence: number,
|
|
163
|
+
type: Extract<SessionEventType, "session.realtime.started" | "session.realtime.ended">,
|
|
164
|
+
payload: Record<string, unknown>,
|
|
165
|
+
now: Date,
|
|
166
|
+
) {
|
|
167
|
+
// Keep the sole lifecycle event writer independently on the canonical
|
|
168
|
+
// control/workspace/session prefix. Current callers already own these locks,
|
|
169
|
+
// so this is a nonblocking re-acquisition that also prevents a future caller
|
|
170
|
+
// from inserting a sequence row without the required session fence.
|
|
171
|
+
await lockSessionEventWriteRows(db, {
|
|
172
|
+
workspaceId: session.workspaceId,
|
|
173
|
+
controlLock: "share",
|
|
174
|
+
sessionIds: [session.id],
|
|
175
|
+
});
|
|
176
|
+
const [event] = await db
|
|
177
|
+
.insert(schema.sessionEvents)
|
|
178
|
+
.values({
|
|
179
|
+
accountId: session.accountId,
|
|
180
|
+
workspaceId: session.workspaceId,
|
|
181
|
+
sessionId: session.id,
|
|
182
|
+
sequence,
|
|
183
|
+
type,
|
|
184
|
+
payload: sanitizeEventPayload(payload),
|
|
185
|
+
occurredAt: now,
|
|
186
|
+
})
|
|
187
|
+
.returning();
|
|
188
|
+
if (!event) throw new Error(`Failed to append ${type}`);
|
|
189
|
+
await db
|
|
190
|
+
.update(schema.sessions)
|
|
191
|
+
.set({ lastSequence: sequence, updatedAt: now })
|
|
192
|
+
.where(
|
|
193
|
+
and(eq(schema.sessions.workspaceId, session.workspaceId), eq(schema.sessions.id, session.id)),
|
|
194
|
+
);
|
|
195
|
+
return event;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function loadActiveRowForUpdate(
|
|
199
|
+
db: Database,
|
|
200
|
+
workspaceId: string,
|
|
201
|
+
sessionId: string,
|
|
202
|
+
): Promise<RealtimeRow | null> {
|
|
203
|
+
const [row] = await db
|
|
204
|
+
.select()
|
|
205
|
+
.from(schema.sessionRealtimeModes)
|
|
206
|
+
.where(
|
|
207
|
+
and(
|
|
208
|
+
eq(schema.sessionRealtimeModes.workspaceId, workspaceId),
|
|
209
|
+
eq(schema.sessionRealtimeModes.sessionId, sessionId),
|
|
210
|
+
eq(schema.sessionRealtimeModes.state, "active"),
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
.orderBy(asc(schema.sessionRealtimeModes.createdAt), asc(schema.sessionRealtimeModes.id))
|
|
214
|
+
.for("update")
|
|
215
|
+
.limit(1);
|
|
216
|
+
return row ?? null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function endRow(
|
|
220
|
+
db: Database,
|
|
221
|
+
row: RealtimeRow,
|
|
222
|
+
reason: SessionRealtimeEndReason,
|
|
223
|
+
now: Date,
|
|
224
|
+
): Promise<RealtimeRow> {
|
|
225
|
+
const [ended] = await db
|
|
226
|
+
.update(schema.sessionRealtimeModes)
|
|
227
|
+
.set({
|
|
228
|
+
state: "ended",
|
|
229
|
+
version: row.version + 1,
|
|
230
|
+
endedAt: now,
|
|
231
|
+
endReason: reason,
|
|
232
|
+
updatedAt: now,
|
|
233
|
+
})
|
|
234
|
+
.where(
|
|
235
|
+
and(
|
|
236
|
+
eq(schema.sessionRealtimeModes.id, row.id),
|
|
237
|
+
eq(schema.sessionRealtimeModes.state, "active"),
|
|
238
|
+
eq(schema.sessionRealtimeModes.version, row.version),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
.returning();
|
|
242
|
+
if (!ended) throw new Error("Realtime owner changed while ending mode");
|
|
243
|
+
return ended;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function registerNormalModeWake(db: Database, session: SessionRow, reason: string) {
|
|
247
|
+
return await registerSessionWorkflowWakeInTransaction(db, {
|
|
248
|
+
accountId: session.accountId,
|
|
249
|
+
workspaceId: session.workspaceId,
|
|
250
|
+
sessionId: session.id,
|
|
251
|
+
temporalWorkflowId: session.temporalWorkflowId ?? `session-${session.id}`,
|
|
252
|
+
reason,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function endWithEvent(
|
|
257
|
+
db: Database,
|
|
258
|
+
session: SessionRow,
|
|
259
|
+
row: RealtimeRow,
|
|
260
|
+
reason: SessionRealtimeEndReason,
|
|
261
|
+
now: Date,
|
|
262
|
+
): Promise<{
|
|
263
|
+
mode: SessionRealtimeMode;
|
|
264
|
+
eventId: string;
|
|
265
|
+
workflowWakeRevision: number;
|
|
266
|
+
session: SessionRow;
|
|
267
|
+
}> {
|
|
268
|
+
const ended = await endRow(db, row, reason, now);
|
|
269
|
+
await db
|
|
270
|
+
.update(schema.sessionRealtimeConnections)
|
|
271
|
+
.set({ state: "closed", closedAt: now, updatedAt: now })
|
|
272
|
+
.where(
|
|
273
|
+
and(
|
|
274
|
+
eq(schema.sessionRealtimeConnections.realtimeId, row.id),
|
|
275
|
+
inArray(schema.sessionRealtimeConnections.state, ["negotiating", "ready", "active"]),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
const event = await appendRealtimeLifecycleEvent(
|
|
279
|
+
db,
|
|
280
|
+
session,
|
|
281
|
+
session.lastSequence + 1,
|
|
282
|
+
"session.realtime.ended",
|
|
283
|
+
{
|
|
284
|
+
realtimeId: ended.id,
|
|
285
|
+
operationId: ended.operationId,
|
|
286
|
+
reason,
|
|
287
|
+
version: ended.version,
|
|
288
|
+
connectionEpoch: ended.connectionEpoch,
|
|
289
|
+
},
|
|
290
|
+
now,
|
|
291
|
+
);
|
|
292
|
+
await flushSessionRealtimeTranscriptTailInTransaction(db, {
|
|
293
|
+
accountId: session.accountId,
|
|
294
|
+
workspaceId: session.workspaceId,
|
|
295
|
+
sessionId: session.id,
|
|
296
|
+
realtimeId: ended.id,
|
|
297
|
+
ownerSubjectId: ended.ownerSubjectId,
|
|
298
|
+
now,
|
|
299
|
+
});
|
|
300
|
+
const workflowWakeRevision = await registerNormalModeWake(db, session, `realtime_${reason}`);
|
|
301
|
+
const [refreshedSession] = await db
|
|
302
|
+
.select()
|
|
303
|
+
.from(schema.sessions)
|
|
304
|
+
.where(
|
|
305
|
+
and(eq(schema.sessions.workspaceId, session.workspaceId), eq(schema.sessions.id, session.id)),
|
|
306
|
+
)
|
|
307
|
+
.limit(1);
|
|
308
|
+
if (!refreshedSession) throw new Error("Realtime session disappeared while ending mode");
|
|
309
|
+
return {
|
|
310
|
+
mode: mapRealtimeMode(ended),
|
|
311
|
+
eventId: event.id,
|
|
312
|
+
workflowWakeRevision,
|
|
313
|
+
session: refreshedSession,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function lockRealtimeSession(
|
|
318
|
+
db: Database,
|
|
319
|
+
workspaceId: string,
|
|
320
|
+
sessionId: string,
|
|
321
|
+
): Promise<{
|
|
322
|
+
session: SessionRow;
|
|
323
|
+
control: Awaited<ReturnType<typeof evaluateSessionControl>>;
|
|
324
|
+
}> {
|
|
325
|
+
const locks = await lockSessionEventWriteRows(db, {
|
|
326
|
+
workspaceId,
|
|
327
|
+
controlLock: "share",
|
|
328
|
+
sessionIds: [sessionId],
|
|
329
|
+
});
|
|
330
|
+
const session = locks.sessions[0];
|
|
331
|
+
if (!session) {
|
|
332
|
+
throw new SessionRealtimeConflictError("REALTIME_NOT_FOUND", "Session not found");
|
|
333
|
+
}
|
|
334
|
+
const control = await evaluateSessionControl(db, workspaceId, sessionId, {
|
|
335
|
+
workspaceControl: locks.control ?? undefined,
|
|
336
|
+
});
|
|
337
|
+
return { session, control };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function assertRealtimeAdmission(session: SessionRow): void {
|
|
341
|
+
if (session.status === "cancelled") {
|
|
342
|
+
throw new SessionRealtimeConflictError(
|
|
343
|
+
"SESSION_CANCELLED",
|
|
344
|
+
"Cancelled session cannot enter realtime mode",
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function beginSessionRealtimeInTransaction(
|
|
350
|
+
db: Database,
|
|
351
|
+
input: BeginSessionRealtimeInput,
|
|
352
|
+
): Promise<SessionRealtimeMutationResult> {
|
|
353
|
+
const now = input.now ?? new Date();
|
|
354
|
+
let { session, control } = await lockRealtimeSession(db, input.workspaceId, input.sessionId);
|
|
355
|
+
if (session.accountId !== input.accountId) {
|
|
356
|
+
throw new SessionRealtimeConflictError("REALTIME_NOT_FOUND", "Session not found");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let active = await loadActiveRowForUpdate(db, input.workspaceId, input.sessionId);
|
|
360
|
+
const eventIds: string[] = [];
|
|
361
|
+
let workflowWakeRevision: number | null = null;
|
|
362
|
+
if (active && active.leaseExpiresAt <= now) {
|
|
363
|
+
const expired = await endWithEvent(db, session, active, "lease_expired", now);
|
|
364
|
+
session = expired.session;
|
|
365
|
+
eventIds.push(expired.eventId);
|
|
366
|
+
workflowWakeRevision = expired.workflowWakeRevision;
|
|
367
|
+
active = null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const [operation] = await db
|
|
371
|
+
.select()
|
|
372
|
+
.from(schema.sessionRealtimeModes)
|
|
373
|
+
.where(
|
|
374
|
+
and(
|
|
375
|
+
eq(schema.sessionRealtimeModes.workspaceId, input.workspaceId),
|
|
376
|
+
eq(schema.sessionRealtimeModes.sessionId, input.sessionId),
|
|
377
|
+
eq(schema.sessionRealtimeModes.operationId, input.operationId),
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
.limit(1);
|
|
381
|
+
if (operation) {
|
|
382
|
+
assertOwner(operation, input);
|
|
383
|
+
if (operation.model !== input.model) {
|
|
384
|
+
throw new SessionRealtimeConflictError(
|
|
385
|
+
"REALTIME_OWNER_MISMATCH",
|
|
386
|
+
"Realtime operation was already used with different input",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
mode: mapRealtimeMode(operation),
|
|
391
|
+
replay: true,
|
|
392
|
+
eventIds,
|
|
393
|
+
workflowWakeRevision,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
if (active) {
|
|
397
|
+
throw new SessionRealtimeConflictError(
|
|
398
|
+
"REALTIME_ACTIVE",
|
|
399
|
+
"Session already has an active realtime owner",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
if (control.state !== "active") {
|
|
403
|
+
throw new SessionRealtimeConflictError(
|
|
404
|
+
"CONTROL_NOT_ACTIVE",
|
|
405
|
+
"Session control must be active before realtime starts",
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
assertRealtimeAdmission(session);
|
|
409
|
+
|
|
410
|
+
const [row] = await db
|
|
411
|
+
.insert(schema.sessionRealtimeModes)
|
|
412
|
+
.values({
|
|
413
|
+
accountId: input.accountId,
|
|
414
|
+
workspaceId: input.workspaceId,
|
|
415
|
+
sessionId: input.sessionId,
|
|
416
|
+
operationId: input.operationId,
|
|
417
|
+
ownerSubjectId: input.ownerSubjectId,
|
|
418
|
+
browserInstanceId: input.browserInstanceId,
|
|
419
|
+
ownerKeyHash: hashOwnerKey(input.ownerKey),
|
|
420
|
+
model: input.model,
|
|
421
|
+
state: "active",
|
|
422
|
+
version: 1,
|
|
423
|
+
connectionEpoch: 1,
|
|
424
|
+
leaseExpiresAt: leaseExpiry(now, input.leaseMs),
|
|
425
|
+
lastHeartbeatAt: now,
|
|
426
|
+
startedAt: now,
|
|
427
|
+
createdAt: now,
|
|
428
|
+
updatedAt: now,
|
|
429
|
+
})
|
|
430
|
+
.returning();
|
|
431
|
+
if (!row) throw new Error("Failed to start realtime mode");
|
|
432
|
+
const event = await appendRealtimeLifecycleEvent(
|
|
433
|
+
db,
|
|
434
|
+
session,
|
|
435
|
+
session.lastSequence + 1,
|
|
436
|
+
"session.realtime.started",
|
|
437
|
+
{
|
|
438
|
+
realtimeId: row.id,
|
|
439
|
+
operationId: row.operationId,
|
|
440
|
+
model: row.model,
|
|
441
|
+
version: row.version,
|
|
442
|
+
connectionEpoch: row.connectionEpoch,
|
|
443
|
+
leaseExpiresAt: row.leaseExpiresAt.toISOString(),
|
|
444
|
+
},
|
|
445
|
+
now,
|
|
446
|
+
);
|
|
447
|
+
eventIds.push(event.id);
|
|
448
|
+
return {
|
|
449
|
+
mode: mapRealtimeMode(row),
|
|
450
|
+
replay: false,
|
|
451
|
+
eventIds,
|
|
452
|
+
workflowWakeRevision: null,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function loadOwnedRealtimeRow(
|
|
457
|
+
db: Database,
|
|
458
|
+
input: RenewSessionRealtimeInput,
|
|
459
|
+
): Promise<RealtimeRow> {
|
|
460
|
+
const [row] = await db
|
|
461
|
+
.select()
|
|
462
|
+
.from(schema.sessionRealtimeModes)
|
|
463
|
+
.where(
|
|
464
|
+
and(
|
|
465
|
+
eq(schema.sessionRealtimeModes.workspaceId, input.workspaceId),
|
|
466
|
+
eq(schema.sessionRealtimeModes.sessionId, input.sessionId),
|
|
467
|
+
eq(schema.sessionRealtimeModes.id, input.realtimeId),
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
.for("update")
|
|
471
|
+
.limit(1);
|
|
472
|
+
if (!row) {
|
|
473
|
+
throw new SessionRealtimeConflictError("REALTIME_NOT_FOUND", "Realtime mode not found");
|
|
474
|
+
}
|
|
475
|
+
assertOwner(row, input);
|
|
476
|
+
return row;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Fence a provider connection to the exact active authenticated browser owner.
|
|
481
|
+
* The caller must finish this transaction before contacting the provider; owner
|
|
482
|
+
* proof is authorization material and must never cross that boundary.
|
|
483
|
+
*/
|
|
484
|
+
export async function assertSessionRealtimeOwnerInTransaction(
|
|
485
|
+
db: Database,
|
|
486
|
+
input: AssertSessionRealtimeOwnerInput,
|
|
487
|
+
): Promise<SessionRealtimeMode> {
|
|
488
|
+
const now = input.now ?? new Date();
|
|
489
|
+
await lockRealtimeSession(db, input.workspaceId, input.sessionId);
|
|
490
|
+
const row = await loadOwnedRealtimeRow(db, input);
|
|
491
|
+
if (row.state !== "active" || row.leaseExpiresAt <= now) {
|
|
492
|
+
throw new SessionRealtimeConflictError(
|
|
493
|
+
"REALTIME_NOT_ACTIVE",
|
|
494
|
+
"Realtime mode is no longer active",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (row.version !== input.expectedVersion) {
|
|
498
|
+
throw new SessionRealtimeConflictError(
|
|
499
|
+
"REALTIME_VERSION_CHANGED",
|
|
500
|
+
"Realtime lease version changed",
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
return mapRealtimeMode(row);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export async function renewSessionRealtimeInTransaction(
|
|
507
|
+
db: Database,
|
|
508
|
+
input: RenewSessionRealtimeInput,
|
|
509
|
+
): Promise<RenewSessionRealtimeResult> {
|
|
510
|
+
const now = input.now ?? new Date();
|
|
511
|
+
const { session } = await lockRealtimeSession(db, input.workspaceId, input.sessionId);
|
|
512
|
+
const row = await loadOwnedRealtimeRow(db, input);
|
|
513
|
+
if (row.state === "ended") {
|
|
514
|
+
return {
|
|
515
|
+
mode: mapRealtimeMode(row),
|
|
516
|
+
replay: true,
|
|
517
|
+
eventIds: [],
|
|
518
|
+
workflowWakeRevision: null,
|
|
519
|
+
expired: row.endReason === "lease_expired",
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (row.version !== input.expectedVersion) {
|
|
523
|
+
throw new SessionRealtimeConflictError(
|
|
524
|
+
"REALTIME_VERSION_CHANGED",
|
|
525
|
+
"Realtime lease version changed",
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (row.leaseExpiresAt <= now) {
|
|
529
|
+
const expired = await endWithEvent(db, session, row, "lease_expired", now);
|
|
530
|
+
return {
|
|
531
|
+
mode: expired.mode,
|
|
532
|
+
replay: false,
|
|
533
|
+
eventIds: [expired.eventId],
|
|
534
|
+
workflowWakeRevision: expired.workflowWakeRevision,
|
|
535
|
+
expired: true,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
const [renewed] = await db
|
|
539
|
+
.update(schema.sessionRealtimeModes)
|
|
540
|
+
.set({
|
|
541
|
+
version: row.version + 1,
|
|
542
|
+
leaseExpiresAt: leaseExpiry(now, input.leaseMs),
|
|
543
|
+
lastHeartbeatAt: now,
|
|
544
|
+
updatedAt: now,
|
|
545
|
+
})
|
|
546
|
+
.where(
|
|
547
|
+
and(
|
|
548
|
+
eq(schema.sessionRealtimeModes.id, row.id),
|
|
549
|
+
eq(schema.sessionRealtimeModes.state, "active"),
|
|
550
|
+
eq(schema.sessionRealtimeModes.version, row.version),
|
|
551
|
+
),
|
|
552
|
+
)
|
|
553
|
+
.returning();
|
|
554
|
+
if (!renewed) {
|
|
555
|
+
throw new SessionRealtimeConflictError(
|
|
556
|
+
"REALTIME_VERSION_CHANGED",
|
|
557
|
+
"Realtime lease version changed",
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
mode: mapRealtimeMode(renewed),
|
|
562
|
+
replay: false,
|
|
563
|
+
eventIds: [],
|
|
564
|
+
workflowWakeRevision: null,
|
|
565
|
+
expired: false,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export async function endSessionRealtimeInTransaction(
|
|
570
|
+
db: Database,
|
|
571
|
+
input: EndSessionRealtimeInput,
|
|
572
|
+
): Promise<SessionRealtimeMutationResult> {
|
|
573
|
+
const now = input.now ?? new Date();
|
|
574
|
+
const { session } = await lockRealtimeSession(db, input.workspaceId, input.sessionId);
|
|
575
|
+
const row = await loadOwnedRealtimeRow(db, input);
|
|
576
|
+
if (row.state === "ended") {
|
|
577
|
+
return {
|
|
578
|
+
mode: mapRealtimeMode(row),
|
|
579
|
+
replay: true,
|
|
580
|
+
eventIds: [],
|
|
581
|
+
workflowWakeRevision: null,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
if (row.version !== input.expectedVersion) {
|
|
585
|
+
throw new SessionRealtimeConflictError(
|
|
586
|
+
"REALTIME_VERSION_CHANGED",
|
|
587
|
+
"Realtime lease version changed",
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
const reason: SessionRealtimeEndReason =
|
|
591
|
+
row.leaseExpiresAt <= now ? "lease_expired" : input.reason;
|
|
592
|
+
const ended = await endWithEvent(db, session, row, reason, now);
|
|
593
|
+
return {
|
|
594
|
+
mode: ended.mode,
|
|
595
|
+
replay: false,
|
|
596
|
+
eventIds: [ended.eventId],
|
|
597
|
+
workflowWakeRevision: ended.workflowWakeRevision,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Lazily settle an expired voice lease under the canonical session lock. */
|
|
602
|
+
export async function settleExpiredSessionRealtimeInTransaction(
|
|
603
|
+
db: Database,
|
|
604
|
+
session: SessionRow,
|
|
605
|
+
now = new Date(),
|
|
606
|
+
): Promise<SessionRow> {
|
|
607
|
+
const row = await loadActiveRowForUpdate(db, session.workspaceId, session.id);
|
|
608
|
+
if (!row || row.leaseExpiresAt > now) return session;
|
|
609
|
+
const expired = await endWithEvent(db, session, row, "lease_expired", now);
|
|
610
|
+
return expired.session;
|
|
611
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
+
import type { WorkspaceInstructionPolicySnapshotEntry } from "@opengeni/contracts";
|
|
2
3
|
import {
|
|
3
4
|
bigint,
|
|
4
5
|
check,
|
|
5
6
|
index,
|
|
7
|
+
integer,
|
|
8
|
+
jsonb,
|
|
6
9
|
pgTable,
|
|
7
10
|
text,
|
|
8
11
|
timestamp,
|
|
@@ -138,3 +141,41 @@ export const workspaceInstructionPolicyActivationEvents = pgTable(
|
|
|
138
141
|
),
|
|
139
142
|
}),
|
|
140
143
|
);
|
|
144
|
+
|
|
145
|
+
export const workspaceInstructionPolicySnapshots = pgTable(
|
|
146
|
+
"workspace_instruction_policy_snapshots",
|
|
147
|
+
{
|
|
148
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
149
|
+
accountId: uuid("account_id").notNull(),
|
|
150
|
+
workspaceId: uuid("workspace_id").notNull(),
|
|
151
|
+
sessionId: uuid("session_id").notNull(),
|
|
152
|
+
turnId: uuid("turn_id").notNull(),
|
|
153
|
+
attemptId: uuid("attempt_id").notNull(),
|
|
154
|
+
executionGeneration: integer("execution_generation").notNull(),
|
|
155
|
+
policyRole: text("policy_role"),
|
|
156
|
+
roleSource: text("role_source").notNull(),
|
|
157
|
+
entries: jsonb("entries").$type<WorkspaceInstructionPolicySnapshotEntry[]>().notNull(),
|
|
158
|
+
entryHash: text("entry_hash").notNull(),
|
|
159
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
160
|
+
},
|
|
161
|
+
(table) => ({
|
|
162
|
+
attempt: uniqueIndex("workspace_instruction_policy_snapshots_attempt_uq").on(
|
|
163
|
+
table.accountId,
|
|
164
|
+
table.workspaceId,
|
|
165
|
+
table.attemptId,
|
|
166
|
+
),
|
|
167
|
+
workspaceTimeline: index("workspace_instruction_policy_snapshots_workspace_time_idx").on(
|
|
168
|
+
table.workspaceId,
|
|
169
|
+
table.createdAt,
|
|
170
|
+
table.id,
|
|
171
|
+
),
|
|
172
|
+
entriesShape: check(
|
|
173
|
+
"workspace_instruction_policy_snapshots_entries_chk",
|
|
174
|
+
sql`jsonb_typeof(${table.entries}) = 'array' and jsonb_array_length(${table.entries}) <= 3`,
|
|
175
|
+
),
|
|
176
|
+
roleSourceValid: check(
|
|
177
|
+
"workspace_instruction_policy_snapshots_role_source_chk",
|
|
178
|
+
sql`${table.roleSource} in ('session_binding', 'metadata_fallback', 'none', 'invalid_metadata_fallback')`,
|
|
179
|
+
),
|
|
180
|
+
}),
|
|
181
|
+
);
|