@opengeni/db 1.3.0 → 1.4.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/canonical-human-identities.js +3 -3
- package/dist/capability-reconciliation.js +2 -2
- package/dist/{chunk-LRSIODCQ.js → chunk-27T4AP25.js} +2 -2
- package/dist/{chunk-VQX54TK3.js → chunk-3WUCM7YF.js} +3 -3
- package/dist/{chunk-BRVBLQAL.js → chunk-AMK3FNOW.js} +4 -4
- package/dist/{chunk-AQMH2ZEH.js → chunk-BA52ITIY.js} +2 -2
- package/dist/{chunk-6S5T6FSP.js → chunk-CKDHKQS4.js} +5 -5
- package/dist/{chunk-HYBQABBJ.js → chunk-EFANLEWI.js} +2 -2
- package/dist/{chunk-PNWWNEDM.js → chunk-EMYA6XNE.js} +2 -2
- package/dist/{chunk-6UKNJ43T.js → chunk-GC5GUTV3.js} +2 -2
- package/dist/{chunk-DSNEIW5E.js → chunk-JL6TBB76.js} +2 -1
- package/dist/chunk-JL6TBB76.js.map +1 -0
- package/dist/{chunk-OEFNTGR3.js → chunk-NT7OGKYX.js} +4 -4
- package/dist/{chunk-BID3CNN3.js → chunk-T3CYF3DH.js} +3 -3
- package/dist/{chunk-3XHC5KC5.js → chunk-TRUEH4I2.js} +2 -2
- package/dist/{chunk-7S3O33NM.js → chunk-UQELJ6OM.js} +2 -2
- package/dist/{chunk-OI6YZXTU.js → chunk-VCHK4D6S.js} +2 -2
- package/dist/connect-attempts.js +3 -3
- package/dist/connection-metadata.d.ts +416 -0
- package/dist/editable-artifact-durable-export.js +2 -2
- package/dist/editable-artifacts.js +3 -3
- package/dist/index.d.ts +28 -7
- package/dist/index.js +10304 -9877
- package/dist/index.js.map +1 -1
- package/dist/legacy-tool-action-reviews.d.ts +75 -0
- package/dist/managed-auth-session-sets.js +3 -3
- package/dist/mcp-operations.js +2 -2
- package/dist/organization-integration-policy.js +3 -3
- package/dist/organization-slack-bots.d.ts +1 -1
- package/dist/retained-provider-commands.js +6 -6
- package/dist/schema.js +1 -1
- package/dist/session-background-commands.js +4 -4
- package/dist/session-command-output.js +5 -5
- package/dist/session-event-slices.js +3 -3
- package/dist/session-mcp-credential-rotation.js +5 -5
- package/dist/session-realtime-billing.d.ts +48 -0
- package/dist/session-realtime.d.ts +8 -2
- package/dist/session-tenancy.js +3 -3
- package/dist/video-generation.js +3 -3
- package/dist/voice-transcription-settlement.d.ts +36 -0
- package/dist/workspace-tool-defaults.js +4 -4
- package/drizzle/0623_voice_transcription_attribution.sql +128 -0
- package/drizzle/0624_azure_live_realtime_model.sql +30 -0
- package/drizzle/0625_transcription_billing_refusal_codes.sql +29 -0
- package/package.json +7 -7
- package/src/connection-metadata.ts +166 -0
- package/src/index.ts +98 -165
- package/src/insights-unified.ts +2 -1
- package/src/legacy-tool-action-reviews.ts +242 -0
- package/src/organization-slack-bots.ts +1 -1
- package/src/schema.ts +1 -0
- package/src/session-realtime-billing.ts +135 -0
- package/src/session-realtime-ledger.ts +9 -4
- package/src/session-realtime.ts +20 -2
- package/src/tool-action-reviews.ts +129 -18
- package/src/voice-transcription-settlement.ts +94 -0
- package/dist/chunk-DSNEIW5E.js.map +0 -1
- /package/dist/{chunk-LRSIODCQ.js.map → chunk-27T4AP25.js.map} +0 -0
- /package/dist/{chunk-VQX54TK3.js.map → chunk-3WUCM7YF.js.map} +0 -0
- /package/dist/{chunk-BRVBLQAL.js.map → chunk-AMK3FNOW.js.map} +0 -0
- /package/dist/{chunk-AQMH2ZEH.js.map → chunk-BA52ITIY.js.map} +0 -0
- /package/dist/{chunk-6S5T6FSP.js.map → chunk-CKDHKQS4.js.map} +0 -0
- /package/dist/{chunk-HYBQABBJ.js.map → chunk-EFANLEWI.js.map} +0 -0
- /package/dist/{chunk-PNWWNEDM.js.map → chunk-EMYA6XNE.js.map} +0 -0
- /package/dist/{chunk-6UKNJ43T.js.map → chunk-GC5GUTV3.js.map} +0 -0
- /package/dist/{chunk-OEFNTGR3.js.map → chunk-NT7OGKYX.js.map} +0 -0
- /package/dist/{chunk-BID3CNN3.js.map → chunk-T3CYF3DH.js.map} +0 -0
- /package/dist/{chunk-3XHC5KC5.js.map → chunk-TRUEH4I2.js.map} +0 -0
- /package/dist/{chunk-7S3O33NM.js.map → chunk-UQELJ6OM.js.map} +0 -0
- /package/dist/{chunk-OI6YZXTU.js.map → chunk-VCHK4D6S.js.map} +0 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { and, desc, eq, gt, isNull, sql } from "drizzle-orm";
|
|
3
|
+
import { parseVerifiedAttemptToolCatalog } from "@opengeni/codemode";
|
|
4
|
+
import {
|
|
5
|
+
McpConnectionAccountBindings,
|
|
6
|
+
ToolActionReview,
|
|
7
|
+
decodeReviewArguments,
|
|
8
|
+
ToolDisplayMetadata,
|
|
9
|
+
stableJson,
|
|
10
|
+
toolReviewAction,
|
|
11
|
+
toolReviewContextFromSchema,
|
|
12
|
+
toolReviewFields,
|
|
13
|
+
type ToolReviewContext,
|
|
14
|
+
} from "@opengeni/contracts";
|
|
15
|
+
import { type Database, withRlsContext } from "./database";
|
|
16
|
+
import { fromPostgresLosslessJson } from "./lossless-json";
|
|
17
|
+
import * as schema from "./schema";
|
|
18
|
+
|
|
19
|
+
const object = (value: unknown): Record<string, unknown> | null =>
|
|
20
|
+
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
21
|
+
? (value as Record<string, unknown>)
|
|
22
|
+
: null;
|
|
23
|
+
|
|
24
|
+
function approval(value: unknown) {
|
|
25
|
+
const item = object(value);
|
|
26
|
+
const raw = object(item?.rawItem);
|
|
27
|
+
if (!item) return null;
|
|
28
|
+
return {
|
|
29
|
+
id: raw?.callId ?? raw?.id ?? item.id ?? item.callId,
|
|
30
|
+
name: item.name ?? item.toolName ?? raw?.name,
|
|
31
|
+
args: decodeReviewArguments(item.arguments ?? raw?.arguments),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Read-only presentation for SDK approvals created before durable connector
|
|
36
|
+
* reviews. Neither current catalogs nor display labels establish a provider.
|
|
37
|
+
* This never creates a request, changes a policy, or resumes an operation. */
|
|
38
|
+
export async function legacyToolActionReview(
|
|
39
|
+
db: Database,
|
|
40
|
+
input: { accountId: string; workspaceId: string; sessionId: string; approvalId: string },
|
|
41
|
+
) {
|
|
42
|
+
return await withRlsContext(db, input, async (tx) => {
|
|
43
|
+
const [snapshot] = await tx
|
|
44
|
+
.select({
|
|
45
|
+
state: {
|
|
46
|
+
id: schema.agentRunStates.id,
|
|
47
|
+
pending: schema.agentRunStates.pendingApprovals,
|
|
48
|
+
codec: schema.agentRunStates.pendingApprovalsCodecVersion,
|
|
49
|
+
},
|
|
50
|
+
turn: schema.sessionTurns,
|
|
51
|
+
session: { status: schema.sessions.status, activeTurnId: schema.sessions.activeTurnId },
|
|
52
|
+
})
|
|
53
|
+
.from(schema.agentRunStates)
|
|
54
|
+
.innerJoin(schema.sessionTurns, eq(schema.sessionTurns.id, schema.agentRunStates.turnId))
|
|
55
|
+
.innerJoin(schema.sessions, eq(schema.sessions.id, schema.agentRunStates.sessionId))
|
|
56
|
+
.where(
|
|
57
|
+
and(
|
|
58
|
+
eq(schema.agentRunStates.accountId, input.accountId),
|
|
59
|
+
eq(schema.agentRunStates.workspaceId, input.workspaceId),
|
|
60
|
+
eq(schema.agentRunStates.sessionId, input.sessionId),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
.orderBy(desc(schema.agentRunStates.stateVersion))
|
|
64
|
+
.limit(1);
|
|
65
|
+
if (!snapshot) return null;
|
|
66
|
+
const pending = fromPostgresLosslessJson(snapshot.state.pending, snapshot.state.codec);
|
|
67
|
+
const matching = Array.isArray(pending)
|
|
68
|
+
? pending.map(approval).filter((item) => item?.id === input.approvalId)
|
|
69
|
+
: [];
|
|
70
|
+
if (matching.length !== 1) return null;
|
|
71
|
+
const saved = matching[0]!;
|
|
72
|
+
if (typeof saved.name !== "string" || saved.args === null) return null;
|
|
73
|
+
const args = saved.args;
|
|
74
|
+
const { turn, session } = snapshot;
|
|
75
|
+
const scope = and(
|
|
76
|
+
eq(schema.sessionEvents.accountId, input.accountId),
|
|
77
|
+
eq(schema.sessionEvents.workspaceId, input.workspaceId),
|
|
78
|
+
eq(schema.sessionEvents.sessionId, input.sessionId),
|
|
79
|
+
eq(schema.sessionEvents.turnId, turn.id),
|
|
80
|
+
eq(schema.sessionEvents.turnAssociation, "current"),
|
|
81
|
+
isNull(schema.sessionEvents.duplicateOfEventId),
|
|
82
|
+
);
|
|
83
|
+
const [waiting] = await tx
|
|
84
|
+
.select()
|
|
85
|
+
.from(schema.sessionEvents)
|
|
86
|
+
.where(and(scope, eq(schema.sessionEvents.type, "session.requiresAction")))
|
|
87
|
+
.orderBy(desc(schema.sessionEvents.sequence))
|
|
88
|
+
.limit(1);
|
|
89
|
+
if (!waiting?.turnAttemptId || waiting.turnGeneration === null) return null;
|
|
90
|
+
const waitPayload = object(
|
|
91
|
+
fromPostgresLosslessJson(waiting.payload, waiting.payloadCodecVersion),
|
|
92
|
+
);
|
|
93
|
+
const waitApprovals = Array.isArray(waitPayload?.approvals)
|
|
94
|
+
? waitPayload.approvals.map(approval).filter((item) => item?.id === input.approvalId)
|
|
95
|
+
: [];
|
|
96
|
+
if (
|
|
97
|
+
waitApprovals.length !== 1 ||
|
|
98
|
+
waitApprovals[0]!.name !== saved.name ||
|
|
99
|
+
stableJson(waitApprovals[0]!.args) !== stableJson(args)
|
|
100
|
+
)
|
|
101
|
+
return null;
|
|
102
|
+
|
|
103
|
+
let toolName = saved.name;
|
|
104
|
+
let context: ToolReviewContext = { kind: "generic" };
|
|
105
|
+
// Only the immutable attempt catalog proves which fields its schema protects.
|
|
106
|
+
// Without that proof no argument value is shown and approval is unavailable.
|
|
107
|
+
let proven = false;
|
|
108
|
+
const calls = await tx
|
|
109
|
+
.select()
|
|
110
|
+
.from(schema.sessionEvents)
|
|
111
|
+
.where(
|
|
112
|
+
and(
|
|
113
|
+
scope,
|
|
114
|
+
eq(schema.sessionEvents.turnAttemptId, waiting.turnAttemptId),
|
|
115
|
+
eq(schema.sessionEvents.turnGeneration, waiting.turnGeneration),
|
|
116
|
+
eq(schema.sessionEvents.type, "agent.toolCall.created"),
|
|
117
|
+
sql`${schema.sessionEvents.payload}->>'id' = ${input.approvalId}`,
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
.limit(2);
|
|
121
|
+
if (calls.length === 1 && calls[0]!.sequence < waiting.sequence) {
|
|
122
|
+
const call = object(
|
|
123
|
+
fromPostgresLosslessJson(calls[0]!.payload, calls[0]!.payloadCodecVersion),
|
|
124
|
+
);
|
|
125
|
+
if (
|
|
126
|
+
call?.name === saved.name &&
|
|
127
|
+
stableJson(decodeReviewArguments(call.arguments)) === stableJson(args)
|
|
128
|
+
) {
|
|
129
|
+
const displayResult = ToolDisplayMetadata.safeParse(call.display);
|
|
130
|
+
const display = displayResult.success ? displayResult.data : undefined;
|
|
131
|
+
if (display)
|
|
132
|
+
context = {
|
|
133
|
+
kind: "generic",
|
|
134
|
+
title: (display.title ?? display.toolName.replaceAll("_", " ")).slice(0, 256),
|
|
135
|
+
...(display.accountLabel ? { accountLabel: display.accountLabel.slice(0, 256) } : {}),
|
|
136
|
+
};
|
|
137
|
+
const [stored] = await tx
|
|
138
|
+
.select()
|
|
139
|
+
.from(schema.sessionAttemptToolCatalogs)
|
|
140
|
+
.where(
|
|
141
|
+
and(
|
|
142
|
+
eq(schema.sessionAttemptToolCatalogs.accountId, input.accountId),
|
|
143
|
+
eq(schema.sessionAttemptToolCatalogs.workspaceId, input.workspaceId),
|
|
144
|
+
eq(schema.sessionAttemptToolCatalogs.sessionId, input.sessionId),
|
|
145
|
+
eq(schema.sessionAttemptToolCatalogs.turnId, turn.id),
|
|
146
|
+
eq(schema.sessionAttemptToolCatalogs.attemptId, waiting.turnAttemptId),
|
|
147
|
+
eq(schema.sessionAttemptToolCatalogs.executionGeneration, waiting.turnGeneration),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
.limit(1);
|
|
151
|
+
// The immutable model-name map supplies schema privacy and exact tool
|
|
152
|
+
// identity; the accepted account binding independently supplies provider.
|
|
153
|
+
if (stored) {
|
|
154
|
+
const catalog = parseVerifiedAttemptToolCatalog(stored.catalog);
|
|
155
|
+
const entry = catalog.entries.find((item) => item.modelName === saved.name);
|
|
156
|
+
if (entry) {
|
|
157
|
+
const bindings = McpConnectionAccountBindings.safeParse(turn.mcpAccountBindings);
|
|
158
|
+
const binding = bindings.success
|
|
159
|
+
? bindings.data.find((item) => item.serverId === entry.identity.serverId)
|
|
160
|
+
: undefined;
|
|
161
|
+
proven = true;
|
|
162
|
+
toolName = entry.identity.toolName;
|
|
163
|
+
context = toolReviewContextFromSchema(entry.inputSchema, {
|
|
164
|
+
kind:
|
|
165
|
+
entry.source === "mcp" && binding?.providerDomain === "gmailmcp.googleapis.com"
|
|
166
|
+
? "gmail"
|
|
167
|
+
: "generic",
|
|
168
|
+
...(entry.title ? { title: entry.title } : {}),
|
|
169
|
+
...(binding?.accountLabel
|
|
170
|
+
? { accountLabel: binding.accountLabel }
|
|
171
|
+
: context.accountLabel
|
|
172
|
+
? { accountLabel: context.accountLabel }
|
|
173
|
+
: {}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const [decision] = await tx
|
|
180
|
+
.select({ payload: schema.sessionEvents.payload })
|
|
181
|
+
.from(schema.sessionEvents)
|
|
182
|
+
.where(
|
|
183
|
+
and(
|
|
184
|
+
scope,
|
|
185
|
+
eq(schema.sessionEvents.type, "user.approvalDecision"),
|
|
186
|
+
gt(schema.sessionEvents.sequence, waiting.sequence),
|
|
187
|
+
sql`${schema.sessionEvents.payload}->>'approvalId' = ${input.approvalId}`,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
.orderBy(desc(schema.sessionEvents.sequence))
|
|
191
|
+
.limit(1);
|
|
192
|
+
const active =
|
|
193
|
+
!decision &&
|
|
194
|
+
turn.status === "requires_action" &&
|
|
195
|
+
session.status === "requires_action" &&
|
|
196
|
+
session.activeTurnId === turn.id;
|
|
197
|
+
const status = decision
|
|
198
|
+
? object(decision.payload)?.decision === "reject"
|
|
199
|
+
? "rejected"
|
|
200
|
+
: "approved"
|
|
201
|
+
: active
|
|
202
|
+
? "pending"
|
|
203
|
+
: "cancelled";
|
|
204
|
+
const actionDigest = createHash("sha256")
|
|
205
|
+
.update(
|
|
206
|
+
stableJson({
|
|
207
|
+
accountId: input.accountId,
|
|
208
|
+
workspaceId: input.workspaceId,
|
|
209
|
+
sessionId: input.sessionId,
|
|
210
|
+
turnId: turn.id,
|
|
211
|
+
approvalId: input.approvalId,
|
|
212
|
+
name: saved.name,
|
|
213
|
+
args,
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
216
|
+
.digest("hex");
|
|
217
|
+
return {
|
|
218
|
+
args,
|
|
219
|
+
context,
|
|
220
|
+
proven,
|
|
221
|
+
review: ToolActionReview.parse({
|
|
222
|
+
version: 1,
|
|
223
|
+
id: input.approvalId,
|
|
224
|
+
actionDigest,
|
|
225
|
+
revision: `${snapshot.state.id}:${turn.updatedAt.toISOString()}:${status}`,
|
|
226
|
+
status,
|
|
227
|
+
...(proven
|
|
228
|
+
? {
|
|
229
|
+
...toolReviewAction(toolName, args, context),
|
|
230
|
+
...toolReviewFields(args, context),
|
|
231
|
+
}
|
|
232
|
+
: { ...toolReviewAction(toolName, null, context), fields: [], moreFields: 0 }),
|
|
233
|
+
...(context.accountLabel ? { accountLabel: context.accountLabel } : {}),
|
|
234
|
+
reason: "This action asks first.",
|
|
235
|
+
createdAt: waiting.createdAt.toISOString(),
|
|
236
|
+
updatedAt: turn.updatedAt.toISOString(),
|
|
237
|
+
availableActions: active ? (proven ? ["approve", "reject"] : ["reject"]) : [],
|
|
238
|
+
detailsAvailable: proven,
|
|
239
|
+
}),
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
2
|
import { rawRows, setSubjectRlsContext, withRlsContext, type Database } from "./database";
|
|
3
|
-
import { getConnectionMetadata, listConnectionsMetadata } from "./
|
|
3
|
+
import { getConnectionMetadata, listConnectionsMetadata } from "./connection-metadata";
|
|
4
4
|
|
|
5
5
|
export type SlackBotAccess = {
|
|
6
6
|
connectionId: string;
|
package/src/schema.ts
CHANGED
|
@@ -5293,6 +5293,7 @@ export const sessionRealtimeModes = pgTable(
|
|
|
5293
5293
|
modelValid: check(
|
|
5294
5294
|
"session_realtime_modes_model_check",
|
|
5295
5295
|
sql`${table.model} in (
|
|
5296
|
+
'opengeni-azure/gpt-live-1',
|
|
5296
5297
|
'gpt-live-1-boulder-alpha',
|
|
5297
5298
|
'supergrok/grok-voice-think-fast-2.0',
|
|
5298
5299
|
'opengeni-gateway/openai/gpt-realtime-2.1',
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { and, eq, inArray, isNotNull } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import type { SessionRealtimeEndReason, SessionRealtimeModel } from "@opengeni/contracts";
|
|
4
|
+
import { withRlsContext, withWorkspaceRls, type Database } from "./database";
|
|
5
|
+
import * as schema from "./schema";
|
|
6
|
+
|
|
7
|
+
/** One provider connection of a live-voice mode, bounded by server observation. */
|
|
8
|
+
export type SessionRealtimeBillableConnection = {
|
|
9
|
+
id: string;
|
|
10
|
+
startedAt: Date;
|
|
11
|
+
observedUntil: Date;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SessionRealtimeBillingFacts = {
|
|
15
|
+
accountId: string;
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
sessionId: string;
|
|
18
|
+
realtimeId: string;
|
|
19
|
+
model: SessionRealtimeModel;
|
|
20
|
+
ownerSubjectId: string;
|
|
21
|
+
state: "active" | "ended";
|
|
22
|
+
connections: SessionRealtimeBillableConnection[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The last instant the server itself observed the voice owner alive. An
|
|
27
|
+
* explicit owner-proven end is observed at `endedAt`; an expired lease only up
|
|
28
|
+
* to its last heartbeat (late lazy settlement never extends the bill); an
|
|
29
|
+
* active mode up to `now`, bounded by its lease.
|
|
30
|
+
*/
|
|
31
|
+
export function sessionRealtimeObservedUntil(
|
|
32
|
+
mode: {
|
|
33
|
+
state: string;
|
|
34
|
+
endReason: string | null;
|
|
35
|
+
endedAt: Date | null;
|
|
36
|
+
lastHeartbeatAt: Date;
|
|
37
|
+
leaseExpiresAt: Date;
|
|
38
|
+
},
|
|
39
|
+
now: Date,
|
|
40
|
+
): Date {
|
|
41
|
+
if (mode.state === "ended") {
|
|
42
|
+
const reason = mode.endReason as SessionRealtimeEndReason | null;
|
|
43
|
+
if (reason !== "lease_expired" && mode.endedAt) return mode.endedAt;
|
|
44
|
+
return mode.lastHeartbeatAt;
|
|
45
|
+
}
|
|
46
|
+
return new Date(Math.min(now.getTime(), mode.leaseExpiresAt.getTime()));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Server-observed lifetime of every provider connection that was actually
|
|
51
|
+
* issued (an SDP answer or a minted client secret was returned). Failed or
|
|
52
|
+
* never-completed negotiations issued nothing billable.
|
|
53
|
+
*/
|
|
54
|
+
export async function loadSessionRealtimeBillingFacts(
|
|
55
|
+
db: Database,
|
|
56
|
+
input: { workspaceId: string; sessionId: string; realtimeId: string; now?: Date },
|
|
57
|
+
): Promise<SessionRealtimeBillingFacts | null> {
|
|
58
|
+
const now = input.now ?? new Date();
|
|
59
|
+
return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
|
|
60
|
+
const [mode] = await scopedDb
|
|
61
|
+
.select()
|
|
62
|
+
.from(schema.sessionRealtimeModes)
|
|
63
|
+
.where(
|
|
64
|
+
and(
|
|
65
|
+
eq(schema.sessionRealtimeModes.workspaceId, input.workspaceId),
|
|
66
|
+
eq(schema.sessionRealtimeModes.sessionId, input.sessionId),
|
|
67
|
+
eq(schema.sessionRealtimeModes.id, input.realtimeId),
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
.limit(1);
|
|
71
|
+
if (!mode) return null;
|
|
72
|
+
const observedUntil = sessionRealtimeObservedUntil(mode, now);
|
|
73
|
+
const rows = await scopedDb
|
|
74
|
+
.select({
|
|
75
|
+
id: schema.sessionRealtimeConnections.id,
|
|
76
|
+
createdAt: schema.sessionRealtimeConnections.createdAt,
|
|
77
|
+
closedAt: schema.sessionRealtimeConnections.closedAt,
|
|
78
|
+
})
|
|
79
|
+
.from(schema.sessionRealtimeConnections)
|
|
80
|
+
.where(
|
|
81
|
+
and(
|
|
82
|
+
eq(schema.sessionRealtimeConnections.realtimeId, mode.id),
|
|
83
|
+
isNotNull(schema.sessionRealtimeConnections.sdpAnswer),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
return {
|
|
87
|
+
accountId: mode.accountId,
|
|
88
|
+
workspaceId: mode.workspaceId,
|
|
89
|
+
sessionId: mode.sessionId,
|
|
90
|
+
realtimeId: mode.id,
|
|
91
|
+
model: mode.model as SessionRealtimeModel,
|
|
92
|
+
ownerSubjectId: mode.ownerSubjectId,
|
|
93
|
+
state: mode.state as "active" | "ended",
|
|
94
|
+
connections: rows
|
|
95
|
+
.map((row) => {
|
|
96
|
+
const closed = row.closedAt ?? observedUntil;
|
|
97
|
+
return {
|
|
98
|
+
id: row.id,
|
|
99
|
+
startedAt: row.createdAt,
|
|
100
|
+
observedUntil: new Date(
|
|
101
|
+
Math.max(
|
|
102
|
+
row.createdAt.getTime(),
|
|
103
|
+
Math.min(closed.getTime(), observedUntil.getTime()),
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
})
|
|
108
|
+
.sort((left, right) => left.startedAt.getTime() - right.startedAt.getTime()),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Which of these usage idempotency keys are already recorded for the account. */
|
|
114
|
+
export async function existingUsageEventIdempotencyKeys(
|
|
115
|
+
db: Database,
|
|
116
|
+
input: { accountId: string; workspaceId: string; keys: readonly string[] },
|
|
117
|
+
): Promise<Set<string>> {
|
|
118
|
+
if (input.keys.length === 0) return new Set();
|
|
119
|
+
return await withRlsContext(
|
|
120
|
+
db,
|
|
121
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
122
|
+
async (scopedDb) => {
|
|
123
|
+
const rows = await scopedDb
|
|
124
|
+
.select({ key: schema.usageEvents.idempotencyKey })
|
|
125
|
+
.from(schema.usageEvents)
|
|
126
|
+
.where(
|
|
127
|
+
and(
|
|
128
|
+
eq(schema.usageEvents.accountId, input.accountId),
|
|
129
|
+
inArray(schema.usageEvents.idempotencyKey, [...input.keys]),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
return new Set(rows.map((row) => row.key));
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -1176,10 +1176,15 @@ async function admitRealtimeDelegationInTransaction(
|
|
|
1176
1176
|
delegationItemId: incoming.delegationItemId!,
|
|
1177
1177
|
ledgerEntryId: entryId,
|
|
1178
1178
|
};
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1179
|
+
// Some providers (Azure Live) delegate without echoing the user's words. The
|
|
1180
|
+
// work instructions in `incoming.text` still carry the transcript delta, so a
|
|
1181
|
+
// missing echo only changes the display label; it must never fail the sync
|
|
1182
|
+
// batch, which the browser would retry forever.
|
|
1183
|
+
const rawInputTranscript = incoming.payload?.inputTranscript;
|
|
1184
|
+
const inputTranscript =
|
|
1185
|
+
typeof rawInputTranscript === "string" && rawInputTranscript.trim().length > 0
|
|
1186
|
+
? rawInputTranscript
|
|
1187
|
+
: "Voice request";
|
|
1183
1188
|
const admitted = await submitHumanPromptInTransaction(db, {
|
|
1184
1189
|
accountId,
|
|
1185
1190
|
workspaceId: input.workspaceId,
|
package/src/session-realtime.ts
CHANGED
|
@@ -88,13 +88,22 @@ export type RenewSessionRealtimeInput = {
|
|
|
88
88
|
expectedVersion: number;
|
|
89
89
|
now?: Date;
|
|
90
90
|
leaseMs?: number;
|
|
91
|
+
/**
|
|
92
|
+
* False keeps the current lease unchanged (owner still proven). Used when a
|
|
93
|
+
* deployment-funded call ran out of credits: the client drains and ends
|
|
94
|
+
* inside the remaining lease, and an ignoring client lapses on its own.
|
|
95
|
+
*/
|
|
96
|
+
extendLease?: boolean;
|
|
91
97
|
};
|
|
92
98
|
|
|
93
|
-
export type EndSessionRealtimeInput = Omit<RenewSessionRealtimeInput, "leaseMs"> & {
|
|
99
|
+
export type EndSessionRealtimeInput = Omit<RenewSessionRealtimeInput, "leaseMs" | "extendLease"> & {
|
|
94
100
|
reason: Extract<SessionRealtimeEndReason, "user_stop" | "browser_unload">;
|
|
95
101
|
};
|
|
96
102
|
|
|
97
|
-
export type AssertSessionRealtimeOwnerInput = Omit<
|
|
103
|
+
export type AssertSessionRealtimeOwnerInput = Omit<
|
|
104
|
+
RenewSessionRealtimeInput,
|
|
105
|
+
"leaseMs" | "extendLease"
|
|
106
|
+
>;
|
|
98
107
|
|
|
99
108
|
export type SessionRealtimeMutationResult = {
|
|
100
109
|
mode: SessionRealtimeMode;
|
|
@@ -594,6 +603,15 @@ export async function renewSessionRealtimeInTransaction(
|
|
|
594
603
|
expired: true,
|
|
595
604
|
};
|
|
596
605
|
}
|
|
606
|
+
if (input.extendLease === false) {
|
|
607
|
+
return {
|
|
608
|
+
mode: mapRealtimeMode(row),
|
|
609
|
+
replay: false,
|
|
610
|
+
eventIds: [],
|
|
611
|
+
workflowWakeRevision: null,
|
|
612
|
+
expired: false,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
597
615
|
const [renewed] = await db
|
|
598
616
|
.update(schema.sessionRealtimeModes)
|
|
599
617
|
.set({
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { and, desc, eq } from "drizzle-orm";
|
|
2
|
+
import { parseVerifiedAttemptToolCatalog } from "@opengeni/codemode";
|
|
2
3
|
import {
|
|
4
|
+
McpConnectionAccountBindings,
|
|
3
5
|
ToolActionReview,
|
|
6
|
+
toolReviewContextFromSchema,
|
|
7
|
+
type ToolReviewContext,
|
|
4
8
|
toolReviewAction,
|
|
5
9
|
toolReviewDetails,
|
|
6
10
|
toolReviewFields,
|
|
@@ -11,6 +15,7 @@ import { type Database, withRlsContext } from "./database";
|
|
|
11
15
|
import * as schema from "./schema";
|
|
12
16
|
import { connectorActionFingerprint } from "./connector-action-fingerprint";
|
|
13
17
|
import { fromPostgresLosslessJson } from "./lossless-json";
|
|
18
|
+
import { legacyToolActionReview } from "./legacy-tool-action-reviews";
|
|
14
19
|
|
|
15
20
|
type ReviewScope = {
|
|
16
21
|
accountId: string;
|
|
@@ -65,10 +70,17 @@ async function reviewSnapshot(db: Database, input: ReviewScope) {
|
|
|
65
70
|
.orderBy(desc(schema.agentRunStates.stateVersion))
|
|
66
71
|
.limit(32);
|
|
67
72
|
for (const state of states) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
// Recovery is best effort: one unreadable state must not hide the
|
|
74
|
+
// decision. Unrecovered arguments leave decline as the only response.
|
|
75
|
+
let pending: unknown;
|
|
76
|
+
try {
|
|
77
|
+
pending = fromPostgresLosslessJson(
|
|
78
|
+
state.pendingApprovals,
|
|
79
|
+
state.pendingApprovalsCodecVersion,
|
|
80
|
+
);
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
72
84
|
if (!Array.isArray(pending)) continue;
|
|
73
85
|
for (const value of pending) {
|
|
74
86
|
if (!value || typeof value !== "object") continue;
|
|
@@ -86,34 +98,109 @@ async function reviewSnapshot(db: Database, input: ReviewScope) {
|
|
|
86
98
|
continue;
|
|
87
99
|
}
|
|
88
100
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
record.request
|
|
92
|
-
|
|
101
|
+
let fingerprint: string;
|
|
102
|
+
try {
|
|
103
|
+
fingerprint = connectorActionFingerprint({ ...record.request, arguments: args });
|
|
104
|
+
} catch {
|
|
93
105
|
continue;
|
|
106
|
+
}
|
|
107
|
+
if (fingerprint !== record.request.actionFingerprint) continue;
|
|
94
108
|
record.request.reviewArguments = JSON.stringify(args);
|
|
95
109
|
break;
|
|
96
110
|
}
|
|
97
111
|
if (record.request.reviewArguments !== null) break;
|
|
98
112
|
}
|
|
113
|
+
// Recovered bytes predate the saved review context, so the context that
|
|
114
|
+
// hides schema-protected fields must be proven from the immutable catalog of
|
|
115
|
+
// the attempt that created the request. Without that proof the arguments
|
|
116
|
+
// stay hidden and decline remains the only response.
|
|
117
|
+
if (record.request.reviewArguments !== null && record.request.reviewContext === null) {
|
|
118
|
+
const context = await catalogReviewContext(scoped, input, record);
|
|
119
|
+
if (context) record.request.reviewContext = context;
|
|
120
|
+
else record.request.reviewArguments = null;
|
|
121
|
+
}
|
|
99
122
|
}
|
|
100
123
|
return record;
|
|
101
124
|
});
|
|
102
125
|
}
|
|
103
126
|
|
|
127
|
+
async function catalogReviewContext(
|
|
128
|
+
scoped: Database,
|
|
129
|
+
input: ReviewScope,
|
|
130
|
+
record: {
|
|
131
|
+
request: typeof schema.connectorActionRequests.$inferSelect;
|
|
132
|
+
turn: typeof schema.sessionTurns.$inferSelect;
|
|
133
|
+
},
|
|
134
|
+
): Promise<ToolReviewContext | null> {
|
|
135
|
+
try {
|
|
136
|
+
const { request, turn } = record;
|
|
137
|
+
const [stored] = await scoped
|
|
138
|
+
.select({ catalog: schema.sessionAttemptToolCatalogs.catalog })
|
|
139
|
+
.from(schema.sessionAttemptToolCatalogs)
|
|
140
|
+
.where(
|
|
141
|
+
and(
|
|
142
|
+
eq(schema.sessionAttemptToolCatalogs.accountId, input.accountId),
|
|
143
|
+
eq(schema.sessionAttemptToolCatalogs.workspaceId, input.workspaceId),
|
|
144
|
+
eq(schema.sessionAttemptToolCatalogs.sessionId, input.sessionId),
|
|
145
|
+
eq(schema.sessionAttemptToolCatalogs.turnId, request.turnId),
|
|
146
|
+
eq(schema.sessionAttemptToolCatalogs.attemptId, request.creationAttemptId),
|
|
147
|
+
eq(
|
|
148
|
+
schema.sessionAttemptToolCatalogs.executionGeneration,
|
|
149
|
+
request.creationExecutionGeneration,
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
.limit(1);
|
|
154
|
+
if (!stored) return null;
|
|
155
|
+
const entries = parseVerifiedAttemptToolCatalog(stored.catalog).entries.filter(
|
|
156
|
+
(item) =>
|
|
157
|
+
item.identity.serverId === request.serverId && item.identity.toolName === request.toolName,
|
|
158
|
+
);
|
|
159
|
+
if (entries.length !== 1) return null;
|
|
160
|
+
const entry = entries[0]!;
|
|
161
|
+
const bindings = McpConnectionAccountBindings.safeParse(turn.mcpAccountBindings);
|
|
162
|
+
const binding = bindings.success
|
|
163
|
+
? bindings.data.find((item) => item.serverId === request.serverId)
|
|
164
|
+
: undefined;
|
|
165
|
+
return toolReviewContextFromSchema(entry.inputSchema, {
|
|
166
|
+
kind:
|
|
167
|
+
entry.source === "mcp" && binding?.providerDomain === "gmailmcp.googleapis.com"
|
|
168
|
+
? "gmail"
|
|
169
|
+
: "generic",
|
|
170
|
+
...(entry.title ? { title: entry.title } : {}),
|
|
171
|
+
...(binding?.accountLabel ? { accountLabel: binding.accountLabel } : {}),
|
|
172
|
+
});
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Older approvals have no saved review. Their presentation is best effort: any
|
|
180
|
+
* failure to rebuild it answers "no saved review", and clients then fall back to
|
|
181
|
+
* the approval's own arguments instead of hiding the decision.
|
|
182
|
+
*/
|
|
183
|
+
async function presentableLegacyReview(db: Database, input: ReviewScope) {
|
|
184
|
+
try {
|
|
185
|
+
return await legacyToolActionReview(db, input);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
104
191
|
/** The HTTP/core caller must independently authorize this session. RLS remains active here. */
|
|
105
192
|
export async function getToolActionReview(
|
|
106
193
|
db: Database,
|
|
107
194
|
input: ReviewScope,
|
|
108
195
|
): Promise<ToolActionReview | null> {
|
|
109
196
|
const record = await reviewSnapshot(db, input);
|
|
110
|
-
if (!record) return null;
|
|
197
|
+
if (!record) return (await presentableLegacyReview(db, input))?.review ?? null;
|
|
111
198
|
const { request, operation, turn } = record;
|
|
112
199
|
let status: ToolReviewStatus =
|
|
113
200
|
request.status === "uncertain"
|
|
114
201
|
? "unknown"
|
|
115
202
|
: request.status === "blocked"
|
|
116
|
-
? "
|
|
203
|
+
? "blocked"
|
|
117
204
|
: request.status;
|
|
118
205
|
if (operation?.state === "outcome_unknown") status = "unknown";
|
|
119
206
|
else if (operation?.state === "cancelled")
|
|
@@ -131,8 +218,10 @@ export async function getToolActionReview(
|
|
|
131
218
|
["pending", "approved"].includes(status)
|
|
132
219
|
)
|
|
133
220
|
status = "cancelled";
|
|
134
|
-
|
|
135
|
-
|
|
221
|
+
// A person can always decline a waiting action. Approving needs the exact saved
|
|
222
|
+
// arguments; when an older request's arguments cannot be recovered, decline is
|
|
223
|
+
// the only safe decision and keeps the session from staying stuck.
|
|
224
|
+
const waiting = status === "pending" && turn.status === "requires_action";
|
|
136
225
|
const args = request.reviewArguments;
|
|
137
226
|
const context = request.reviewContext ?? undefined;
|
|
138
227
|
const parsed = decodeReviewArguments(args);
|
|
@@ -157,14 +246,22 @@ export async function getToolActionReview(
|
|
|
157
246
|
: {}),
|
|
158
247
|
...toolReviewFields(args, context),
|
|
159
248
|
reason:
|
|
160
|
-
|
|
161
|
-
?
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
249
|
+
status === "blocked"
|
|
250
|
+
? request.policySource === "explicit"
|
|
251
|
+
? "Your permission settings block this action."
|
|
252
|
+
: request.policySource === "ambiguous"
|
|
253
|
+
? "Two permission settings conflict, so this action is blocked."
|
|
254
|
+
: "This action is blocked by default."
|
|
255
|
+
: request.policySource === "explicit"
|
|
256
|
+
? "This action is set to Ask first."
|
|
257
|
+
: "This action asks first by default.",
|
|
165
258
|
createdAt: request.createdAt.toISOString(),
|
|
166
259
|
updatedAt: (operation?.updatedAt ?? request.updatedAt).toISOString(),
|
|
167
|
-
availableActions:
|
|
260
|
+
availableActions: waiting
|
|
261
|
+
? request.reviewArguments !== null
|
|
262
|
+
? ["approve", "reject"]
|
|
263
|
+
: ["reject"]
|
|
264
|
+
: [],
|
|
168
265
|
detailsAvailable: args !== null,
|
|
169
266
|
});
|
|
170
267
|
}
|
|
@@ -174,6 +271,20 @@ export async function getToolReviewDetailsPage(
|
|
|
174
271
|
input: ReviewScope & { actionDigest: string; path: string; offset: number },
|
|
175
272
|
) {
|
|
176
273
|
const record = await reviewSnapshot(db, input);
|
|
274
|
+
if (!record) {
|
|
275
|
+
const legacy = await presentableLegacyReview(db, input);
|
|
276
|
+
if (!legacy?.proven || legacy.review.actionDigest !== input.actionDigest) return null;
|
|
277
|
+
try {
|
|
278
|
+
return {
|
|
279
|
+
version: 1 as const,
|
|
280
|
+
id: input.approvalId,
|
|
281
|
+
actionDigest: input.actionDigest,
|
|
282
|
+
...toolReviewDetails(legacy.args, legacy.context, input.path, input.offset),
|
|
283
|
+
};
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
177
288
|
if (
|
|
178
289
|
!record ||
|
|
179
290
|
record.request.actionFingerprint !== input.actionDigest ||
|