@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.
Files changed (91) hide show
  1. package/dist/canonical-human-identities.js +3 -3
  2. package/dist/capability-reconciliation.js +2 -2
  3. package/dist/{chunk-7QWWTVYH.js → chunk-5ROHLEN6.js} +29 -4
  4. package/dist/chunk-5ROHLEN6.js.map +1 -0
  5. package/dist/{chunk-AQMH2ZEH.js → chunk-A4J7EECR.js} +2 -2
  6. package/dist/{chunk-VQX54TK3.js → chunk-B5C5SBSI.js} +3 -3
  7. package/dist/{chunk-OI6YZXTU.js → chunk-DKFEUGM4.js} +2 -2
  8. package/dist/{chunk-HYBQABBJ.js → chunk-HUKBIZGH.js} +2 -2
  9. package/dist/{chunk-LRSIODCQ.js → chunk-IT3EYY2C.js} +6 -4
  10. package/dist/{chunk-LRSIODCQ.js.map → chunk-IT3EYY2C.js.map} +1 -1
  11. package/dist/{chunk-DSNEIW5E.js → chunk-JL6TBB76.js} +2 -1
  12. package/dist/chunk-JL6TBB76.js.map +1 -0
  13. package/dist/{chunk-OEFNTGR3.js → chunk-JWBEE4CL.js} +4 -4
  14. package/dist/{chunk-BRVBLQAL.js → chunk-K5MTNKJU.js} +4 -4
  15. package/dist/{chunk-6UKNJ43T.js → chunk-NZ2SP55O.js} +2 -2
  16. package/dist/{chunk-3XHC5KC5.js → chunk-OKCBARX6.js} +2 -2
  17. package/dist/{chunk-PNWWNEDM.js → chunk-OV5AUIBA.js} +2 -2
  18. package/dist/{chunk-BID3CNN3.js → chunk-TODVDGMO.js} +36 -18
  19. package/dist/{chunk-BID3CNN3.js.map → chunk-TODVDGMO.js.map} +1 -1
  20. package/dist/{chunk-6S5T6FSP.js → chunk-W75DRYG3.js} +5 -5
  21. package/dist/{chunk-7S3O33NM.js → chunk-Y72DP3JF.js} +2 -2
  22. package/dist/codemode-approvals.d.ts +7 -1
  23. package/dist/connect-attempts.js +3 -3
  24. package/dist/connection-metadata.d.ts +416 -0
  25. package/dist/credit-balances.d.ts +25 -4
  26. package/dist/database.d.ts +7 -1
  27. package/dist/editable-artifact-durable-export.js +2 -2
  28. package/dist/editable-artifacts.js +3 -3
  29. package/dist/index.d.ts +51 -8
  30. package/dist/index.js +10826 -10129
  31. package/dist/index.js.map +1 -1
  32. package/dist/legacy-tool-action-reviews.d.ts +75 -0
  33. package/dist/managed-auth-session-sets.js +3 -3
  34. package/dist/mcp-operations.js +2 -2
  35. package/dist/modal-native-live-origin.d.ts +75 -0
  36. package/dist/organization-integration-policy.js +3 -3
  37. package/dist/organization-slack-bots.d.ts +1 -1
  38. package/dist/provision-roles.js +1 -1
  39. package/dist/retained-provider-commands.d.ts +21 -21
  40. package/dist/retained-provider-commands.js +6 -6
  41. package/dist/runtime-posture.d.ts +1 -1
  42. package/dist/sandbox-interaction-idle.d.ts +22 -0
  43. package/dist/schema.js +1 -1
  44. package/dist/session-background-commands.js +4 -4
  45. package/dist/session-command-output.js +5 -5
  46. package/dist/session-event-slices.js +3 -3
  47. package/dist/session-mcp-credential-rotation.js +5 -5
  48. package/dist/session-realtime-billing.d.ts +48 -0
  49. package/dist/session-realtime.d.ts +8 -2
  50. package/dist/session-tenancy.js +3 -3
  51. package/dist/video-generation.js +3 -3
  52. package/dist/voice-transcription-settlement.d.ts +36 -0
  53. package/dist/workspace-tool-defaults.js +4 -4
  54. package/drizzle/0623_voice_transcription_attribution.sql +128 -0
  55. package/drizzle/0624_azure_live_realtime_model.sql +30 -0
  56. package/drizzle/0625_transcription_billing_refusal_codes.sql +29 -0
  57. package/drizzle/0632_modal_native_live_origin.sql +239 -0
  58. package/package.json +7 -7
  59. package/src/codemode-approvals.ts +52 -22
  60. package/src/codemode-operations.ts +198 -181
  61. package/src/connection-metadata.ts +166 -0
  62. package/src/credit-balances.ts +58 -17
  63. package/src/database.ts +9 -0
  64. package/src/index.ts +540 -412
  65. package/src/insights-unified.ts +2 -1
  66. package/src/legacy-tool-action-reviews.ts +242 -0
  67. package/src/modal-native-live-origin.ts +148 -0
  68. package/src/organization-slack-bots.ts +1 -1
  69. package/src/provision-roles.ts +12 -0
  70. package/src/runtime-posture.ts +32 -3
  71. package/src/sandbox-interaction-idle.ts +91 -0
  72. package/src/schema.ts +1 -0
  73. package/src/session-realtime-billing.ts +135 -0
  74. package/src/session-realtime-ledger.ts +9 -4
  75. package/src/session-realtime.ts +20 -2
  76. package/src/tool-action-reviews.ts +129 -18
  77. package/src/transcription-recordings.ts +3 -1
  78. package/src/voice-transcription-settlement.ts +94 -0
  79. package/dist/chunk-7QWWTVYH.js.map +0 -1
  80. package/dist/chunk-DSNEIW5E.js.map +0 -1
  81. /package/dist/{chunk-AQMH2ZEH.js.map → chunk-A4J7EECR.js.map} +0 -0
  82. /package/dist/{chunk-VQX54TK3.js.map → chunk-B5C5SBSI.js.map} +0 -0
  83. /package/dist/{chunk-OI6YZXTU.js.map → chunk-DKFEUGM4.js.map} +0 -0
  84. /package/dist/{chunk-HYBQABBJ.js.map → chunk-HUKBIZGH.js.map} +0 -0
  85. /package/dist/{chunk-OEFNTGR3.js.map → chunk-JWBEE4CL.js.map} +0 -0
  86. /package/dist/{chunk-BRVBLQAL.js.map → chunk-K5MTNKJU.js.map} +0 -0
  87. /package/dist/{chunk-6UKNJ43T.js.map → chunk-NZ2SP55O.js.map} +0 -0
  88. /package/dist/{chunk-3XHC5KC5.js.map → chunk-OKCBARX6.js.map} +0 -0
  89. /package/dist/{chunk-PNWWNEDM.js.map → chunk-OV5AUIBA.js.map} +0 -0
  90. /package/dist/{chunk-6S5T6FSP.js.map → chunk-W75DRYG3.js.map} +0 -0
  91. /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
- const inputTranscript = incoming.payload?.inputTranscript;
1180
- if (typeof inputTranscript !== "string" || inputTranscript.trim().length === 0) {
1181
- throw new Error("Realtime delegation input transcript is required");
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,
@@ -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<RenewSessionRealtimeInput, "leaseMs">;
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
- const pending = fromPostgresLosslessJson(
69
- state.pendingApprovals,
70
- state.pendingApprovalsCodecVersion,
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
- if (
90
- connectorActionFingerprint({ ...record.request, arguments: args }) !==
91
- record.request.actionFingerprint
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
- ? "revoked"
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
- const available =
135
- status === "pending" && turn.status === "requires_action" && request.reviewArguments !== null;
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
- request.policySource === "explicit"
161
- ? "Your permission setting for this action is Ask."
162
- : request.policySource === "ambiguous"
163
- ? "Overlapping permission settings require a review."
164
- : "This action asks for approval by default.",
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: available ? ["approve", "reject"] : [],
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
- .join("\n\n");
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
+ }