@opengeni/db 1.4.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 (68) hide show
  1. package/dist/canonical-human-identities.js +2 -2
  2. package/dist/capability-reconciliation.js +1 -1
  3. package/dist/{chunk-7QWWTVYH.js → chunk-5ROHLEN6.js} +29 -4
  4. package/dist/chunk-5ROHLEN6.js.map +1 -0
  5. package/dist/{chunk-BA52ITIY.js → chunk-A4J7EECR.js} +2 -2
  6. package/dist/{chunk-3WUCM7YF.js → chunk-B5C5SBSI.js} +2 -2
  7. package/dist/{chunk-VCHK4D6S.js → chunk-DKFEUGM4.js} +2 -2
  8. package/dist/{chunk-EFANLEWI.js → chunk-HUKBIZGH.js} +2 -2
  9. package/dist/{chunk-27T4AP25.js → chunk-IT3EYY2C.js} +5 -3
  10. package/dist/{chunk-27T4AP25.js.map → chunk-IT3EYY2C.js.map} +1 -1
  11. package/dist/{chunk-NT7OGKYX.js → chunk-JWBEE4CL.js} +3 -3
  12. package/dist/{chunk-AMK3FNOW.js → chunk-K5MTNKJU.js} +3 -3
  13. package/dist/{chunk-GC5GUTV3.js → chunk-NZ2SP55O.js} +2 -2
  14. package/dist/{chunk-TRUEH4I2.js → chunk-OKCBARX6.js} +2 -2
  15. package/dist/{chunk-EMYA6XNE.js → chunk-OV5AUIBA.js} +2 -2
  16. package/dist/{chunk-T3CYF3DH.js → chunk-TODVDGMO.js} +35 -17
  17. package/dist/{chunk-T3CYF3DH.js.map → chunk-TODVDGMO.js.map} +1 -1
  18. package/dist/{chunk-CKDHKQS4.js → chunk-W75DRYG3.js} +4 -4
  19. package/dist/{chunk-UQELJ6OM.js → chunk-Y72DP3JF.js} +2 -2
  20. package/dist/codemode-approvals.d.ts +7 -1
  21. package/dist/connect-attempts.js +2 -2
  22. package/dist/credit-balances.d.ts +25 -4
  23. package/dist/database.d.ts +7 -1
  24. package/dist/editable-artifact-durable-export.js +1 -1
  25. package/dist/editable-artifacts.js +2 -2
  26. package/dist/index.d.ts +24 -2
  27. package/dist/index.js +2778 -2508
  28. package/dist/index.js.map +1 -1
  29. package/dist/managed-auth-session-sets.js +2 -2
  30. package/dist/mcp-operations.js +1 -1
  31. package/dist/modal-native-live-origin.d.ts +75 -0
  32. package/dist/organization-integration-policy.js +2 -2
  33. package/dist/provision-roles.js +1 -1
  34. package/dist/retained-provider-commands.d.ts +21 -21
  35. package/dist/retained-provider-commands.js +5 -5
  36. package/dist/runtime-posture.d.ts +1 -1
  37. package/dist/sandbox-interaction-idle.d.ts +22 -0
  38. package/dist/session-background-commands.js +3 -3
  39. package/dist/session-command-output.js +4 -4
  40. package/dist/session-event-slices.js +2 -2
  41. package/dist/session-mcp-credential-rotation.js +4 -4
  42. package/dist/session-tenancy.js +2 -2
  43. package/dist/video-generation.js +2 -2
  44. package/dist/workspace-tool-defaults.js +3 -3
  45. package/drizzle/0632_modal_native_live_origin.sql +239 -0
  46. package/package.json +7 -7
  47. package/src/codemode-approvals.ts +52 -22
  48. package/src/codemode-operations.ts +198 -181
  49. package/src/credit-balances.ts +58 -17
  50. package/src/database.ts +9 -0
  51. package/src/index.ts +449 -254
  52. package/src/modal-native-live-origin.ts +148 -0
  53. package/src/provision-roles.ts +12 -0
  54. package/src/runtime-posture.ts +32 -3
  55. package/src/sandbox-interaction-idle.ts +91 -0
  56. package/src/transcription-recordings.ts +3 -1
  57. package/dist/chunk-7QWWTVYH.js.map +0 -1
  58. /package/dist/{chunk-BA52ITIY.js.map → chunk-A4J7EECR.js.map} +0 -0
  59. /package/dist/{chunk-3WUCM7YF.js.map → chunk-B5C5SBSI.js.map} +0 -0
  60. /package/dist/{chunk-VCHK4D6S.js.map → chunk-DKFEUGM4.js.map} +0 -0
  61. /package/dist/{chunk-EFANLEWI.js.map → chunk-HUKBIZGH.js.map} +0 -0
  62. /package/dist/{chunk-NT7OGKYX.js.map → chunk-JWBEE4CL.js.map} +0 -0
  63. /package/dist/{chunk-AMK3FNOW.js.map → chunk-K5MTNKJU.js.map} +0 -0
  64. /package/dist/{chunk-GC5GUTV3.js.map → chunk-NZ2SP55O.js.map} +0 -0
  65. /package/dist/{chunk-TRUEH4I2.js.map → chunk-OKCBARX6.js.map} +0 -0
  66. /package/dist/{chunk-EMYA6XNE.js.map → chunk-OV5AUIBA.js.map} +0 -0
  67. /package/dist/{chunk-CKDHKQS4.js.map → chunk-W75DRYG3.js.map} +0 -0
  68. /package/dist/{chunk-UQELJ6OM.js.map → chunk-Y72DP3JF.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "OpenGeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -115,12 +115,12 @@
115
115
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
116
116
  },
