@opengeni/db 1.3.0 → 1.4.2
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-7QWWTVYH.js → chunk-5ROHLEN6.js} +29 -4
- package/dist/chunk-5ROHLEN6.js.map +1 -0
- package/dist/{chunk-AQMH2ZEH.js → chunk-A4J7EECR.js} +2 -2
- package/dist/{chunk-VQX54TK3.js → chunk-B5C5SBSI.js} +3 -3
- package/dist/{chunk-OI6YZXTU.js → chunk-DKFEUGM4.js} +2 -2
- package/dist/{chunk-HYBQABBJ.js → chunk-HUKBIZGH.js} +2 -2
- package/dist/{chunk-LRSIODCQ.js → chunk-IT3EYY2C.js} +6 -4
- package/dist/{chunk-LRSIODCQ.js.map → chunk-IT3EYY2C.js.map} +1 -1
- package/dist/{chunk-DSNEIW5E.js → chunk-JL6TBB76.js} +2 -1
- package/dist/chunk-JL6TBB76.js.map +1 -0
- package/dist/{chunk-OEFNTGR3.js → chunk-JWBEE4CL.js} +4 -4
- package/dist/{chunk-BRVBLQAL.js → chunk-K5MTNKJU.js} +4 -4
- package/dist/{chunk-6UKNJ43T.js → chunk-NZ2SP55O.js} +2 -2
- package/dist/{chunk-3XHC5KC5.js → chunk-OKCBARX6.js} +2 -2
- package/dist/{chunk-PNWWNEDM.js → chunk-OV5AUIBA.js} +2 -2
- package/dist/{chunk-BID3CNN3.js → chunk-TODVDGMO.js} +36 -18
- package/dist/{chunk-BID3CNN3.js.map → chunk-TODVDGMO.js.map} +1 -1
- package/dist/{chunk-6S5T6FSP.js → chunk-W75DRYG3.js} +5 -5
- package/dist/{chunk-7S3O33NM.js → chunk-Y72DP3JF.js} +2 -2
- package/dist/codemode-approvals.d.ts +7 -1
- package/dist/connect-attempts.js +3 -3
- package/dist/connection-metadata.d.ts +416 -0
- package/dist/credit-balances.d.ts +25 -4
- package/dist/database.d.ts +7 -1
- package/dist/editable-artifact-durable-export.js +2 -2
- package/dist/editable-artifacts.js +3 -3
- package/dist/index.d.ts +51 -8
- package/dist/index.js +10826 -10129
- 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/modal-native-live-origin.d.ts +75 -0
- package/dist/organization-integration-policy.js +3 -3
- package/dist/organization-slack-bots.d.ts +1 -1
- package/dist/provision-roles.js +1 -1
- package/dist/retained-provider-commands.d.ts +21 -21
- package/dist/retained-provider-commands.js +6 -6
- package/dist/runtime-posture.d.ts +1 -1
- package/dist/sandbox-interaction-idle.d.ts +22 -0
- 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/drizzle/0632_modal_native_live_origin.sql +239 -0
- package/package.json +7 -7
- package/src/codemode-approvals.ts +52 -22
- package/src/codemode-operations.ts +198 -181
- package/src/connection-metadata.ts +166 -0
- package/src/credit-balances.ts +58 -17
- package/src/database.ts +9 -0
- package/src/index.ts +540 -412
- package/src/insights-unified.ts +2 -1
- package/src/legacy-tool-action-reviews.ts +242 -0
- package/src/modal-native-live-origin.ts +148 -0
- package/src/organization-slack-bots.ts +1 -1
- package/src/provision-roles.ts +12 -0
- package/src/runtime-posture.ts +32 -3
- package/src/sandbox-interaction-idle.ts +91 -0
- 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/transcription-recordings.ts +3 -1
- package/src/voice-transcription-settlement.ts +94 -0
- package/dist/chunk-7QWWTVYH.js.map +0 -1
- package/dist/chunk-DSNEIW5E.js.map +0 -1
- /package/dist/{chunk-AQMH2ZEH.js.map → chunk-A4J7EECR.js.map} +0 -0
- /package/dist/{chunk-VQX54TK3.js.map → chunk-B5C5SBSI.js.map} +0 -0
- /package/dist/{chunk-OI6YZXTU.js.map → chunk-DKFEUGM4.js.map} +0 -0
- /package/dist/{chunk-HYBQABBJ.js.map → chunk-HUKBIZGH.js.map} +0 -0
- /package/dist/{chunk-OEFNTGR3.js.map → chunk-JWBEE4CL.js.map} +0 -0
- /package/dist/{chunk-BRVBLQAL.js.map → chunk-K5MTNKJU.js.map} +0 -0
- /package/dist/{chunk-6UKNJ43T.js.map → chunk-NZ2SP55O.js.map} +0 -0
- /package/dist/{chunk-3XHC5KC5.js.map → chunk-OKCBARX6.js.map} +0 -0
- /package/dist/{chunk-PNWWNEDM.js.map → chunk-OV5AUIBA.js.map} +0 -0
- /package/dist/{chunk-6S5T6FSP.js.map → chunk-W75DRYG3.js.map} +0 -0
- /package/dist/{chunk-7S3O33NM.js.map → chunk-Y72DP3JF.js.map} +0 -0
|
@@ -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 ||
|
|
@@ -1087,7 +1087,9 @@ export function assembleTranscriptionSegments(
|
|
|
1087
1087
|
).trim(),
|
|
1088
1088
|
)
|
|
1089
1089
|
.filter(Boolean)
|
|
1090
|
-
|
|
1090
|
+
// Segments are fixed-length time cuts, not paragraphs: a boundary usually
|
|
1091
|
+
// falls mid-sentence, so a long dictation must read as one continuous text.
|
|
1092
|
+
.join(" ");
|
|
1091
1093
|
const seen = new Set<string>();
|
|
1092
1094
|
const combinedLanguages: string[] = [];
|
|
1093
1095
|
for (const segment of ordered) {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { CreditDebitAttribution } from "./credit-debit-attribution";
|
|
3
|
+
import type { Database } from "./database";
|
|
4
|
+
import { rawRows, withRlsContext } from "./database";
|
|
5
|
+
|
|
6
|
+
/** Usage source and credit debit type of deployment-funded voice transcription. */
|
|
7
|
+
export const VOICE_TRANSCRIPTION_SOURCE_TYPE = "voice_transcription";
|
|
8
|
+
export const VOICE_TRANSCRIPTION_DEBIT_TYPE = "voice_transcription_debit";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Idempotency keys for one settled voice-transcription unit. Both derive from
|
|
12
|
+
* the workspace and the server-built unit id only, so an inline settlement, its
|
|
13
|
+
* in-process retry, and a later reconciliation converge on the same rows.
|
|
14
|
+
*/
|
|
15
|
+
export function voiceTranscriptionSettlementKeys(input: {
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
sourceId: string;
|
|
18
|
+
}): { usageIdempotencyKey: string; debitIdempotencyKey: string } {
|
|
19
|
+
return {
|
|
20
|
+
usageIdempotencyKey: `voice.transcription_cost:${input.sourceId}`,
|
|
21
|
+
debitIdempotencyKey: `credit:${VOICE_TRANSCRIPTION_DEBIT_TYPE}:voice_input:${input.workspaceId}:${input.sourceId}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type UnsettledVoiceTranscriptionCharge = {
|
|
26
|
+
sourceId: string;
|
|
27
|
+
amountMicros: number;
|
|
28
|
+
attribution: CreditDebitAttribution;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Voice usage receipts in one workspace whose credit debit never committed
|
|
33
|
+
* (the debit transaction failed after the receipt was durable). Bounded and
|
|
34
|
+
* ordered oldest first; `minAgeMilliseconds` leaves an in-flight inline
|
|
35
|
+
* settlement to finish with its full ledger metadata first. The scan walks the
|
|
36
|
+
* workspace's `model.cost` index range, so the window is short (3 days) and
|
|
37
|
+
* the statement is capped at 2 s; a timeout only defers reconciliation.
|
|
38
|
+
*/
|
|
39
|
+
export async function listUnsettledVoiceTranscriptionCharges(
|
|
40
|
+
db: Database,
|
|
41
|
+
input: {
|
|
42
|
+
accountId: string;
|
|
43
|
+
workspaceId: string;
|
|
44
|
+
limit?: number;
|
|
45
|
+
minAgeMilliseconds?: number;
|
|
46
|
+
},
|
|
47
|
+
): Promise<UnsettledVoiceTranscriptionCharge[]> {
|
|
48
|
+
const limit = Math.max(1, Math.min(input.limit ?? 20, 100));
|
|
49
|
+
const minAgeSeconds = Math.max(0, Math.floor((input.minAgeMilliseconds ?? 60_000) / 1_000));
|
|
50
|
+
return await withRlsContext(
|
|
51
|
+
db,
|
|
52
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
53
|
+
async (scopedDb) => {
|
|
54
|
+
await scopedDb.execute(sql`select set_config('statement_timeout', '2000ms', true)`);
|
|
55
|
+
const rows = await rawRows<{
|
|
56
|
+
source_id: string;
|
|
57
|
+
quantity: string | number;
|
|
58
|
+
attribution: unknown;
|
|
59
|
+
}>(
|
|
60
|
+
scopedDb,
|
|
61
|
+
sql`
|
|
62
|
+
select usage_row.source_resource_id as source_id,
|
|
63
|
+
usage_row.quantity,
|
|
64
|
+
usage_row.initiator_context -> 'creditDebitAttribution' as attribution
|
|
65
|
+
from usage_events usage_row
|
|
66
|
+
where usage_row.account_id = ${input.accountId}::uuid
|
|
67
|
+
and usage_row.workspace_id = ${input.workspaceId}::uuid
|
|
68
|
+
and usage_row.event_type = 'model.cost'
|
|
69
|
+
and usage_row.source_resource_type = ${VOICE_TRANSCRIPTION_SOURCE_TYPE}
|
|
70
|
+
and usage_row.source_resource_id is not null
|
|
71
|
+
and usage_row.quantity > 0
|
|
72
|
+
and usage_row.idempotency_key = 'voice.transcription_cost:' || usage_row.source_resource_id
|
|
73
|
+
and usage_row.occurred_at >= now() - interval '3 days'
|
|
74
|
+
and usage_row.occurred_at <= now() - make_interval(secs => ${minAgeSeconds})
|
|
75
|
+
and not exists (
|
|
76
|
+
select 1 from credit_ledger_entries ledger
|
|
77
|
+
where ledger.idempotency_key = ${`credit:${VOICE_TRANSCRIPTION_DEBIT_TYPE}:voice_input:`}
|
|
78
|
+
|| usage_row.workspace_id::text || ':' || usage_row.source_resource_id
|
|
79
|
+
)
|
|
80
|
+
order by usage_row.occurred_at asc
|
|
81
|
+
limit ${limit}
|
|
82
|
+
`,
|
|
83
|
+
);
|
|
84
|
+
return rows.map((row) => {
|
|
85
|
+
const attribution = CreditDebitAttribution.safeParse(row.attribution);
|
|
86
|
+
return {
|
|
87
|
+
sourceId: row.source_id,
|
|
88
|
+
amountMicros: Number(row.quantity),
|
|
89
|
+
attribution: attribution.success ? attribution.data : { kind: "unknown" as const },
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
}
|