@tangle-network/agent-gateway 0.7.1 → 0.8.1
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/README.md +90 -3
- package/dist/chunk-C7Z2BRYV.js +5693 -0
- package/dist/chunk-C7Z2BRYV.js.map +1 -0
- package/dist/chunk-GITV7CPT.js +84 -0
- package/dist/chunk-GITV7CPT.js.map +1 -0
- package/dist/chunk-J5SDVHOL.js +104 -0
- package/dist/chunk-J5SDVHOL.js.map +1 -0
- package/dist/index.d.ts +70 -10
- package/dist/index.js +303 -21
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +7 -2
- package/dist/middleware.js +3 -2
- package/dist/nonce-store.d.ts +47 -11
- package/dist/nonce-store.js +9 -3
- package/dist/observer-types-A0RtA8uL.d.ts +95 -0
- package/dist/observer.d.ts +79 -0
- package/dist/observer.js +11 -0
- package/dist/observer.js.map +1 -0
- package/dist/{types-DEsMmS-X.d.ts → types-oQ58UakD.d.ts} +447 -172
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/src/a2a/execution-fence.ts +162 -0
- package/src/a2a/handler.ts +506 -560
- package/src/a2a/message-send-execution.ts +241 -0
- package/src/a2a/message-stream-execution.ts +392 -0
- package/src/a2a/payment-recovery.ts +431 -0
- package/src/a2a/push-config-methods.ts +158 -0
- package/src/a2a/push-notifications.ts +172 -22
- package/src/a2a/task-cancellation.ts +50 -0
- package/src/a2a/task-finalization.ts +451 -0
- package/src/a2a/task-lifecycle.ts +54 -0
- package/src/a2a/task-methods.ts +163 -0
- package/src/a2a/task-push-delivery.ts +119 -0
- package/src/a2a/task-recovery.ts +11 -0
- package/src/a2a/task-state.ts +99 -0
- package/src/a2a/task-store-sql.ts +222 -24
- package/src/a2a/task-store.ts +58 -1
- package/src/a2a/task-submission-recovery.ts +178 -0
- package/src/a2a/types.ts +1 -0
- package/src/dispatch-authorization.ts +468 -0
- package/src/dispatch-payment-recovery.ts +248 -0
- package/src/dispatch-payment.ts +425 -0
- package/src/dispatch-pricing.ts +108 -0
- package/src/dispatch-sandbox.ts +424 -0
- package/src/dispatch-settlement.ts +139 -0
- package/src/dispatch-types.ts +84 -0
- package/src/dispatch.ts +35 -483
- package/src/index.ts +59 -1
- package/src/middleware.ts +339 -35
- package/src/mpp-payment.ts +117 -0
- package/src/nonce-store.ts +122 -20
- package/src/observer-types.ts +63 -0
- package/src/observer.ts +3 -63
- package/src/payment-operations.ts +485 -0
- package/src/payment-recovery-sql.ts +108 -0
- package/src/payment-recovery-worker.ts +488 -0
- package/src/payment-recovery.ts +331 -0
- package/src/payment-types.ts +48 -0
- package/src/types.ts +188 -49
- package/src/verify.ts +240 -71
- package/dist/chunk-M7ZJAK4K.js +0 -53
- package/dist/chunk-M7ZJAK4K.js.map +0 -1
- package/dist/chunk-Q4YAIEZY.js +0 -1763
- package/dist/chunk-Q4YAIEZY.js.map +0 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AuthorizedRequest,
|
|
3
|
+
releasePayment,
|
|
4
|
+
settleAndRecord,
|
|
5
|
+
} from './dispatch'
|
|
6
|
+
import { assertMppChargeOperation } from './mpp-payment'
|
|
7
|
+
import {
|
|
8
|
+
deserializePaymentOperation,
|
|
9
|
+
PaymentRecoveryFenceError,
|
|
10
|
+
recoveryTiming,
|
|
11
|
+
updateOwnedPaymentRecovery,
|
|
12
|
+
type PaymentRecoveryRecord,
|
|
13
|
+
} from './payment-recovery'
|
|
14
|
+
import type { AgentMeta, GatewayConfig, SandboxUsageReceipt } from './types'
|
|
15
|
+
|
|
16
|
+
interface RecoveryWorkerOptions {
|
|
17
|
+
now?: number
|
|
18
|
+
/** Fresh wall-clock source for each row's lease and retry timestamps. */
|
|
19
|
+
clock?: () => number
|
|
20
|
+
workerId?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RecoverPaymentOptions extends RecoveryWorkerOptions {
|
|
24
|
+
/** Process one requested row even when its normal retry time is later. */
|
|
25
|
+
force?: boolean
|
|
26
|
+
/** Exact receipt recovered from a durable protocol record, such as an A2A task. */
|
|
27
|
+
usage?: SandboxUsageReceipt
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Batch recovery never accepts receipt data because each receipt belongs to one row. */
|
|
31
|
+
export interface RecoverPaymentsOptions extends RecoveryWorkerOptions {
|
|
32
|
+
limit?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PaymentRecoveryRun {
|
|
36
|
+
scanned: number
|
|
37
|
+
reconciled: number
|
|
38
|
+
deferred: number
|
|
39
|
+
failed: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Scan and reconcile due payment rows. Safe to run concurrently on many workers. */
|
|
43
|
+
export async function recoverPayments(
|
|
44
|
+
config: GatewayConfig,
|
|
45
|
+
options: RecoverPaymentsOptions = {},
|
|
46
|
+
): Promise<PaymentRecoveryRun> {
|
|
47
|
+
const recovery = config.paymentRecovery
|
|
48
|
+
if (!recovery) throw new Error('durable payment recovery is not configured')
|
|
49
|
+
const scanNow = options.now ?? recoveryNow(options)
|
|
50
|
+
const limit = options.limit ?? 100
|
|
51
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
52
|
+
throw new Error('payment recovery limit must be a positive safe integer')
|
|
53
|
+
}
|
|
54
|
+
const due = await recovery.store.listDue(scanNow, limit)
|
|
55
|
+
const run: PaymentRecoveryRun = { scanned: due.length, reconciled: 0, deferred: 0, failed: 0 }
|
|
56
|
+
for (const candidate of due) {
|
|
57
|
+
try {
|
|
58
|
+
const result = await recoverPayment(candidate.id, config, {
|
|
59
|
+
force: false,
|
|
60
|
+
...(options.clock ? { clock: options.clock } : {}),
|
|
61
|
+
...(options.workerId ? { workerId: options.workerId } : {}),
|
|
62
|
+
})
|
|
63
|
+
if (result?.state === 'reconciled') run.reconciled += 1
|
|
64
|
+
else run.deferred += 1
|
|
65
|
+
} catch {
|
|
66
|
+
run.failed += 1
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return run
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Reconcile one payment identity. Hosts can expose this through a private worker API. */
|
|
73
|
+
export async function recoverPayment(
|
|
74
|
+
recoveryId: string,
|
|
75
|
+
config: GatewayConfig,
|
|
76
|
+
options: RecoverPaymentOptions = {},
|
|
77
|
+
): Promise<PaymentRecoveryRecord | undefined> {
|
|
78
|
+
const recovery = config.paymentRecovery
|
|
79
|
+
if (!recovery) throw new Error('durable payment recovery is not configured')
|
|
80
|
+
const scanNow = options.now ?? recoveryNow(options)
|
|
81
|
+
const current = await recovery.store.get(recoveryId)
|
|
82
|
+
if (!current || current.state === 'reconciled') return current
|
|
83
|
+
if (!options.force && current.nextAttemptAt > scanNow) return current
|
|
84
|
+
const now = options.now ?? recoveryNow(options)
|
|
85
|
+
|
|
86
|
+
const leased = await acquireLease(
|
|
87
|
+
current.id,
|
|
88
|
+
config,
|
|
89
|
+
options.workerId ? `${options.workerId}:${randomId()}` : randomId(),
|
|
90
|
+
now,
|
|
91
|
+
)
|
|
92
|
+
if (!leased) return recovery.store.get(recoveryId)
|
|
93
|
+
const fenceId = requireFence(leased)
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const ready = options.usage
|
|
97
|
+
? await persistRecoveredUsage(leased, options.usage, config, now)
|
|
98
|
+
: leased
|
|
99
|
+
await reconcileLeased(ready, config, now)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
102
|
+
const failedAt = options.now ?? recoveryNow(options)
|
|
103
|
+
try {
|
|
104
|
+
await updateOwnedPaymentRecovery(recovery.store, recoveryId, fenceId, (record) => ({
|
|
105
|
+
...record,
|
|
106
|
+
attempts: record.attempts + 1,
|
|
107
|
+
lastError: message,
|
|
108
|
+
lease: undefined,
|
|
109
|
+
nextAttemptAt: failedAt + recoveryTiming(recovery).retryDelayMs,
|
|
110
|
+
}), failedAt)
|
|
111
|
+
} catch (updateError) {
|
|
112
|
+
if (!(updateError instanceof PaymentRecoveryFenceError)) throw updateError
|
|
113
|
+
}
|
|
114
|
+
throw error
|
|
115
|
+
}
|
|
116
|
+
return recovery.store.get(recoveryId)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function recoveryNow(options: RecoveryWorkerOptions): number {
|
|
120
|
+
return options.clock?.() ?? Date.now()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function reconcileLeased(
|
|
124
|
+
record: PaymentRecoveryRecord,
|
|
125
|
+
config: GatewayConfig,
|
|
126
|
+
now: number,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
if (record.payment.kind === 'mpp-charge' && !record.payment.operation) {
|
|
129
|
+
record = await recoverUnknownMppCharge(record, config, now)
|
|
130
|
+
}
|
|
131
|
+
if (record.state === 'reconciled') return
|
|
132
|
+
|
|
133
|
+
if (record.state === 'claiming') {
|
|
134
|
+
if (record.payment.kind === 'x402') {
|
|
135
|
+
if (!config.x402.paymentOperations) {
|
|
136
|
+
throw new Error('x402 payment recovery is not configured')
|
|
137
|
+
}
|
|
138
|
+
const recovered = await config.x402.paymentOperations.reclaimPayment(record.payment.operationId)
|
|
139
|
+
if (recovered.state === 'not-found') {
|
|
140
|
+
if (recovered.operationId !== record.payment.operationId) {
|
|
141
|
+
throw new Error('x402 recovery operation id mismatch')
|
|
142
|
+
}
|
|
143
|
+
await completeRecord(record, config, now)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (recovered.state !== 'released' && recovered.state !== 'reclaimed') {
|
|
147
|
+
throw new Error(`ambiguous x402 claim recovered in state ${recovered.state}`)
|
|
148
|
+
}
|
|
149
|
+
await completeRecord(record, config, now)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
throw new Error('MPP charge confirmation remains unresolved')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (record.state === 'claimed' && !record.workStarted) {
|
|
156
|
+
await releaseRecoveredPayment(record, config, 'request ended before sandbox execution', now)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (record.state === 'releasing') {
|
|
161
|
+
await releaseRecoveredPayment(record, config, record.reason ?? 'payment recovery release', now)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (
|
|
166
|
+
record.state === 'executing' ||
|
|
167
|
+
record.state === 'retained' ||
|
|
168
|
+
record.state === 'settling' ||
|
|
169
|
+
(record.state === 'claimed' && record.workStarted)
|
|
170
|
+
) {
|
|
171
|
+
if (!record.usage && record.fallbackAt !== undefined && record.fallbackAt > now) {
|
|
172
|
+
await updateOwnedPaymentRecovery(
|
|
173
|
+
config.paymentRecovery!.store,
|
|
174
|
+
record.id,
|
|
175
|
+
requireFence(record),
|
|
176
|
+
(current) => ({
|
|
177
|
+
...current,
|
|
178
|
+
lease: undefined,
|
|
179
|
+
nextAttemptAt: record.fallbackAt!,
|
|
180
|
+
}),
|
|
181
|
+
now,
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
await settleRecoveredPayment(record, config)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw new Error(`payment recovery cannot reconcile state ${record.state}`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function recoverUnknownMppCharge(
|
|
193
|
+
record: PaymentRecoveryRecord,
|
|
194
|
+
config: GatewayConfig,
|
|
195
|
+
now: number,
|
|
196
|
+
): Promise<PaymentRecoveryRecord> {
|
|
197
|
+
if (record.payment.kind !== 'mpp-charge') return record
|
|
198
|
+
const method = record.payment.method
|
|
199
|
+
const operationId = record.payment.operationId
|
|
200
|
+
const lifecycle = config.mpp?.charge
|
|
201
|
+
if (!lifecycle || lifecycle.protocolVersion !== 1) {
|
|
202
|
+
throw new Error('MPP charge recovery is not configured')
|
|
203
|
+
}
|
|
204
|
+
const result = await lifecycle.recoverPayment(operationId)
|
|
205
|
+
if (!('protocolVersion' in result)) {
|
|
206
|
+
if (result.operationId !== operationId) {
|
|
207
|
+
throw new Error('MPP recovery operation id mismatch')
|
|
208
|
+
}
|
|
209
|
+
if (result.state === 'not-found') {
|
|
210
|
+
await completeRecord(record, config, now)
|
|
211
|
+
return requireRecord(record.id, config)
|
|
212
|
+
}
|
|
213
|
+
throw new Error('MPP charge confirmation is still pending')
|
|
214
|
+
}
|
|
215
|
+
assertMppChargeOperation(
|
|
216
|
+
result,
|
|
217
|
+
{
|
|
218
|
+
operationId,
|
|
219
|
+
requestId: record.attribution.requestId,
|
|
220
|
+
method,
|
|
221
|
+
},
|
|
222
|
+
['confirmed', 'releasing', 'released'],
|
|
223
|
+
false,
|
|
224
|
+
)
|
|
225
|
+
return updateOwnedPaymentRecovery(
|
|
226
|
+
config.paymentRecovery!.store,
|
|
227
|
+
record.id,
|
|
228
|
+
requireFence(record),
|
|
229
|
+
(current) => ({
|
|
230
|
+
...current,
|
|
231
|
+
state: result.state === 'released'
|
|
232
|
+
? 'reconciled'
|
|
233
|
+
: result.state === 'releasing'
|
|
234
|
+
? 'releasing'
|
|
235
|
+
: current.workStarted
|
|
236
|
+
? 'executing'
|
|
237
|
+
: 'claimed',
|
|
238
|
+
payment: { kind: 'mpp-charge', method, operationId, operation: result },
|
|
239
|
+
...(result.state === 'released'
|
|
240
|
+
? { reconciledAt: now, nextAttemptAt: Number.MAX_SAFE_INTEGER, lease: undefined }
|
|
241
|
+
: {}),
|
|
242
|
+
}),
|
|
243
|
+
now,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function releaseRecoveredPayment(
|
|
248
|
+
record: PaymentRecoveryRecord,
|
|
249
|
+
config: GatewayConfig,
|
|
250
|
+
reason: string,
|
|
251
|
+
now: number,
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const authz = authorizedRequest(record)
|
|
254
|
+
if (record.payment.kind === 'x402') {
|
|
255
|
+
if (!record.payment.operation) {
|
|
256
|
+
if (!config.x402.paymentOperations) throw new Error('x402 payment recovery is not configured')
|
|
257
|
+
const recovered = await config.x402.paymentOperations.reclaimPayment(record.payment.operationId)
|
|
258
|
+
if (recovered.state === 'not-found') {
|
|
259
|
+
if (recovered.operationId !== record.payment.operationId) {
|
|
260
|
+
throw new Error('x402 recovery operation id mismatch')
|
|
261
|
+
}
|
|
262
|
+
await completeRecord(record, config, now)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (recovered.state !== 'released' && recovered.state !== 'reclaimed') {
|
|
266
|
+
throw new Error(`x402 release recovered in state ${recovered.state}`)
|
|
267
|
+
}
|
|
268
|
+
await completeRecord(record, config, now)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
authz.paymentOperation = deserializePaymentOperation(record.payment.operation)
|
|
272
|
+
authz.paymentOperationAcquired = true
|
|
273
|
+
} else {
|
|
274
|
+
if (!record.payment.operation) throw new Error('MPP release operation is missing')
|
|
275
|
+
authz.mppChargeOperation = record.payment.operation
|
|
276
|
+
}
|
|
277
|
+
await releasePayment(authz, config, reason)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function settleRecoveredPayment(
|
|
281
|
+
record: PaymentRecoveryRecord,
|
|
282
|
+
config: GatewayConfig,
|
|
283
|
+
): Promise<void> {
|
|
284
|
+
const authz = authorizedRequest(record)
|
|
285
|
+
if (record.payment.kind === 'x402') {
|
|
286
|
+
if (!record.payment.operation) throw new Error('x402 settlement operation is missing')
|
|
287
|
+
authz.paymentOperation = deserializePaymentOperation(record.payment.operation)
|
|
288
|
+
authz.paymentOperationAcquired = true
|
|
289
|
+
} else {
|
|
290
|
+
if (!record.payment.operation) throw new Error('MPP settlement operation is missing')
|
|
291
|
+
authz.mppChargeOperation = record.payment.operation
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const fallback = record.settlementBasis === 'quoted-ceiling' || !record.usage
|
|
295
|
+
const usage = record.usage ?? quotedCeilingUsage(record)
|
|
296
|
+
const settlementBasis = fallback
|
|
297
|
+
? 'quoted-ceiling'
|
|
298
|
+
: record.settlementBasis ?? 'usage-receipt'
|
|
299
|
+
await settleAndRecord(
|
|
300
|
+
recoveryAgent(record),
|
|
301
|
+
authz,
|
|
302
|
+
usage,
|
|
303
|
+
config,
|
|
304
|
+
config.observer,
|
|
305
|
+
{
|
|
306
|
+
usageAlreadyRecorded: record.usageRecorded,
|
|
307
|
+
settlementBasis,
|
|
308
|
+
...(settlementBasis === 'quoted-ceiling' && record.payment.kind === 'x402'
|
|
309
|
+
? { paymentAmount: BigInt(record.attribution.requiredAmount) }
|
|
310
|
+
: {}),
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function quotedCeilingUsage(record: PaymentRecoveryRecord): SandboxUsageReceipt {
|
|
316
|
+
const amount = BigInt(record.attribution.requiredAmount)
|
|
317
|
+
const providerCostUsd = baseUnitsToNumber(amount, record.attribution.currencyDecimals)
|
|
318
|
+
if (!Number.isFinite(providerCostUsd) || providerCostUsd < 0) {
|
|
319
|
+
throw new Error('quoted payment ceiling cannot be represented as USD')
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
inputTokens: 0,
|
|
323
|
+
outputTokens: 0,
|
|
324
|
+
reasoningTokens: 0,
|
|
325
|
+
toolTokens: 0,
|
|
326
|
+
toolCallCount: 0,
|
|
327
|
+
providerCostUsd,
|
|
328
|
+
budgetEnforced: true,
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function baseUnitsToNumber(amount: bigint, decimals: number): number {
|
|
333
|
+
const digits = amount.toString().padStart(decimals + 1, '0')
|
|
334
|
+
if (decimals === 0) return Number(digits)
|
|
335
|
+
const split = digits.length - decimals
|
|
336
|
+
return Number(`${digits.slice(0, split)}.${digits.slice(split)}`)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function authorizedRequest(record: PaymentRecoveryRecord): AuthorizedRequest {
|
|
340
|
+
const attribution = record.attribution
|
|
341
|
+
return {
|
|
342
|
+
agent: recoveryAgent(record),
|
|
343
|
+
consumerId: attribution.consumerId,
|
|
344
|
+
paymentMethod: attribution.paymentMethod,
|
|
345
|
+
keyInfo: null,
|
|
346
|
+
userMessage: '[payment recovery]',
|
|
347
|
+
rateLimitRemaining: undefined,
|
|
348
|
+
requestId: attribution.requestId,
|
|
349
|
+
startMs: attribution.startMs,
|
|
350
|
+
maxOutputTokens: attribution.maxOutputTokens,
|
|
351
|
+
executionBudget: attribution.executionBudget,
|
|
352
|
+
requiredPaymentAmount: BigInt(attribution.requiredAmount),
|
|
353
|
+
paymentPayload: null,
|
|
354
|
+
paymentRecoveryId: record.id,
|
|
355
|
+
paymentRecoveryFence: requireFence(record),
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function recoveryAgent(record: PaymentRecoveryRecord): AgentMeta {
|
|
360
|
+
const attribution = record.attribution
|
|
361
|
+
return {
|
|
362
|
+
id: attribution.agentId,
|
|
363
|
+
ownerId: '[payment recovery]',
|
|
364
|
+
slug: attribution.agentSlug,
|
|
365
|
+
pricePerTokenUsd: attribution.pricePerTokenUsd,
|
|
366
|
+
platformFeePercent: attribution.platformFeePercent,
|
|
367
|
+
sandboxEndpoint: null,
|
|
368
|
+
remoteSandboxId: null,
|
|
369
|
+
remoteBearerToken: null,
|
|
370
|
+
enabled: true,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function acquireLease(
|
|
375
|
+
id: string,
|
|
376
|
+
config: GatewayConfig,
|
|
377
|
+
workerId: string,
|
|
378
|
+
now: number,
|
|
379
|
+
): Promise<PaymentRecoveryRecord | undefined> {
|
|
380
|
+
const recovery = config.paymentRecovery!
|
|
381
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
382
|
+
const current = await recovery.store.get(id)
|
|
383
|
+
if (!current || current.state === 'reconciled') return current
|
|
384
|
+
if (current.lease && current.lease.expiresAt > now) return undefined
|
|
385
|
+
const next: PaymentRecoveryRecord = {
|
|
386
|
+
...current,
|
|
387
|
+
revision: current.revision + 1,
|
|
388
|
+
lease: { id: workerId, expiresAt: now + recoveryTiming(recovery).leaseMs },
|
|
389
|
+
updatedAt: now,
|
|
390
|
+
}
|
|
391
|
+
if (await recovery.store.compareAndSet(current, next)) return next
|
|
392
|
+
}
|
|
393
|
+
throw new Error(`payment recovery record ${id} changed too many times`)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function completeRecord(
|
|
397
|
+
record: PaymentRecoveryRecord,
|
|
398
|
+
config: GatewayConfig,
|
|
399
|
+
now: number,
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
await updateOwnedPaymentRecovery(
|
|
402
|
+
config.paymentRecovery!.store,
|
|
403
|
+
record.id,
|
|
404
|
+
requireFence(record),
|
|
405
|
+
(current) => ({
|
|
406
|
+
...current,
|
|
407
|
+
state: 'reconciled',
|
|
408
|
+
lease: undefined,
|
|
409
|
+
lastError: undefined,
|
|
410
|
+
nextAttemptAt: Number.MAX_SAFE_INTEGER,
|
|
411
|
+
reconciledAt: now,
|
|
412
|
+
}),
|
|
413
|
+
now,
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function requireRecord(id: string, config: GatewayConfig): Promise<PaymentRecoveryRecord> {
|
|
418
|
+
const record = await config.paymentRecovery!.store.get(id)
|
|
419
|
+
if (!record) throw new Error(`payment recovery record ${id} was not found`)
|
|
420
|
+
return record
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function randomId(): string {
|
|
424
|
+
return globalThis.crypto.randomUUID()
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function requireFence(record: PaymentRecoveryRecord): string {
|
|
428
|
+
if (!record.lease?.id) throw new Error(`payment recovery record ${record.id} has no owner fence`)
|
|
429
|
+
return record.lease.id
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function persistRecoveredUsage(
|
|
433
|
+
record: PaymentRecoveryRecord,
|
|
434
|
+
usage: SandboxUsageReceipt,
|
|
435
|
+
config: GatewayConfig,
|
|
436
|
+
now: number,
|
|
437
|
+
): Promise<PaymentRecoveryRecord> {
|
|
438
|
+
assertRecoveryUsage(usage)
|
|
439
|
+
return updateOwnedPaymentRecovery(
|
|
440
|
+
config.paymentRecovery!.store,
|
|
441
|
+
record.id,
|
|
442
|
+
requireFence(record),
|
|
443
|
+
(current) => {
|
|
444
|
+
if (current.usage && !sameUsage(current.usage, usage)) {
|
|
445
|
+
throw new Error('payment recovery receipt does not match persisted usage')
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
...current,
|
|
449
|
+
state: 'settling',
|
|
450
|
+
workStarted: true,
|
|
451
|
+
usage,
|
|
452
|
+
settlementBasis: 'usage-receipt',
|
|
453
|
+
nextAttemptAt: now,
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
now,
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function assertRecoveryUsage(usage: SandboxUsageReceipt): void {
|
|
461
|
+
for (const [name, value] of [
|
|
462
|
+
['inputTokens', usage.inputTokens],
|
|
463
|
+
['outputTokens', usage.outputTokens],
|
|
464
|
+
['reasoningTokens', usage.reasoningTokens],
|
|
465
|
+
['toolTokens', usage.toolTokens],
|
|
466
|
+
['toolCallCount', usage.toolCallCount],
|
|
467
|
+
] as const) {
|
|
468
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
469
|
+
throw new Error(`payment recovery ${name} must be a non-negative safe integer`)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (!Number.isFinite(usage.providerCostUsd) || usage.providerCostUsd < 0) {
|
|
473
|
+
throw new Error('payment recovery providerCostUsd must be finite and non-negative')
|
|
474
|
+
}
|
|
475
|
+
if (usage.budgetEnforced !== true) {
|
|
476
|
+
throw new Error('payment recovery receipt must confirm budget enforcement')
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function sameUsage(left: SandboxUsageReceipt, right: SandboxUsageReceipt): boolean {
|
|
481
|
+
return left.inputTokens === right.inputTokens &&
|
|
482
|
+
left.outputTokens === right.outputTokens &&
|
|
483
|
+
left.reasoningTokens === right.reasoningTokens &&
|
|
484
|
+
left.toolTokens === right.toolTokens &&
|
|
485
|
+
left.toolCallCount === right.toolCallCount &&
|
|
486
|
+
left.providerCostUsd === right.providerCostUsd &&
|
|
487
|
+
left.budgetEnforced === right.budgetEnforced
|
|
488
|
+
}
|