117
117
  "dependencies": {
118
- "@opengeni/codemode": "1.4.0",
119
- "@opengeni/codex": "1.4.0",
120
- "@opengeni/config": "1.4.0",
121
- "@opengeni/contracts": "1.4.0",
122
- "@opengeni/network": "1.4.0",
123
- "@opengeni/observability": "1.4.0",
118
+ "@opengeni/codemode": "1.4.2",
119
+ "@opengeni/codex": "1.4.2",
120
+ "@opengeni/config": "1.4.2",
121
+ "@opengeni/contracts": "1.4.2",
122
+ "@opengeni/network": "1.4.2",
123
+ "@opengeni/observability": "1.4.2",
124
124
  "drizzle-orm": "^0.45.2",
125
125
  "postgres": "^3.4.7"
126
126
  },
@@ -277,7 +277,13 @@ export async function adoptCodemodeApproval(
277
277
  );
278
278
  }
279
279
 
280
- /** Read a handle with the current exact attempt and same original caller; old bearer authority is insufficient. */
280
+ /**
281
+ * Read a handle with the current exact attempt and same original caller; old
282
+ * bearer authority is insufficient. This is a status read polled while a
283
+ * caller waits, so it is one plain snapshot select: it proves the exact live
284
+ * attempt in the same statement but takes no session/turn/attempt row locks
285
+ * and is not refused by a pending Pause/Steer, which owns the write fence only.
286
+ */
281
287
  export async function readTurnCodemodeOperation(
282
288
  db: Database,
283
289
  input: CodemodeContinuationScope & {
@@ -285,27 +291,51 @@ export async function readTurnCodemodeOperation(
285
291
  callerSubjectId: string;
286
292
  },
287
293
  ): Promise<CodemodeOperation | null> {
288
- return await withRlsContext(
289
- db,
290
- input,
291
- async (scoped) =>
292
- await scoped.transaction(async (tx) => {
293
- await lockActiveTurn(tx as unknown as Database, input);
294
- const [row] = await tx
295
- .select()
296
- .from(schema.sessionAttemptCodemodeCalls)
297
- .where(
298
- and(
299
- eq(schema.sessionAttemptCodemodeCalls.workspaceId, input.workspaceId),
300
- eq(schema.sessionAttemptCodemodeCalls.sessionId, input.sessionId),
301
- eq(schema.sessionAttemptCodemodeCalls.turnId, input.turnId),
302
- eq(schema.sessionAttemptCodemodeCalls.operationId, input.operationId),
303
- ),
304
- )
305
- .limit(1);
306
- return row && codemodeCallerCanContinue(row, input) ? mapCodemodeOperation(row) : null;
307
- }),
308
- );
294
+ return await withRlsContext(db, input, async (scoped) => {
295
+ const [live] = await scoped
296
+ .select({ attemptId: schema.sessionTurnAttempts.id })
297
+ .from(schema.sessionTurns)
298
+ .innerJoin(
299
+ schema.sessionTurnAttempts,
300
+ and(
301
+ eq(schema.sessionTurnAttempts.id, schema.sessionTurns.activeAttemptId),
302
+ eq(schema.sessionTurnAttempts.turnId, schema.sessionTurns.id),
303
+ eq(schema.sessionTurnAttempts.sessionId, schema.sessionTurns.sessionId),
304
+ eq(schema.sessionTurnAttempts.accountId, schema.sessionTurns.accountId),
305
+ eq(
306
+ schema.sessionTurnAttempts.executionGeneration,
307
+ schema.sessionTurns.executionGeneration,
308
+ ),
309
+ ),
310
+ )
311
+ .where(
312
+ and(
313
+ eq(schema.sessionTurns.id, input.turnId),
314
+ eq(schema.sessionTurns.accountId, input.accountId),
315
+ eq(schema.sessionTurns.workspaceId, input.workspaceId),
316
+ eq(schema.sessionTurns.sessionId, input.sessionId),
317
+ eq(schema.sessionTurns.status, "running"),
318
+ eq(schema.sessionTurns.activeAttemptId, input.attemptId),
319
+ eq(schema.sessionTurns.executionGeneration, input.executionGeneration),
320
+ inArray(schema.sessionTurnAttempts.state, ["claimed", "running"]),
321
+ ),
322
+ )
323
+ .limit(1);
324
+ if (!live) throw new CodemodeOperationNotExecutableError();
325
+ const [row] = await scoped
326
+ .select()
327
+ .from(schema.sessionAttemptCodemodeCalls)
328
+ .where(
329
+ and(
330
+ eq(schema.sessionAttemptCodemodeCalls.workspaceId, input.workspaceId),
331
+ eq(schema.sessionAttemptCodemodeCalls.sessionId, input.sessionId),
332
+ eq(schema.sessionAttemptCodemodeCalls.turnId, input.turnId),
333
+ eq(schema.sessionAttemptCodemodeCalls.operationId, input.operationId),
334
+ ),
335
+ )
336
+ .limit(1);
337
+ return row && codemodeCallerCanContinue(row, input) ? mapCodemodeOperation(row) : null;
338
+ });
309
339
  }
310
340
 
311
341
  /** Signed sandbox subjects are attempt-bound; only the same owning turn can use this mapping. */
@@ -18,6 +18,7 @@ import {
18
18
  import { and, eq, sql } from "drizzle-orm";
19
19
  import type { Database } from "./database";
20
20
  import { withRlsContext } from "./database";
21
+ import { runIdempotentPersistenceTransaction } from "./persistence-errors";
21
22
  import * as schema from "./schema";
22
23
 
23
24
  export class CodemodeOperationConflictError extends Error {
@@ -101,106 +102,114 @@ export async function submitCodemodeOperation(
101
102
  if (call.caller.kind !== "codemode") throw new CodemodeOperationNotExecutableError();
102
103
  assertJsonBytes(call.arguments, CODEMODE_ARGUMENTS_MAX_BYTES, "arguments");
103
104
  const requestDigest = digestCodemodeOperationRequest(call);
104
- return await withRlsContext(
105
- db,
106
- { accountId: input.accountId, workspaceId: input.workspaceId },
107
- async (scopedDb) =>
108
- await scopedDb.transaction(async (tx) => {
109
- // Serialize the caller-owned id before testing for an existing row.
110
- // Without this fence, two first submissions can both observe absence
111
- // and the later insert fails its unique constraint instead of replaying.
112
- await tx.execute(sql`
105
+ // The whole admission is one idempotent transaction keyed by the
106
+ // caller-owned operation id (identical bytes replay the same row), so a
107
+ // deadlock/serialization victim rolls back completely and is retried here
108
+ // instead of surfacing as an HTTP failure. No external effect runs inside.
109
+ return await runIdempotentPersistenceTransaction(
110
+ { stage: "codemode.operation.submit", maxAttempts: 3 },
111
+ async () =>
112
+ await withRlsContext(
113
+ db,
114
+ { accountId: input.accountId, workspaceId: input.workspaceId },
115
+ async (scopedDb) =>
116
+ await scopedDb.transaction(async (tx) => {
117
+ // Serialize the caller-owned id before testing for an existing row.
118
+ // Without this fence, two first submissions can both observe absence
119
+ // and the later insert fails its unique constraint instead of replaying.
120
+ await tx.execute(sql`
113
121
  select pg_advisory_xact_lock(
114
122
  hashtextextended(${`codemode-operation:${input.workspaceId}:${call.operationId}`}, 0)
115
123
  )
116
124
  `);
117
- const fence = await lockTurnAttemptWriteFenceTx(tx as unknown as Database, input);
118
- if (
119
- !fence.allowed ||
120
- fence.turn.accountId !== input.accountId ||
121
- fence.turn.status !== "running"
122
- )
123
- throw new CodemodeOperationNotExecutableError();
124
- const [existing] = await tx
125
- .select()
126
- .from(schema.sessionAttemptCodemodeCalls)
127
- .where(eq(schema.sessionAttemptCodemodeCalls.operationId, call.operationId))
128
- .for("update")
129
- .limit(1);
130
- if (existing) {
131
- assertExistingRequest(existing, input, requestDigest);
132
- return { operation: mapCodemodeOperation(existing), created: false };
133
- }
134
-
135
- const [catalogRow] = await tx
136
- .select({ catalog: schema.sessionAttemptToolCatalogs.catalog })
137
- .from(schema.sessionAttemptToolCatalogs)
138
- .where(
139
- and(
140
- eq(schema.sessionAttemptToolCatalogs.attemptId, input.attemptId),
141
- eq(schema.sessionAttemptToolCatalogs.digest, call.catalogDigest),
142
- ),
143
- )
144
- .limit(1);
145
- if (!catalogRow) throw new CodemodeOperationNotExecutableError();
146
- const catalog = parseVerifiedAttemptToolCatalog(catalogRow.catalog);
147
- if (
148
- catalog.accountId !== input.accountId ||
149
- catalog.workspaceId !== input.workspaceId ||
150
- catalog.sessionId !== input.sessionId ||
151
- catalog.turnId !== input.turnId ||
152
- catalog.attemptId !== input.attemptId ||
153
- catalog.executionGeneration !== input.executionGeneration
154
- ) {
155
- throw new CodemodeOperationNotExecutableError();
156
- }
157
- const catalogEntry = catalog.entries.find(
158
- (entry) =>
159
- entry.identity.serverId === call.identity.serverId &&
160
- entry.identity.toolName === call.identity.toolName,
161
- );
162
- if (!catalogEntry) {
163
- throw new CodemodeToolNotInCatalogError();
164
- }
165
- if (catalogEntry.approval === "human" && input.durableApproval !== true) {
166
- throw new CodemodeToolApprovalRequiredError();
167
- }
168
-
169
- // The turn lock also serializes admission; completed receipts never consume this budget.
170
- const active = await tx
171
- .select({ operationId: schema.sessionAttemptCodemodeCalls.operationId })
172
- .from(schema.sessionAttemptCodemodeCalls)
173
- .where(
174
- and(
175
- eq(schema.sessionAttemptCodemodeCalls.turnId, input.turnId),
176
- sql`${schema.sessionAttemptCodemodeCalls.state} in ('queued', 'running', 'waiting_for_approval')`,
177
- ),
178
- )
179
- .limit(128);
180
- if (active.length >= 128) throw new CodemodeOperationLimitError();
181
-
182
- const [created] = await tx
183
- .insert(schema.sessionAttemptCodemodeCalls)
184
- .values({
185
- operationId: call.operationId,
186
- accountId: input.accountId,
187
- workspaceId: input.workspaceId,
188
- sessionId: input.sessionId,
189
- turnId: input.turnId,
190
- attemptId: input.attemptId,
191
- executionGeneration: input.executionGeneration,
192
- catalogDigest: call.catalogDigest,
193
- requestDigest,
194
- durableApproval: input.durableApproval === true,
195
- serverId: call.identity.serverId,
196
- toolName: call.identity.toolName,
197
- arguments: call.arguments,
198
- callerSubjectId: call.caller.subjectId,
199
- })
200
- .returning();
201
- if (!created) throw new Error("Failed to create Codemode operation");
202
- return { operation: mapCodemodeOperation(created), created: true };
203
- }),
125
+ const fence = await lockTurnAttemptWriteFenceTx(tx as unknown as Database, input);
126
+ if (
127
+ !fence.allowed ||
128
+ fence.turn.accountId !== input.accountId ||
129
+ fence.turn.status !== "running"
130
+ )
131
+ throw new CodemodeOperationNotExecutableError();
132
+ const [existing] = await tx
133
+ .select()
134
+ .from(schema.sessionAttemptCodemodeCalls)
135
+ .where(eq(schema.sessionAttemptCodemodeCalls.operationId, call.operationId))
136
+ .for("update")
137
+ .limit(1);
138
+ if (existing) {
139
+ assertExistingRequest(existing, input, requestDigest);
140
+ return { operation: mapCodemodeOperation(existing), created: false };
141
+ }
142
+
143
+ const [catalogRow] = await tx
144
+ .select({ catalog: schema.sessionAttemptToolCatalogs.catalog })
145
+ .from(schema.sessionAttemptToolCatalogs)
146
+ .where(
147
+ and(
148
+ eq(schema.sessionAttemptToolCatalogs.attemptId, input.attemptId),
149
+ eq(schema.sessionAttemptToolCatalogs.digest, call.catalogDigest),
150
+ ),
151
+ )
152
+ .limit(1);
153
+ if (!catalogRow) throw new CodemodeOperationNotExecutableError();
154
+ const catalog = parseVerifiedAttemptToolCatalog(catalogRow.catalog);
155
+ if (
156
+ catalog.accountId !== input.accountId ||
157
+ catalog.workspaceId !== input.workspaceId ||
158
+ catalog.sessionId !== input.sessionId ||
159
+ catalog.turnId !== input.turnId ||
160
+ catalog.attemptId !== input.attemptId ||
161
+ catalog.executionGeneration !== input.executionGeneration
162
+ ) {
163
+ throw new CodemodeOperationNotExecutableError();
164
+ }
165
+ const catalogEntry = catalog.entries.find(
166
+ (entry) =>
167
+ entry.identity.serverId === call.identity.serverId &&
168
+ entry.identity.toolName === call.identity.toolName,
169
+ );
170
+ if (!catalogEntry) {
171
+ throw new CodemodeToolNotInCatalogError();
172
+ }
173
+ if (catalogEntry.approval === "human" && input.durableApproval !== true) {
174
+ throw new CodemodeToolApprovalRequiredError();
175
+ }
176
+
177
+ // The turn lock also serializes admission; completed receipts never consume this budget.
178
+ const active = await tx
179
+ .select({ operationId: schema.sessionAttemptCodemodeCalls.operationId })
180
+ .from(schema.sessionAttemptCodemodeCalls)
181
+ .where(
182
+ and(
183
+ eq(schema.sessionAttemptCodemodeCalls.turnId, input.turnId),
184
+ sql`${schema.sessionAttemptCodemodeCalls.state} in ('queued', 'running', 'waiting_for_approval')`,
185
+ ),
186
+ )
187
+ .limit(128);
188
+ if (active.length >= 128) throw new CodemodeOperationLimitError();
189
+
190
+ const [created] = await tx
191
+ .insert(schema.sessionAttemptCodemodeCalls)
192
+ .values({
193
+ operationId: call.operationId,
194
+ accountId: input.accountId,
195
+ workspaceId: input.workspaceId,
196
+ sessionId: input.sessionId,
197
+ turnId: input.turnId,
198
+ attemptId: input.attemptId,
199
+ executionGeneration: input.executionGeneration,
200
+ catalogDigest: call.catalogDigest,
201
+ requestDigest,
202
+ durableApproval: input.durableApproval === true,
203
+ serverId: call.identity.serverId,
204
+ toolName: call.identity.toolName,
205
+ arguments: call.arguments,
206
+ callerSubjectId: call.caller.subjectId,
207
+ })
208
+ .returning();
209
+ if (!created) throw new Error("Failed to create Codemode operation");
210
+ return { operation: mapCodemodeOperation(created), created: true };
211
+ }),
212
+ ),
204
213
  );
205
214
  }
206
215
 
@@ -255,94 +264,102 @@ export async function claimCodemodeOperation(
255
264
  claimLeaseMs?: number;
256
265
  },
257
266
  ): Promise<ClaimCodemodeOperationResult> {
258
- return await withRlsContext(
259
- db,
260
- { accountId: input.accountId, workspaceId: input.workspaceId },
261
- async (scopedDb) =>
262
- await scopedDb.transaction(async (tx) => {
263
- const fence = await lockTurnAttemptWriteFenceTx(tx as unknown as Database, input);
264
- if (
265
- !fence.allowed ||
266
- fence.turn.accountId !== input.accountId ||
267
- fence.turn.status !== "running"
268
- )
269
- return { status: "rejected", operation: null };
270
- let [row] = await tx
271
- .select()
272
- .from(schema.sessionAttemptCodemodeCalls)
273
- .where(eq(schema.sessionAttemptCodemodeCalls.operationId, input.operationId))
274
- .for("update")
275
- .limit(1);
276
- if (!row || !operationMatchesAuthority(row, input)) {
277
- return { status: "rejected", operation: row ? mapCodemodeOperation(row) : null };
278
- }
279
- const now = input.now ?? new Date();
280
- const claimExpiresAt = new Date(now.getTime() + boundedClaimLeaseMs(input.claimLeaseMs));
281
- let reclaimed = false;
282
- if (row.state === "running") {
283
- if (!row.claimExpiresAt || row.claimExpiresAt.getTime() > now.getTime()) {
284
- return { status: "already_running", operation: mapCodemodeOperation(row) };
285
- }
286
- if (row.executionStartedAt) {
287
- if (!row.claimId) {
288
- throw new Error("Codemode running operation is missing its claim id");
267
+ // Pure database transaction keyed by the caller's claim id; a rolled-back
268
+ // deadlock victim committed nothing and is safe to repeat.
269
+ return await runIdempotentPersistenceTransaction(
270
+ { stage: "codemode.operation.claim", maxAttempts: 3 },
271
+ async () =>
272
+ await withRlsContext(
273
+ db,
274
+ { accountId: input.accountId, workspaceId: input.workspaceId },
275
+ async (scopedDb) =>
276
+ await scopedDb.transaction(async (tx) => {
277
+ const fence = await lockTurnAttemptWriteFenceTx(tx as unknown as Database, input);
278
+ if (
279
+ !fence.allowed ||
280
+ fence.turn.accountId !== input.accountId ||
281
+ fence.turn.status !== "running"
282
+ )
283
+ return { status: "rejected", operation: null };
284
+ let [row] = await tx
285
+ .select()
286
+ .from(schema.sessionAttemptCodemodeCalls)
287
+ .where(eq(schema.sessionAttemptCodemodeCalls.operationId, input.operationId))
288
+ .for("update")
289
+ .limit(1);
290
+ if (!row || !operationMatchesAuthority(row, input)) {
291
+ return { status: "rejected", operation: row ? mapCodemodeOperation(row) : null };
289
292
  }
293
+ const now = input.now ?? new Date();
294
+ const claimExpiresAt = new Date(
295
+ now.getTime() + boundedClaimLeaseMs(input.claimLeaseMs),
296
+ );
297
+ let reclaimed = false;
298
+ if (row.state === "running") {
299
+ if (!row.claimExpiresAt || row.claimExpiresAt.getTime() > now.getTime()) {
300
+ return { status: "already_running", operation: mapCodemodeOperation(row) };
301
+ }
302
+ if (row.executionStartedAt) {
303
+ if (!row.claimId) {
304
+ throw new Error("Codemode running operation is missing its claim id");
305
+ }
306
+ return {
307
+ status: "execution_owner_lost",
308
+ operation: mapCodemodeOperation(row),
309
+ claimId: row.claimId,
310
+ };
311
+ }
312
+ const [requeued] = await tx
313
+ .update(schema.sessionAttemptCodemodeCalls)
314
+ .set({
315
+ state: "queued",
316
+ claimId: null,
317
+ claimedAt: null,
318
+ claimExpiresAt: null,
319
+ updatedAt: now,
320
+ })
321
+ .where(
322
+ and(
323
+ eq(schema.sessionAttemptCodemodeCalls.operationId, input.operationId),
324
+ eq(schema.sessionAttemptCodemodeCalls.state, "running"),
325
+ eq(schema.sessionAttemptCodemodeCalls.claimId, row.claimId!),
326
+ ),
327
+ )
328
+ .returning();
329
+ if (!requeued) throw new Error("Codemode expired claim recovery lost its row lock");
330
+ row = requeued;
331
+ reclaimed = true;
332
+ }
333
+ if (row.state === "waiting_for_approval")
334
+ return { status: "terminal", operation: mapCodemodeOperation(row) };
335
+ if (isTerminalState(row.state))
336
+ return { status: "terminal", operation: mapCodemodeOperation(row) };
337
+
338
+ const [claimed] = await tx
339
+ .update(schema.sessionAttemptCodemodeCalls)
340
+ .set({
341
+ state: "running",
342
+ claimId: input.claimId,
343
+ claimedAt: now,
344
+ claimExpiresAt,
345
+ updatedAt: now,
346
+ })
347
+ .where(
348
+ and(
349
+ eq(schema.sessionAttemptCodemodeCalls.operationId, input.operationId),
350
+ eq(schema.sessionAttemptCodemodeCalls.state, "queued"),
351
+ ),
352
+ )
353
+ .returning();
354
+ if (!claimed) throw new Error("Codemode operation claim lost its row lock");
290
355
  return {
291
- status: "execution_owner_lost",
292
- operation: mapCodemodeOperation(row),
293
- claimId: row.claimId,
356
+ status: "claimed",
357
+ operation: mapCodemodeOperation(claimed),
358
+ claimId: input.claimId,
359
+ reclaimed,
294
360
  };
295
- }
296
- const [requeued] = await tx
297
- .update(schema.sessionAttemptCodemodeCalls)
298
- .set({
299
- state: "queued",
300
- claimId: null,
301
- claimedAt: null,
302
- claimExpiresAt: null,
303
- updatedAt: now,
304
- })
305
- .where(
306
- and(
307
- eq(schema.sessionAttemptCodemodeCalls.operationId, input.operationId),
308
- eq(schema.sessionAttemptCodemodeCalls.state, "running"),
309
- eq(schema.sessionAttemptCodemodeCalls.claimId, row.claimId!),
310
- ),
311
- )
312
- .returning();
313
- if (!requeued) throw new Error("Codemode expired claim recovery lost its row lock");
314
- row = requeued;
315
- reclaimed = true;
316
- }
317
- if (row.state === "waiting_for_approval")
318
- return { status: "terminal", operation: mapCodemodeOperation(row) };
319
- if (isTerminalState(row.state))
320
- return { status: "terminal", operation: mapCodemodeOperation(row) };
321
-
322
- const [claimed] = await tx
323
- .update(schema.sessionAttemptCodemodeCalls)
324
- .set({
325
- state: "running",
326
- claimId: input.claimId,
327
- claimedAt: now,
328
- claimExpiresAt,
329
- updatedAt: now,
330
- })
331
- .where(
332
- and(
333
- eq(schema.sessionAttemptCodemodeCalls.operationId, input.operationId),
334
- eq(schema.sessionAttemptCodemodeCalls.state, "queued"),
335
- ),
336
- )
337
- .returning();
338
- if (!claimed) throw new Error("Codemode operation claim lost its row lock");
339
- return {
340
- status: "claimed",
341
- operation: mapCodemodeOperation(claimed),
342
- claimId: input.claimId,
343
- reclaimed,
344
- };
345
- }),
361
+ }),
362
+ ),
346
363
  );
347
364
  }
348
365
 
@@ -38,7 +38,8 @@ export async function getBillingBalance(
38
38
  else policy->'offers'->(g.metadata->>'creditOfferId')->'eligibleModelIds' end,
39
39
  policy->'defaultModelIds'
40
40
  ) from current_policy), to_jsonb(g.eligible_model_ids)),
41
- 'remainingMicros', g.amount_micros - coalesce(used.amount, 0)
41
+ 'remainingMicros', g.amount_micros - coalesce(used.amount, 0),
42
+ 'coversVoice', g.source_type = 'verified_signup_trial'
42
43
  ) order by g.created_at, g.id)
43
44
  from ${schema.creditLedgerEntries} g
44
45
  left join (
@@ -66,13 +67,28 @@ export async function getBillingBalance(
66
67
  });
67
68
  }
68
69
 
69
- /** Missing model means a non-model resource, which can only use general credits. */
70
- export function spendableCreditMicros(balance: BillingBalance, modelId?: string): number {
70
+ /**
71
+ * What a charge pays for. A canonical model ID uses grants scoped to that
72
+ * model; {@link VOICE_CREDIT_USAGE} (dictation and live voice) uses grants
73
+ * that cover voice, which are the verified-signup trial credits. Omitted means
74
+ * a non-model resource, which can only use general credits.
75
+ */
76
+ export type CreditUsage = string | { readonly kind: "voice" } | undefined;
77
+
78
+ /** Deployment-funded dictation and live voice. */
79
+ export const VOICE_CREDIT_USAGE: { readonly kind: "voice" } = Object.freeze({ kind: "voice" });
80
+
81
+ function grantCovers(grant: PromotionalCreditBalance, usage: CreditUsage): boolean {
82
+ if (!usage) return false;
83
+ if (typeof usage === "string") return grant.eligibleModelIds.includes(usage);
84
+ return grant.coversVoice === true;
85
+ }
86
+
87
+ export function spendableCreditMicros(balance: BillingBalance, usage?: CreditUsage): number {
71
88
  return (
72
89
  Math.max(0, balance.generalBalanceMicros ?? balance.balanceMicros) +
73
90
  (balance.promotionalCredits ?? []).reduce(
74
- (sum, grant) =>
75
- sum + (modelId && grant.eligibleModelIds.includes(modelId) ? grant.remainingMicros : 0),
91
+ (sum, grant) => sum + (grantCovers(grant, usage) ? grant.remainingMicros : 0),
76
92
  0,
77
93
  )
78
94
  );
@@ -81,29 +97,54 @@ export function spendableCreditMicros(balance: BillingBalance, modelId?: string)
81
97
  export async function getSpendableCreditBalance(
82
98
  db: Database,
83
99
  accountId: string,
84
- modelId?: string,
100
+ usage?: CreditUsage,
85
101
  policyRevision?: number,
86
102
  ): Promise<CreditBalanceSnapshot> {
87
103
  const balance = await getBillingBalance(db, accountId, policyRevision);
88
- return { ...balance, balanceMicros: spendableCreditMicros(balance, modelId) };
104
+ return { ...balance, balanceMicros: spendableCreditMicros(balance, usage) };
105
+ }
106
+
107
+ /** Covering grants pay first, oldest first; the rest is general credit. */
108
+ function allocateToCoveringGrants(
109
+ balance: BillingBalance,
110
+ amountMicros: number,
111
+ usage: CreditUsage,
112
+ ) {
113
+ let remaining = amountMicros;
114
+ const allocations: { grantEntryId: string; amountMicros: number }[] = [];
115
+ for (const grant of balance.promotionalCredits ?? []) {
116
+ if (!remaining || !grantCovers(grant, usage) || grant.remainingMicros <= 0) continue;
117
+ const allocated = Math.min(remaining, grant.remainingMicros);
118
+ allocations.push({ grantEntryId: grant.grantId, amountMicros: allocated });
119
+ remaining -= allocated;
120
+ }
121
+ return allocations;
89
122
  }
90
123
 
91
124
  export function planCreditDebit(
92
125
  balance: BillingBalance,
93
126
  requestedMicros: number,
94
- modelId?: string,
127
+ usage?: CreditUsage,
95
128
  ) {
96
129
  if (!Number.isSafeInteger(requestedMicros) || requestedMicros <= 0) {
97
130
  throw new Error("credit debit requires a positive, safe integer micro amount");
98
131
  }
99
- const debitedMicros = Math.min(requestedMicros, spendableCreditMicros(balance, modelId));
100
- let remaining = debitedMicros;
101
- const allocations: { grantEntryId: string; amountMicros: number }[] = [];
102
- for (const grant of balance.promotionalCredits ?? []) {
103
- if (!remaining || !modelId || !grant.eligibleModelIds.includes(modelId)) continue;
104
- const amountMicros = Math.min(remaining, grant.remainingMicros);
105
- allocations.push({ grantEntryId: grant.grantId, amountMicros });
106
- remaining -= amountMicros;
132
+ const debitedMicros = Math.min(requestedMicros, spendableCreditMicros(balance, usage));
133
+ return { debitedMicros, allocations: allocateToCoveringGrants(balance, debitedMicros, usage) };
134
+ }
135
+
136
+ /**
137
+ * Allocations for an already-consumed charge of the full amount: covering
138
+ * grants pay what they can and general credit takes the rest, possibly going
139
+ * negative.
140
+ */
141
+ export function planPostUseCreditAllocations(
142
+ balance: BillingBalance,
143
+ amountMicros: number,
144
+ usage: CreditUsage,
145
+ ) {
146
+ if (!Number.isSafeInteger(amountMicros) || amountMicros <= 0) {
147
+ throw new Error("credit debit requires a positive, safe integer micro amount");
107
148
  }
108
- return { debitedMicros, allocations };
149
+ return allocateToCoveringGrants(balance, amountMicros, usage);
109
150
  }
package/src/database.ts CHANGED
@@ -898,17 +898,26 @@ export async function withSessionActivityRlsContext<T>(
898
898
  );
899
899
  }
900
900
 
901
+ /**
902
+ * `organizationMembershipFence` is required when the callback takes the
903
+ * organization-membership lifecycle lock (for example external-actor
904
+ * reauthorization): that lock precedes the session-tenancy fence and the
905
+ * canonical control/workspace/session prefix, never follows it.
906
+ */
901
907
  export async function withWorkspaceSessionActivityRls<T>(
902
908
  db: Database,
903
909
  workspaceId: string,
904
910
  fn: (db: SessionActivityDatabase) => Promise<T>,
905
911
  transactionConfig?: PgTransactionConfig,
912
+ organizationMembershipFence = false,
906
913
  ): Promise<T> {
907
914
  return await withSessionActivityRlsContext(
908
915
  db,
909
916
  { ...(await rlsContextForWorkspace(db, workspaceId)), workspaceId },
910
917
  fn,
911
918
  transactionConfig,
919
+ "shared",
920
+ organizationMembershipFence,
912
921
  );
913
922
  }
914
923