@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,451 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthorizedRequest,
|
|
3
|
+
SettleAndRecordOptions,
|
|
4
|
+
} from '../dispatch'
|
|
5
|
+
import type { PaymentOperation, PaymentOperations } from '../payment-operations'
|
|
6
|
+
import {
|
|
7
|
+
deserializePaymentOperation,
|
|
8
|
+
serializePaymentOperation,
|
|
9
|
+
type PaymentRecoveryConfig,
|
|
10
|
+
type PaymentRecoveryRecord,
|
|
11
|
+
type SerializedPaymentOperation,
|
|
12
|
+
} from '../payment-recovery'
|
|
13
|
+
import type { AgentMeta, PaymentMethod, SandboxExecutionBudget, SandboxUsageReceipt } from '../types'
|
|
14
|
+
import {
|
|
15
|
+
clearPaymentRecoveryMarker,
|
|
16
|
+
readPaymentRecoveryMarker,
|
|
17
|
+
} from './payment-recovery'
|
|
18
|
+
import { clearTaskSubmission } from './task-submission-recovery'
|
|
19
|
+
import type { Task } from './types'
|
|
20
|
+
import {
|
|
21
|
+
agentMessage,
|
|
22
|
+
asError,
|
|
23
|
+
compareAndSetTask,
|
|
24
|
+
clearTaskMetadata,
|
|
25
|
+
cryptoRandomId,
|
|
26
|
+
isTerminal,
|
|
27
|
+
persistTaskIfCurrent,
|
|
28
|
+
type TaskStateStore,
|
|
29
|
+
withStatus,
|
|
30
|
+
} from './task-state'
|
|
31
|
+
import { responseTextToArtifact } from './translate'
|
|
32
|
+
import type { Artifact } from './types'
|
|
33
|
+
|
|
34
|
+
const FINALIZING_METADATA_KEY = 'gatewayFinalizing'
|
|
35
|
+
const FINALIZATION_LEASE_MS = 5 * 60 * 1000
|
|
36
|
+
|
|
37
|
+
export type FinalizationState = 'completed' | 'input-required' | 'canceled'
|
|
38
|
+
|
|
39
|
+
export interface FinalizationRecord {
|
|
40
|
+
version: 1
|
|
41
|
+
lease: { id: string; expiresAt: number }
|
|
42
|
+
agentSlug: string
|
|
43
|
+
requestId: string
|
|
44
|
+
consumerId: string
|
|
45
|
+
paymentMethod: PaymentMethod
|
|
46
|
+
startMs: number
|
|
47
|
+
operationId: string | null
|
|
48
|
+
paymentOperation: SerializedPaymentOperation | null
|
|
49
|
+
receipt: SandboxUsageReceipt
|
|
50
|
+
artifact: Artifact | null
|
|
51
|
+
inputRequired: boolean
|
|
52
|
+
inputRequiredPrompt?: string
|
|
53
|
+
finalState?: FinalizationState
|
|
54
|
+
maxOutputTokens: number
|
|
55
|
+
executionBudget: SandboxExecutionBudget
|
|
56
|
+
usageRecorded: boolean
|
|
57
|
+
recoveryAttempts?: number
|
|
58
|
+
recoveryError?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TaskFinalizationDependencies {
|
|
62
|
+
taskStore: TaskStateStore
|
|
63
|
+
settle: (
|
|
64
|
+
authz: AuthorizedRequest,
|
|
65
|
+
usage: SandboxUsageReceipt,
|
|
66
|
+
options?: SettleAndRecordOptions,
|
|
67
|
+
) => Promise<void>
|
|
68
|
+
resolveAgent: (slug: string) => Promise<AgentMeta | null | undefined>
|
|
69
|
+
paymentOperations?: PaymentOperations
|
|
70
|
+
paymentRecovery?: PaymentRecoveryConfig
|
|
71
|
+
recoverDurablePayment: (
|
|
72
|
+
recoveryId: string,
|
|
73
|
+
options?: { force?: boolean; usage?: SandboxUsageReceipt },
|
|
74
|
+
) => Promise<PaymentRecoveryRecord | undefined>
|
|
75
|
+
releasePaymentAfterFailure: (
|
|
76
|
+
authz: AuthorizedRequest,
|
|
77
|
+
reason: string,
|
|
78
|
+
workObserved: boolean,
|
|
79
|
+
) => Promise<void>
|
|
80
|
+
releaseTaskPayment: (
|
|
81
|
+
authz: AuthorizedRequest,
|
|
82
|
+
task: Task,
|
|
83
|
+
reason: string,
|
|
84
|
+
workObserved: boolean,
|
|
85
|
+
) => Promise<Task>
|
|
86
|
+
deliverPush: (task: Task) => Promise<void>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildFinalizationRecord(
|
|
90
|
+
authz: AuthorizedRequest,
|
|
91
|
+
receipt: SandboxUsageReceipt,
|
|
92
|
+
artifact: Artifact | null,
|
|
93
|
+
inputRequired: boolean,
|
|
94
|
+
inputRequiredPrompt: string | undefined,
|
|
95
|
+
finalState: FinalizationState = inputRequired ? 'input-required' : 'completed',
|
|
96
|
+
): FinalizationRecord {
|
|
97
|
+
const operation = authz.paymentOperation
|
|
98
|
+
return {
|
|
99
|
+
version: 1,
|
|
100
|
+
lease: { id: cryptoRandomId(), expiresAt: Date.now() + FINALIZATION_LEASE_MS },
|
|
101
|
+
agentSlug: authz.agent.slug,
|
|
102
|
+
requestId: authz.requestId,
|
|
103
|
+
consumerId: authz.consumerId,
|
|
104
|
+
paymentMethod: authz.paymentMethod,
|
|
105
|
+
startMs: authz.startMs,
|
|
106
|
+
operationId: operation?.operationId ?? null,
|
|
107
|
+
paymentOperation: operation ? serializePaymentOperation(operation) : null,
|
|
108
|
+
receipt,
|
|
109
|
+
artifact,
|
|
110
|
+
inputRequired,
|
|
111
|
+
...(inputRequiredPrompt ? { inputRequiredPrompt } : {}),
|
|
112
|
+
finalState,
|
|
113
|
+
maxOutputTokens: authz.maxOutputTokens,
|
|
114
|
+
executionBudget: authz.executionBudget,
|
|
115
|
+
usageRecorded: false,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function withFinalizationRecord(task: Task, record: FinalizationRecord): Task {
|
|
120
|
+
return {
|
|
121
|
+
...task,
|
|
122
|
+
metadata: { ...(task.metadata ?? {}), [FINALIZING_METADATA_KEY]: record },
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function readFinalizationRecord(task: Task): FinalizationRecord | undefined {
|
|
127
|
+
const raw = task.metadata?.[FINALIZING_METADATA_KEY]
|
|
128
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
129
|
+
const record = raw as Partial<FinalizationRecord>
|
|
130
|
+
if (
|
|
131
|
+
record.version !== 1 ||
|
|
132
|
+
!record.lease ||
|
|
133
|
+
typeof record.lease.id !== 'string' ||
|
|
134
|
+
typeof record.lease.expiresAt !== 'number'
|
|
135
|
+
) {
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
return record as FinalizationRecord
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function isTaskFinalizing(task: Task): boolean {
|
|
142
|
+
const marker = task.metadata?.[FINALIZING_METADATA_KEY]
|
|
143
|
+
return marker === true || (typeof marker === 'object' && marker !== null)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function clearFinalizationMarker(task: Task): Task {
|
|
147
|
+
return clearTaskMetadata(task, FINALIZING_METADATA_KEY)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function markUsageRecordedRecord(task: Task): Task {
|
|
151
|
+
const record = readFinalizationRecord(task)
|
|
152
|
+
if (!record || record.usageRecorded) return task
|
|
153
|
+
return withFinalizationRecord(task, { ...record, usageRecorded: true })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function markUsageRecorded(
|
|
157
|
+
taskStore: TaskStateStore,
|
|
158
|
+
task: Task,
|
|
159
|
+
): Promise<Task> {
|
|
160
|
+
const marked = markUsageRecordedRecord(task)
|
|
161
|
+
if (marked === task) return task
|
|
162
|
+
if (await compareAndSetTask(taskStore, task, marked)) return marked
|
|
163
|
+
return await taskStore.get(task.id) ?? marked
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function retainFinalizationForRecovery(
|
|
167
|
+
taskStore: TaskStateStore,
|
|
168
|
+
taskId: string,
|
|
169
|
+
leaseId: string,
|
|
170
|
+
error: Error,
|
|
171
|
+
): Promise<Task | undefined> {
|
|
172
|
+
const current = await taskStore.get(taskId)
|
|
173
|
+
if (!current) return undefined
|
|
174
|
+
const record = readFinalizationRecord(current)
|
|
175
|
+
if (!record || record.lease.id !== leaseId) return undefined
|
|
176
|
+
const retry: FinalizationRecord = {
|
|
177
|
+
...record,
|
|
178
|
+
lease: { id: cryptoRandomId(), expiresAt: Date.now() + FINALIZATION_LEASE_MS },
|
|
179
|
+
recoveryAttempts: (record.recoveryAttempts ?? 0) + 1,
|
|
180
|
+
recoveryError: error.message,
|
|
181
|
+
}
|
|
182
|
+
const next = withFinalizationRecord(current, retry)
|
|
183
|
+
if (await compareAndSetTask(taskStore, current, next)) return next
|
|
184
|
+
return await taskStore.get(taskId)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function completeCanceledTask(
|
|
188
|
+
authz: AuthorizedRequest,
|
|
189
|
+
task: Task,
|
|
190
|
+
responseText: string,
|
|
191
|
+
usage: SandboxUsageReceipt | undefined,
|
|
192
|
+
workObserved: boolean,
|
|
193
|
+
deps: TaskFinalizationDependencies,
|
|
194
|
+
): Promise<Task> {
|
|
195
|
+
if (usage) {
|
|
196
|
+
const current = await deps.taskStore.get(task.id) ?? task
|
|
197
|
+
const finalization = buildFinalizationRecord(
|
|
198
|
+
authz,
|
|
199
|
+
usage,
|
|
200
|
+
responseText
|
|
201
|
+
? responseTextToArtifact(responseText, `${task.id}-artifact-0`)
|
|
202
|
+
: current.artifacts?.[0] ?? null,
|
|
203
|
+
false,
|
|
204
|
+
undefined,
|
|
205
|
+
'canceled',
|
|
206
|
+
)
|
|
207
|
+
let finalizingTask: Task | undefined
|
|
208
|
+
let candidate = current
|
|
209
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
210
|
+
if (isTaskFinalizing(candidate)) return candidate
|
|
211
|
+
if (isTerminal(candidate.status.state) && candidate.status.state !== 'canceled') return candidate
|
|
212
|
+
const next = withFinalizationRecord(candidate, finalization)
|
|
213
|
+
if (await compareAndSetTask(deps.taskStore, candidate, next)) {
|
|
214
|
+
finalizingTask = next
|
|
215
|
+
break
|
|
216
|
+
}
|
|
217
|
+
const latest = await deps.taskStore.get(task.id)
|
|
218
|
+
if (!latest) break
|
|
219
|
+
if (isTerminal(latest.status.state) && latest.status.state !== 'canceled') return latest
|
|
220
|
+
candidate = latest
|
|
221
|
+
}
|
|
222
|
+
if (!finalizingTask) {
|
|
223
|
+
throw new Error(`A2A task '${task.id}' changed before cancellation settlement`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let usageRecordedTask = finalizingTask
|
|
227
|
+
try {
|
|
228
|
+
await deps.settle(authz, usage, {
|
|
229
|
+
onUsageRecorded: async () => {
|
|
230
|
+
usageRecordedTask = await markUsageRecorded(deps.taskStore, usageRecordedTask)
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
usageRecordedTask = await markUsageRecorded(deps.taskStore, usageRecordedTask)
|
|
234
|
+
} catch (settlementError) {
|
|
235
|
+
await deps.releasePaymentAfterFailure(
|
|
236
|
+
authz,
|
|
237
|
+
settlementError instanceof Error ? settlementError.message : String(settlementError),
|
|
238
|
+
true,
|
|
239
|
+
)
|
|
240
|
+
const retained = await retainFinalizationForRecovery(
|
|
241
|
+
deps.taskStore,
|
|
242
|
+
task.id,
|
|
243
|
+
finalization.lease.id,
|
|
244
|
+
asError(settlementError),
|
|
245
|
+
)
|
|
246
|
+
const recoveryTask = retained ?? finalizingTask
|
|
247
|
+
console.error(
|
|
248
|
+
`[a2a] canceled task settlement retained for ${authz.requestId}:`,
|
|
249
|
+
settlementError instanceof Error ? settlementError.message : String(settlementError),
|
|
250
|
+
)
|
|
251
|
+
await deps.deliverPush(recoveryTask)
|
|
252
|
+
return recoveryTask
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const canceled = withStatus(
|
|
256
|
+
clearPaymentRecoveryMarker(clearFinalizationMarker(usageRecordedTask)),
|
|
257
|
+
'canceled',
|
|
258
|
+
undefined,
|
|
259
|
+
responseText
|
|
260
|
+
? [responseTextToArtifact(responseText, `${task.id}-artifact-0`)]
|
|
261
|
+
: finalizingTask.artifacts,
|
|
262
|
+
)
|
|
263
|
+
if (!await compareAndSetTask(deps.taskStore, usageRecordedTask, canceled)) {
|
|
264
|
+
return await deps.taskStore.get(task.id) ?? canceled
|
|
265
|
+
}
|
|
266
|
+
await deps.deliverPush(canceled)
|
|
267
|
+
return canceled
|
|
268
|
+
}
|
|
269
|
+
await deps.releaseTaskPayment(authz, task, 'a2a task canceled', workObserved)
|
|
270
|
+
const currentTask = await deps.taskStore.get(task.id)
|
|
271
|
+
const canceledBase = currentTask?.status.state === 'canceled'
|
|
272
|
+
? currentTask
|
|
273
|
+
: withStatus(currentTask ?? task, 'canceled')
|
|
274
|
+
const canceled: Task = responseText
|
|
275
|
+
? {
|
|
276
|
+
...canceledBase,
|
|
277
|
+
artifacts: [responseTextToArtifact(responseText, `${task.id}-artifact-0`)],
|
|
278
|
+
}
|
|
279
|
+
: canceledBase
|
|
280
|
+
const persisted = await persistTaskIfCurrent(deps.taskStore, currentTask ?? task, canceled)
|
|
281
|
+
await deps.deliverPush(persisted)
|
|
282
|
+
return persisted
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function recoverFinalizationIfNeeded(
|
|
286
|
+
task: Task,
|
|
287
|
+
deps: TaskFinalizationDependencies,
|
|
288
|
+
requestedAgentSlug: string,
|
|
289
|
+
): Promise<Task> {
|
|
290
|
+
if (!isTaskFinalizing(task)) return task
|
|
291
|
+
const record = readFinalizationRecord(task)
|
|
292
|
+
if (!record) {
|
|
293
|
+
return expireFinalization(task, deps, null, new Error('A2A finalization record is missing'))
|
|
294
|
+
}
|
|
295
|
+
if (record.lease.expiresAt > Date.now()) return task
|
|
296
|
+
|
|
297
|
+
const renewed: FinalizationRecord = {
|
|
298
|
+
...record,
|
|
299
|
+
lease: { id: cryptoRandomId(), expiresAt: Date.now() + FINALIZATION_LEASE_MS },
|
|
300
|
+
}
|
|
301
|
+
const leasedTask = withFinalizationRecord(task, renewed)
|
|
302
|
+
if (!await compareAndSetTask(deps.taskStore, task, leasedTask)) {
|
|
303
|
+
return await deps.taskStore.get(task.id) ?? task
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const agentSlug = renewed.agentSlug || requestedAgentSlug
|
|
308
|
+
const agent = await deps.resolveAgent(agentSlug)
|
|
309
|
+
if (!agent || !agent.enabled) throw new Error('A2A recovery agent is unavailable')
|
|
310
|
+
|
|
311
|
+
const paymentRecovery = readPaymentRecoveryMarker(leasedTask)
|
|
312
|
+
if (paymentRecovery && deps.paymentRecovery) {
|
|
313
|
+
const recovery = await deps.recoverDurablePayment(paymentRecovery.id, {
|
|
314
|
+
force: true,
|
|
315
|
+
usage: renewed.receipt,
|
|
316
|
+
})
|
|
317
|
+
if (recovery?.state !== 'reconciled') {
|
|
318
|
+
throw new Error('durable payment finalization is still pending')
|
|
319
|
+
}
|
|
320
|
+
const usageRecordedTask = await markUsageRecorded(deps.taskStore, leasedTask)
|
|
321
|
+
const recoveredTask = finalizationResultTask(usageRecordedTask, renewed)
|
|
322
|
+
if (!await compareAndSetTask(deps.taskStore, usageRecordedTask, recoveredTask)) {
|
|
323
|
+
return await deps.taskStore.get(task.id) ?? recoveredTask
|
|
324
|
+
}
|
|
325
|
+
await deps.deliverPush(recoveredTask)
|
|
326
|
+
return recoveredTask
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let paymentOperation: PaymentOperation | undefined
|
|
330
|
+
if (renewed.operationId || renewed.paymentOperation) {
|
|
331
|
+
if (!renewed.operationId || !renewed.paymentOperation) {
|
|
332
|
+
throw new Error('A2A payment operation recovery record is incomplete')
|
|
333
|
+
}
|
|
334
|
+
if (renewed.operationId !== renewed.paymentOperation.operationId) {
|
|
335
|
+
throw new Error('A2A payment operation recovery id does not match')
|
|
336
|
+
}
|
|
337
|
+
if (!deps.paymentOperations) {
|
|
338
|
+
throw new Error('A2A payment operation recovery is not configured')
|
|
339
|
+
}
|
|
340
|
+
paymentOperation = deserializePaymentOperation(renewed.paymentOperation)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let paymentAlreadySettled = false
|
|
344
|
+
if (paymentOperation && deps.paymentOperations) {
|
|
345
|
+
const currentOperation = await deps.paymentOperations.getPaymentOperation(paymentOperation.operationId)
|
|
346
|
+
if (currentOperation.state === 'not-found') {
|
|
347
|
+
throw new Error('A2A payment operation disappeared during finalization recovery')
|
|
348
|
+
}
|
|
349
|
+
if (currentOperation.operationId !== paymentOperation.operationId) {
|
|
350
|
+
throw new Error('A2A payment operation recovery returned a different operation')
|
|
351
|
+
}
|
|
352
|
+
paymentOperation = currentOperation
|
|
353
|
+
paymentAlreadySettled = currentOperation.state === 'settled'
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const authz: AuthorizedRequest = {
|
|
357
|
+
agent,
|
|
358
|
+
consumerId: renewed.consumerId,
|
|
359
|
+
paymentMethod: renewed.paymentMethod,
|
|
360
|
+
keyInfo: null,
|
|
361
|
+
userMessage: '[recovered A2A task]',
|
|
362
|
+
rateLimitRemaining: undefined,
|
|
363
|
+
requestId: renewed.requestId,
|
|
364
|
+
startMs: renewed.startMs,
|
|
365
|
+
maxOutputTokens: renewed.maxOutputTokens,
|
|
366
|
+
executionBudget: renewed.executionBudget,
|
|
367
|
+
requiredPaymentAmount: 0n,
|
|
368
|
+
paymentPayload: null,
|
|
369
|
+
...(paymentRecovery ? { paymentRecoveryId: paymentRecovery.id } : {}),
|
|
370
|
+
...(paymentOperation ? { paymentOperation, paymentOperationAcquired: true } : {}),
|
|
371
|
+
}
|
|
372
|
+
let usageRecordedTask = leasedTask
|
|
373
|
+
await deps.settle(authz, renewed.receipt, {
|
|
374
|
+
usageAlreadyRecorded: renewed.usageRecorded === true,
|
|
375
|
+
paymentAlreadySettled,
|
|
376
|
+
onUsageRecorded: async () => {
|
|
377
|
+
usageRecordedTask = await markUsageRecorded(deps.taskStore, usageRecordedTask)
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
usageRecordedTask = await markUsageRecorded(deps.taskStore, usageRecordedTask)
|
|
381
|
+
const recovered = finalizationResultTask(usageRecordedTask, renewed)
|
|
382
|
+
if (!await compareAndSetTask(deps.taskStore, usageRecordedTask, recovered)) {
|
|
383
|
+
return await deps.taskStore.get(task.id) ?? recovered
|
|
384
|
+
}
|
|
385
|
+
await deps.deliverPush(recovered)
|
|
386
|
+
return recovered
|
|
387
|
+
} catch (error) {
|
|
388
|
+
const recoveryError = asError(error)
|
|
389
|
+
console.error(`[a2a] finalization recovery failed for ${task.id}:`, recoveryError.message)
|
|
390
|
+
if (
|
|
391
|
+
(readPaymentRecoveryMarker(leasedTask) && deps.paymentRecovery) ||
|
|
392
|
+
(renewed.operationId && renewed.paymentOperation)
|
|
393
|
+
) {
|
|
394
|
+
const retained = await retainFinalizationForRecovery(
|
|
395
|
+
deps.taskStore,
|
|
396
|
+
task.id,
|
|
397
|
+
renewed.lease.id,
|
|
398
|
+
recoveryError,
|
|
399
|
+
)
|
|
400
|
+
if (retained) return retained
|
|
401
|
+
}
|
|
402
|
+
return expireFinalization(leasedTask, deps, renewed, recoveryError)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function finalizationResultTask(task: Task, record: FinalizationRecord): Task {
|
|
407
|
+
const cleanTask = clearPaymentRecoveryMarker(clearFinalizationMarker(task))
|
|
408
|
+
const finalState = record.finalState ?? (
|
|
409
|
+
task.status.state === 'canceled'
|
|
410
|
+
? 'canceled'
|
|
411
|
+
: record.inputRequired
|
|
412
|
+
? 'input-required'
|
|
413
|
+
: 'completed'
|
|
414
|
+
)
|
|
415
|
+
if (finalState === 'canceled') {
|
|
416
|
+
return withStatus(cleanTask, 'canceled', undefined, record.artifact ? [record.artifact] : cleanTask.artifacts)
|
|
417
|
+
}
|
|
418
|
+
if (finalState === 'input-required') {
|
|
419
|
+
return withStatus(
|
|
420
|
+
cleanTask,
|
|
421
|
+
'input-required',
|
|
422
|
+
record.inputRequiredPrompt ? agentMessage(cleanTask, record.inputRequiredPrompt) : undefined,
|
|
423
|
+
record.artifact ? [record.artifact] : cleanTask.artifacts,
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
return withStatus(cleanTask, 'completed', undefined, record.artifact ? [record.artifact] : cleanTask.artifacts)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function expireFinalization(
|
|
430
|
+
task: Task,
|
|
431
|
+
deps: TaskFinalizationDependencies,
|
|
432
|
+
record: FinalizationRecord | null,
|
|
433
|
+
error: Error,
|
|
434
|
+
): Promise<Task> {
|
|
435
|
+
const cleanTask = clearFinalizationMarker(task)
|
|
436
|
+
const failed: Task = {
|
|
437
|
+
...withStatus(cleanTask, 'failed'),
|
|
438
|
+
metadata: {
|
|
439
|
+
...(cleanTask.metadata ?? {}),
|
|
440
|
+
gatewayFinalizationRecovery: {
|
|
441
|
+
operationId: record?.operationId ?? null,
|
|
442
|
+
error: error.message,
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
}
|
|
446
|
+
if (await compareAndSetTask(deps.taskStore, task, failed)) {
|
|
447
|
+
await deps.deliverPush(failed)
|
|
448
|
+
return failed
|
|
449
|
+
}
|
|
450
|
+
return await deps.taskStore.get(task.id) ?? failed
|
|
451
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type GatewayState,
|
|
3
|
+
releasePayment,
|
|
4
|
+
releasePaymentAfterFailure,
|
|
5
|
+
settleAndRecord,
|
|
6
|
+
} from '../dispatch'
|
|
7
|
+
import { recoverPayment as recoverDurablePayment } from '../payment-recovery-worker'
|
|
8
|
+
import type { GatewayConfig } from '../types'
|
|
9
|
+
import { releaseTaskPayment } from './payment-recovery'
|
|
10
|
+
import type { PaymentRecoveryDependencies } from './payment-recovery'
|
|
11
|
+
import type { TaskFinalizationDependencies } from './task-finalization'
|
|
12
|
+
import type { Task } from './types'
|
|
13
|
+
import type { TaskStateStore } from './task-state'
|
|
14
|
+
|
|
15
|
+
export interface TaskLifecycleDependencies {
|
|
16
|
+
taskStore: TaskStateStore
|
|
17
|
+
config: GatewayConfig
|
|
18
|
+
state: GatewayState
|
|
19
|
+
deliverPush: (task: Task) => Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TaskLifecycle {
|
|
23
|
+
payment: PaymentRecoveryDependencies
|
|
24
|
+
finalization: TaskFinalizationDependencies
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createTaskLifecycle(deps: TaskLifecycleDependencies): TaskLifecycle {
|
|
28
|
+
const payment: PaymentRecoveryDependencies = {
|
|
29
|
+
taskStore: deps.taskStore,
|
|
30
|
+
paymentOperations: deps.config.x402.paymentOperations,
|
|
31
|
+
paymentRecovery: deps.config.paymentRecovery,
|
|
32
|
+
releasePayment: (authz, reason) => releasePayment(authz, deps.config, reason),
|
|
33
|
+
releasePaymentAfterFailure: (authz, reason, workObserved) =>
|
|
34
|
+
releasePaymentAfterFailure(authz, deps.config, reason, workObserved),
|
|
35
|
+
recoverDurablePayment: (recoveryId, options) =>
|
|
36
|
+
recoverDurablePayment(recoveryId, deps.config, options),
|
|
37
|
+
deliverPush: deps.deliverPush,
|
|
38
|
+
}
|
|
39
|
+
const finalization: TaskFinalizationDependencies = {
|
|
40
|
+
taskStore: deps.taskStore,
|
|
41
|
+
settle: (authz, usage, options) =>
|
|
42
|
+
settleAndRecord(authz.agent, authz, usage, deps.config, deps.state.obs, options),
|
|
43
|
+
resolveAgent: deps.config.resolveAgent,
|
|
44
|
+
paymentOperations: deps.config.x402.paymentOperations,
|
|
45
|
+
paymentRecovery: deps.config.paymentRecovery,
|
|
46
|
+
recoverDurablePayment: (recoveryId, options) =>
|
|
47
|
+
recoverDurablePayment(recoveryId, deps.config, options),
|
|
48
|
+
releasePaymentAfterFailure: payment.releasePaymentAfterFailure,
|
|
49
|
+
releaseTaskPayment: (authz, task, reason, workObserved) =>
|
|
50
|
+
releaseTaskPayment(authz, task, payment, reason, workObserved),
|
|
51
|
+
deliverPush: deps.deliverPush,
|
|
52
|
+
}
|
|
53
|
+
return { payment, finalization }
|
|
54
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Context } from 'hono'
|
|
2
|
+
import { hasActiveTaskExecution } from './execution-fence'
|
|
3
|
+
import { fail, ok } from './jsonrpc'
|
|
4
|
+
import { releaseTaskPayment } from './payment-recovery'
|
|
5
|
+
import type { PaymentRecoveryDependencies } from './payment-recovery'
|
|
6
|
+
import { isTaskFinalizing } from './task-finalization'
|
|
7
|
+
import type { TaskCancellationRegistry } from './task-cancellation'
|
|
8
|
+
import {
|
|
9
|
+
compareAndSetTask,
|
|
10
|
+
isTerminal,
|
|
11
|
+
withStatus,
|
|
12
|
+
} from './task-state'
|
|
13
|
+
import type { TaskStateStore } from './task-state'
|
|
14
|
+
import {
|
|
15
|
+
A2A_ERROR_CODES,
|
|
16
|
+
type JSONRPCRequest,
|
|
17
|
+
type Task,
|
|
18
|
+
type TaskIdParams,
|
|
19
|
+
type TaskStatusUpdateEvent,
|
|
20
|
+
} from './types'
|
|
21
|
+
|
|
22
|
+
export interface TaskMethodDependencies {
|
|
23
|
+
taskStore: TaskStateStore
|
|
24
|
+
payment: PaymentRecoveryDependencies
|
|
25
|
+
cancels: TaskCancellationRegistry
|
|
26
|
+
authorizeTaskAccess: (
|
|
27
|
+
c: Context,
|
|
28
|
+
req: JSONRPCRequest,
|
|
29
|
+
task: Task,
|
|
30
|
+
) => Promise<Response | undefined>
|
|
31
|
+
recoverTask: (task: Task, requestedAgentSlug: string) => Promise<Task>
|
|
32
|
+
deliverPush: (task: Task) => Promise<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function handleTasksGet(
|
|
36
|
+
c: Context,
|
|
37
|
+
req: JSONRPCRequest,
|
|
38
|
+
deps: TaskMethodDependencies,
|
|
39
|
+
): Promise<Response> {
|
|
40
|
+
const params = req.params as TaskIdParams | undefined
|
|
41
|
+
if (!params || typeof params.id !== 'string') {
|
|
42
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id required'))
|
|
43
|
+
}
|
|
44
|
+
const storedTask = await deps.taskStore.get(params.id)
|
|
45
|
+
if (!storedTask) {
|
|
46
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.id}' not found`))
|
|
47
|
+
}
|
|
48
|
+
const accessError = await deps.authorizeTaskAccess(c, req, storedTask)
|
|
49
|
+
if (accessError) return accessError
|
|
50
|
+
const task = await deps.recoverTask(storedTask, c.req.param('slug') ?? '')
|
|
51
|
+
return c.json(ok(req.id, task))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function handleTasksCancel(
|
|
55
|
+
c: Context,
|
|
56
|
+
req: JSONRPCRequest,
|
|
57
|
+
deps: TaskMethodDependencies,
|
|
58
|
+
): Promise<Response> {
|
|
59
|
+
const params = req.params as TaskIdParams | undefined
|
|
60
|
+
if (!params || typeof params.id !== 'string') {
|
|
61
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id required'))
|
|
62
|
+
}
|
|
63
|
+
const storedTask = await deps.taskStore.get(params.id)
|
|
64
|
+
if (!storedTask) {
|
|
65
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.id}' not found`))
|
|
66
|
+
}
|
|
67
|
+
const accessError = await deps.authorizeTaskAccess(c, req, storedTask)
|
|
68
|
+
if (accessError) return accessError
|
|
69
|
+
const task = await deps.recoverTask(storedTask, c.req.param('slug') ?? '')
|
|
70
|
+
if (isTerminal(task.status.state)) {
|
|
71
|
+
return c.json(
|
|
72
|
+
fail(
|
|
73
|
+
req.id,
|
|
74
|
+
A2A_ERROR_CODES.TASK_NOT_CANCELABLE,
|
|
75
|
+
`task '${params.id}' is in terminal state '${task.status.state}'`,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
isTaskFinalizing(task) ||
|
|
81
|
+
deps.cancels.isFinalizing(task.id) ||
|
|
82
|
+
(hasActiveTaskExecution(task) && !deps.cancels.has(task.id))
|
|
83
|
+
) {
|
|
84
|
+
return c.json(
|
|
85
|
+
fail(
|
|
86
|
+
req.id,
|
|
87
|
+
A2A_ERROR_CODES.TASK_NOT_CANCELABLE,
|
|
88
|
+
hasActiveTaskExecution(task)
|
|
89
|
+
? `task '${task.id}' has an active execution fence`
|
|
90
|
+
: `task '${task.id}' is being finalized`,
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
let candidate = task
|
|
95
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
96
|
+
if (isTerminal(candidate.status.state)) {
|
|
97
|
+
return c.json(
|
|
98
|
+
fail(req.id, A2A_ERROR_CODES.TASK_NOT_CANCELABLE, `task '${task.id}' changed before cancellation`),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
if (isTaskFinalizing(candidate)) {
|
|
102
|
+
return c.json(
|
|
103
|
+
fail(req.id, A2A_ERROR_CODES.TASK_NOT_CANCELABLE, `task '${task.id}' is being finalized`),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
if (hasActiveTaskExecution(candidate) && !deps.cancels.has(candidate.id)) {
|
|
107
|
+
return c.json(
|
|
108
|
+
fail(req.id, A2A_ERROR_CODES.TASK_NOT_CANCELABLE, `task '${task.id}' has an active execution fence`),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
const canceled = withStatus(candidate, 'canceled')
|
|
112
|
+
if (await compareAndSetTask(deps.taskStore, candidate, canceled)) {
|
|
113
|
+
const stillActive = deps.cancels.cancel(task.id)
|
|
114
|
+
if (!stillActive) await deps.deliverPush(canceled)
|
|
115
|
+
return c.json(ok(req.id, canceled))
|
|
116
|
+
}
|
|
117
|
+
const current = await deps.taskStore.get(task.id)
|
|
118
|
+
if (!current) {
|
|
119
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${task.id}' not found`))
|
|
120
|
+
}
|
|
121
|
+
candidate = current
|
|
122
|
+
}
|
|
123
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.INTERNAL_ERROR, 'task changed before cancellation'))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function handleTasksResubscribe(
|
|
127
|
+
c: Context,
|
|
128
|
+
req: JSONRPCRequest,
|
|
129
|
+
deps: TaskMethodDependencies,
|
|
130
|
+
): Promise<Response> {
|
|
131
|
+
const params = req.params as TaskIdParams | undefined
|
|
132
|
+
if (!params || typeof params.id !== 'string') {
|
|
133
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id required'))
|
|
134
|
+
}
|
|
135
|
+
const storedTask = await deps.taskStore.get(params.id)
|
|
136
|
+
if (!storedTask) {
|
|
137
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.id}' not found`))
|
|
138
|
+
}
|
|
139
|
+
const accessError = await deps.authorizeTaskAccess(c, req, storedTask)
|
|
140
|
+
if (accessError) return accessError
|
|
141
|
+
const task = await deps.recoverTask(storedTask, c.req.param('slug') ?? '')
|
|
142
|
+
const event: TaskStatusUpdateEvent = {
|
|
143
|
+
kind: 'status-update',
|
|
144
|
+
taskId: task.id,
|
|
145
|
+
contextId: task.contextId,
|
|
146
|
+
status: task.status,
|
|
147
|
+
final: isTerminal(task.status.state) || task.status.state === 'input-required',
|
|
148
|
+
}
|
|
149
|
+
const encoder = new TextEncoder()
|
|
150
|
+
const stream = new ReadableStream({
|
|
151
|
+
start(ctrl) {
|
|
152
|
+
ctrl.enqueue(encoder.encode(`data: ${JSON.stringify(ok(req.id, event))}\n\n`))
|
|
153
|
+
ctrl.close()
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
return new Response(stream, {
|
|
157
|
+
headers: {
|
|
158
|
+
'Content-Type': 'text/event-stream',
|
|
159
|
+
'Cache-Control': 'no-cache',
|
|
160
|
+
'X-Task-Id': task.id,
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
}
|