@opengeni/db 1.4.0 → 1.4.4
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 +2 -2
- package/dist/capability-reconciliation.js +1 -1
- package/dist/{chunk-27T4AP25.js → chunk-45EMCVJC.js} +22 -6
- package/dist/{chunk-27T4AP25.js.map → chunk-45EMCVJC.js.map} +1 -1
- package/dist/{chunk-EMYA6XNE.js → chunk-4SUZSKKW.js} +2 -2
- package/dist/{chunk-VCHK4D6S.js → chunk-57FM4RG4.js} +2 -2
- package/dist/{chunk-7QWWTVYH.js → chunk-5ROHLEN6.js} +29 -4
- package/dist/chunk-5ROHLEN6.js.map +1 -0
- package/dist/{chunk-BA52ITIY.js → chunk-7HP5NCRN.js} +2 -2
- package/dist/{chunk-UQELJ6OM.js → chunk-A4LPJOKP.js} +2 -2
- package/dist/{chunk-CKDHKQS4.js → chunk-DEMX4GWU.js} +4 -4
- package/dist/{chunk-GC5GUTV3.js → chunk-ISNA4KUA.js} +2 -2
- package/dist/{chunk-3WUCM7YF.js → chunk-JYKUR5KT.js} +2 -2
- package/dist/{chunk-EFANLEWI.js → chunk-LGXWM3UJ.js} +2 -2
- package/dist/{chunk-T3CYF3DH.js → chunk-NI3WPW2L.js} +35 -17
- package/dist/{chunk-T3CYF3DH.js.map → chunk-NI3WPW2L.js.map} +1 -1
- package/dist/{chunk-TRUEH4I2.js → chunk-PUOQEL6A.js} +2 -2
- package/dist/{chunk-NT7OGKYX.js → chunk-SG5YDHSE.js} +3 -3
- package/dist/{chunk-AMK3FNOW.js → chunk-WAFY2TCB.js} +3 -3
- package/dist/codemode-approvals.d.ts +7 -1
- package/dist/connect-attempts.js +2 -2
- package/dist/credit-balances.d.ts +25 -4
- package/dist/database.d.ts +15 -3
- package/dist/editable-artifact-durable-export.js +1 -1
- package/dist/editable-artifacts.js +2 -2
- package/dist/index.d.ts +24 -2
- package/dist/index.js +2782 -2509
- package/dist/index.js.map +1 -1
- package/dist/interaction-schema.d.ts +2 -0
- package/dist/managed-auth-session-sets.js +2 -2
- package/dist/mcp-operations.js +1 -1
- package/dist/modal-native-live-origin.d.ts +75 -0
- package/dist/organization-integration-policy.js +2 -2
- package/dist/provision-roles.js +1 -1
- package/dist/retained-provider-commands.d.ts +21 -21
- package/dist/retained-provider-commands.js +5 -5
- package/dist/runtime-posture.d.ts +1 -1
- package/dist/sandbox-interaction-idle.d.ts +22 -0
- package/dist/session-background-commands.js +3 -3
- package/dist/session-command-output.js +4 -4
- package/dist/session-event-slices.js +2 -2
- package/dist/session-mcp-credential-rotation.js +4 -4
- package/dist/session-tenancy.js +2 -2
- package/dist/video-generation.js +2 -2
- package/dist/workspace-tool-defaults.js +3 -3
- 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/credit-balances.ts +58 -17
- package/src/database.ts +47 -8
- package/src/index.ts +453 -255
- package/src/modal-native-live-origin.ts +148 -0
- package/src/provision-roles.ts +12 -0
- package/src/runtime-posture.ts +32 -3
- package/src/sandbox-interaction-idle.ts +91 -0
- package/src/transcription-recordings.ts +3 -1
- package/dist/chunk-7QWWTVYH.js.map +0 -1
- /package/dist/{chunk-EMYA6XNE.js.map → chunk-4SUZSKKW.js.map} +0 -0
- /package/dist/{chunk-VCHK4D6S.js.map → chunk-57FM4RG4.js.map} +0 -0
- /package/dist/{chunk-BA52ITIY.js.map → chunk-7HP5NCRN.js.map} +0 -0
- /package/dist/{chunk-UQELJ6OM.js.map → chunk-A4LPJOKP.js.map} +0 -0
- /package/dist/{chunk-CKDHKQS4.js.map → chunk-DEMX4GWU.js.map} +0 -0
- /package/dist/{chunk-GC5GUTV3.js.map → chunk-ISNA4KUA.js.map} +0 -0
- /package/dist/{chunk-3WUCM7YF.js.map → chunk-JYKUR5KT.js.map} +0 -0
- /package/dist/{chunk-EFANLEWI.js.map → chunk-LGXWM3UJ.js.map} +0 -0
- /package/dist/{chunk-TRUEH4I2.js.map → chunk-PUOQEL6A.js.map} +0 -0
- /package/dist/{chunk-NT7OGKYX.js.map → chunk-SG5YDHSE.js.map} +0 -0
- /package/dist/{chunk-AMK3FNOW.js.map → chunk-WAFY2TCB.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/db",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
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.
|
|
119
|
-
"@opengeni/codex": "1.4.
|
|
120
|
-
"@opengeni/config": "1.4.
|
|
121
|
-
"@opengeni/contracts": "1.4.
|
|
122
|
-
"@opengeni/network": "1.4.
|
|
123
|
-
"@opengeni/observability": "1.4.
|
|
118
|
+
"@opengeni/codemode": "1.4.4",
|
|
119
|
+
"@opengeni/codex": "1.4.4",
|
|
120
|
+
"@opengeni/config": "1.4.4",
|
|
121
|
+
"@opengeni/contracts": "1.4.4",
|
|
122
|
+
"@opengeni/network": "1.4.4",
|
|
123
|
+
"@opengeni/observability": "1.4.4",
|
|
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
|
-
/**
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
.
|
|
296
|
-
|
|
297
|
-
.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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: "
|
|
292
|
-
operation: mapCodemodeOperation(
|
|
293
|
-
claimId:
|
|
356
|
+
status: "claimed",
|
|
357
|
+
operation: mapCodemodeOperation(claimed),
|
|
358
|
+
claimId: input.claimId,
|
|
359
|
+
reclaimed,
|
|
294
360
|
};
|
|
295
|
-
}
|
|
296
|
-
|
|
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
|
|
package/src/credit-balances.ts
CHANGED
|
@@ -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
|
-
/**
|
|
70
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
149
|
+
return allocateToCoveringGrants(balance, amountMicros, usage);
|
|
109
150
|
}
|