@tangle-network/agent-gateway 0.7.0 → 0.8.0

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 (65) hide show
  1. package/README.md +108 -6
  2. package/dist/chunk-GITV7CPT.js +84 -0
  3. package/dist/chunk-GITV7CPT.js.map +1 -0
  4. package/dist/chunk-J5SDVHOL.js +104 -0
  5. package/dist/chunk-J5SDVHOL.js.map +1 -0
  6. package/dist/chunk-MP6IIAIA.js +5651 -0
  7. package/dist/chunk-MP6IIAIA.js.map +1 -0
  8. package/dist/index.d.ts +76 -12
  9. package/dist/index.js +307 -21
  10. package/dist/index.js.map +1 -1
  11. package/dist/middleware.d.ts +7 -2
  12. package/dist/middleware.js +3 -2
  13. package/dist/nonce-store.d.ts +47 -11
  14. package/dist/nonce-store.js +9 -3
  15. package/dist/observer-types-A0RtA8uL.d.ts +95 -0
  16. package/dist/observer.d.ts +79 -0
  17. package/dist/observer.js +11 -0
  18. package/dist/observer.js.map +1 -0
  19. package/dist/{types-CX2V06cN.d.ts → types-BHISsm7D.d.ts} +423 -166
  20. package/dist/types.d.ts +2 -1
  21. package/package.json +1 -1
  22. package/src/a2a/agent-card.ts +4 -3
  23. package/src/a2a/execution-fence.ts +162 -0
  24. package/src/a2a/handler.ts +507 -562
  25. package/src/a2a/message-send-execution.ts +241 -0
  26. package/src/a2a/message-stream-execution.ts +392 -0
  27. package/src/a2a/payment-recovery.ts +431 -0
  28. package/src/a2a/push-config-methods.ts +158 -0
  29. package/src/a2a/push-notifications.ts +172 -22
  30. package/src/a2a/task-cancellation.ts +50 -0
  31. package/src/a2a/task-finalization.ts +451 -0
  32. package/src/a2a/task-lifecycle.ts +54 -0
  33. package/src/a2a/task-methods.ts +163 -0
  34. package/src/a2a/task-push-delivery.ts +119 -0
  35. package/src/a2a/task-recovery.ts +11 -0
  36. package/src/a2a/task-state.ts +99 -0
  37. package/src/a2a/task-store-sql.ts +222 -24
  38. package/src/a2a/task-store.ts +58 -1
  39. package/src/a2a/task-submission-recovery.ts +178 -0
  40. package/src/a2a/types.ts +1 -0
  41. package/src/dispatch-authorization.ts +437 -0
  42. package/src/dispatch-payment-recovery.ts +248 -0
  43. package/src/dispatch-payment.ts +425 -0
  44. package/src/dispatch-pricing.ts +108 -0
  45. package/src/dispatch-sandbox.ts +422 -0
  46. package/src/dispatch-settlement.ts +139 -0
  47. package/src/dispatch-types.ts +81 -0
  48. package/src/dispatch.ts +35 -462
  49. package/src/index.ts +64 -2
  50. package/src/middleware.ts +313 -32
  51. package/src/mpp-payment.ts +117 -0
  52. package/src/nonce-store.ts +122 -20
  53. package/src/observer-types.ts +63 -0
  54. package/src/observer.ts +3 -63
  55. package/src/payment-operations.ts +485 -0
  56. package/src/payment-recovery-sql.ts +108 -0
  57. package/src/payment-recovery-worker.ts +488 -0
  58. package/src/payment-recovery.ts +331 -0
  59. package/src/payment-types.ts +48 -0
  60. package/src/types.ts +153 -42
  61. package/src/verify.ts +265 -36
  62. package/dist/chunk-3IKQWFKX.js +0 -1703
  63. package/dist/chunk-3IKQWFKX.js.map +0 -1
  64. package/dist/chunk-M7ZJAK4K.js +0 -53
  65. package/dist/chunk-M7ZJAK4K.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
+ }