@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,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deliverDemoPushNotifications,
|
|
3
|
+
deliverPushNotifications,
|
|
4
|
+
type PushDeliveryResult,
|
|
5
|
+
type PushNotificationDeliveryOptions,
|
|
6
|
+
type PushNotificationStore,
|
|
7
|
+
} from './push-notifications'
|
|
8
|
+
import type { Task } from './types'
|
|
9
|
+
import {
|
|
10
|
+
compareAndSetTask,
|
|
11
|
+
clearTaskMetadata,
|
|
12
|
+
TERMINAL_STATES,
|
|
13
|
+
type TaskStateStore,
|
|
14
|
+
} from './task-state'
|
|
15
|
+
|
|
16
|
+
const PUSH_DELIVERY_METADATA_KEY = 'gatewayPushDelivery'
|
|
17
|
+
|
|
18
|
+
interface TaskPushDeliveryClaims {
|
|
19
|
+
version: 1
|
|
20
|
+
claims: Record<string, Task['status']['state']>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PushDeliveryDependencies {
|
|
24
|
+
taskStore: TaskStateStore
|
|
25
|
+
pushStore?: PushNotificationStore
|
|
26
|
+
demoMode: boolean
|
|
27
|
+
webhookSecret?: string
|
|
28
|
+
fetcher?: PushNotificationDeliveryOptions['fetcher']
|
|
29
|
+
urlValidator?: PushNotificationDeliveryOptions['urlValidator']
|
|
30
|
+
onDeliveryFailure?: (task: Task, result: PushDeliveryResult) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function deliverTaskPush(
|
|
34
|
+
task: Task,
|
|
35
|
+
deps: PushDeliveryDependencies,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
if (!deps.pushStore || !TERMINAL_STATES.has(task.status.state)) return
|
|
38
|
+
const webhookSecret = deps.webhookSecret
|
|
39
|
+
const hasWebhookSecret = typeof webhookSecret === 'string' && webhookSecret.trim().length > 0
|
|
40
|
+
if (!deps.demoMode && !hasWebhookSecret) {
|
|
41
|
+
console.error(`[agent-gateway] production A2A push requires a webhookSecret for task ${task.id}`)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const deliveryTask = clearPushDeliveryClaims(task)
|
|
46
|
+
const deliveryArgs: Omit<PushNotificationDeliveryOptions, 'webhookSecret'> = {
|
|
47
|
+
task: deliveryTask,
|
|
48
|
+
store: deps.pushStore,
|
|
49
|
+
fetcher: deps.fetcher,
|
|
50
|
+
urlValidator: deps.urlValidator,
|
|
51
|
+
requireUrlValidator: !deps.demoMode,
|
|
52
|
+
claimDelivery: (taskId, configId, terminalState) => claimTaskPushDelivery(
|
|
53
|
+
deps.taskStore,
|
|
54
|
+
taskId,
|
|
55
|
+
configId,
|
|
56
|
+
terminalState,
|
|
57
|
+
),
|
|
58
|
+
onDelivery: (result) => {
|
|
59
|
+
if (!result.ok) deps.onDeliveryFailure?.(task, result)
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
if (hasWebhookSecret) {
|
|
63
|
+
await deliverPushNotifications({ ...deliveryArgs, webhookSecret })
|
|
64
|
+
} else {
|
|
65
|
+
await deliverDemoPushNotifications(deliveryArgs)
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(
|
|
69
|
+
`[agent-gateway] push delivery threw for task ${task.id}: ${err instanceof Error ? err.message : String(err)}`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function claimTaskPushDelivery(
|
|
75
|
+
taskStore: TaskStateStore,
|
|
76
|
+
taskId: string,
|
|
77
|
+
configId: string,
|
|
78
|
+
terminalState: Task['status']['state'],
|
|
79
|
+
): Promise<boolean> {
|
|
80
|
+
if (!TERMINAL_STATES.has(terminalState)) return false
|
|
81
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
82
|
+
const current = await taskStore.get(taskId)
|
|
83
|
+
if (!current || current.status.state !== terminalState) return false
|
|
84
|
+
const existing = readPushDeliveryClaims(current)
|
|
85
|
+
if (existing?.claims[configId] === terminalState) return false
|
|
86
|
+
const next: Task = {
|
|
87
|
+
...current,
|
|
88
|
+
metadata: {
|
|
89
|
+
...(current.metadata ?? {}),
|
|
90
|
+
[PUSH_DELIVERY_METADATA_KEY]: {
|
|
91
|
+
version: 1,
|
|
92
|
+
claims: {
|
|
93
|
+
...(existing?.claims ?? {}),
|
|
94
|
+
[configId]: terminalState,
|
|
95
|
+
},
|
|
96
|
+
} satisfies TaskPushDeliveryClaims,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
if (await compareAndSetTask(taskStore, current, next)) return true
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`A2A push delivery claim changed too many times for task '${taskId}'`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function clearPushDeliveryClaims(task: Task): Task {
|
|
105
|
+
return clearTaskMetadata(task, PUSH_DELIVERY_METADATA_KEY)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readPushDeliveryClaims(task: Task): TaskPushDeliveryClaims | undefined {
|
|
109
|
+
const raw = task.metadata?.[PUSH_DELIVERY_METADATA_KEY]
|
|
110
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
111
|
+
const record = raw as Partial<TaskPushDeliveryClaims>
|
|
112
|
+
if (!record.claims || typeof record.claims !== 'object') return undefined
|
|
113
|
+
const claims = Object.fromEntries(
|
|
114
|
+
Object.entries(record.claims).filter(([, state]) =>
|
|
115
|
+
typeof state === 'string' && TERMINAL_STATES.has(state as Task['status']['state']),
|
|
116
|
+
),
|
|
117
|
+
) as Record<string, Task['status']['state']>
|
|
118
|
+
return record.version === 1 ? { version: 1, claims } : undefined
|
|
119
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Task } from './types'
|
|
2
|
+
|
|
3
|
+
const PAYMENT_RECOVERY_KEYS = [
|
|
4
|
+
'gatewayFinalizing',
|
|
5
|
+
'gatewayPaymentRelease',
|
|
6
|
+
'gatewayPaymentRecovery',
|
|
7
|
+
] as const
|
|
8
|
+
|
|
9
|
+
export function hasPendingPaymentRecovery(task: Task): boolean {
|
|
10
|
+
return PAYMENT_RECOVERY_KEYS.some((key) => task.metadata?.[key] !== undefined)
|
|
11
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Task, TaskStatus, Message } from './types'
|
|
2
|
+
import { clearTaskExecution } from './execution-fence'
|
|
3
|
+
import { hasPendingPaymentRecovery } from './task-recovery'
|
|
4
|
+
|
|
5
|
+
export interface TaskStateStore {
|
|
6
|
+
get(id: string): Promise<Task | undefined>
|
|
7
|
+
compareAndSet?(expected: Task, next: Task): Promise<boolean>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const TERMINAL_STATES: ReadonlySet<Task['status']['state']> = new Set([
|
|
11
|
+
'completed',
|
|
12
|
+
'canceled',
|
|
13
|
+
'failed',
|
|
14
|
+
'rejected',
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
export function isTerminal(state: Task['status']['state']): boolean {
|
|
18
|
+
return TERMINAL_STATES.has(state)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function shouldPreserveTask(task: Task): boolean {
|
|
22
|
+
return isTerminal(task.status.state) || hasPendingPaymentRecovery(task)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function compareAndSetTask(
|
|
26
|
+
taskStore: TaskStateStore,
|
|
27
|
+
expected: Task,
|
|
28
|
+
next: Task,
|
|
29
|
+
): Promise<boolean> {
|
|
30
|
+
if (!taskStore.compareAndSet) {
|
|
31
|
+
throw new Error('A2A task store does not provide compareAndSet')
|
|
32
|
+
}
|
|
33
|
+
return taskStore.compareAndSet(expected, next)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function persistTaskIfCurrent(
|
|
37
|
+
taskStore: TaskStateStore,
|
|
38
|
+
expected: Task,
|
|
39
|
+
next: Task,
|
|
40
|
+
): Promise<Task> {
|
|
41
|
+
if (expected === next || JSON.stringify(expected) === JSON.stringify(next)) return expected
|
|
42
|
+
if (await compareAndSetTask(taskStore, expected, next)) return next
|
|
43
|
+
return await taskStore.get(expected.id) ?? expected
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function clearTaskMetadata(task: Task, key: string): Task {
|
|
47
|
+
if (!task.metadata || !(key in task.metadata)) return task
|
|
48
|
+
const metadata = { ...task.metadata }
|
|
49
|
+
delete metadata[key]
|
|
50
|
+
if (Object.keys(metadata).length > 0) return { ...task, metadata }
|
|
51
|
+
const { metadata: _metadata, ...withoutMetadata } = task
|
|
52
|
+
return withoutMetadata
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function withStatus(
|
|
56
|
+
task: Task,
|
|
57
|
+
state: TaskStatus['state'],
|
|
58
|
+
message?: Message,
|
|
59
|
+
artifacts?: Task['artifacts'],
|
|
60
|
+
): Task {
|
|
61
|
+
const next: Task = {
|
|
62
|
+
...task,
|
|
63
|
+
status: { state, timestamp: nowIso(), ...(message ? { message } : {}) },
|
|
64
|
+
...(artifacts !== undefined ? { artifacts } : {}),
|
|
65
|
+
}
|
|
66
|
+
return isTerminal(state) || state === 'input-required' ? clearTaskExecution(next) : next
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function agentMessage(task: Task, text: string): Message {
|
|
70
|
+
return {
|
|
71
|
+
kind: 'message',
|
|
72
|
+
role: 'agent',
|
|
73
|
+
parts: [{ kind: 'text', text }],
|
|
74
|
+
messageId: `${task.id}-input-required-${stableMessageDigest(text)}`,
|
|
75
|
+
taskId: task.id,
|
|
76
|
+
contextId: task.contextId,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function asError(error: unknown): Error {
|
|
81
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function nowIso(): string {
|
|
85
|
+
return new Date().toISOString()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function cryptoRandomId(): string {
|
|
89
|
+
return crypto.randomUUID().replace(/-/g, '')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stableMessageDigest(value: string): string {
|
|
93
|
+
let hash = 2166136261
|
|
94
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
95
|
+
hash ^= value.charCodeAt(index)
|
|
96
|
+
hash = Math.imul(hash, 16777619)
|
|
97
|
+
}
|
|
98
|
+
return (hash >>> 0).toString(16).padStart(8, '0')
|
|
99
|
+
}
|
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
* Schema is one table: tasks keyed by id with the full JSON payload, plus a
|
|
10
10
|
* secondary index on `context_id` so `tasks/resubscribe` and conversational
|
|
11
11
|
* lookups by context are O(log n). TTL is enforced at read time the same way
|
|
12
|
-
* `InMemoryTaskStore` does —
|
|
13
|
-
*
|
|
14
|
-
* deleted.
|
|
12
|
+
* `InMemoryTaskStore` does — `createIfAbsent` and `compareAndSet` make task
|
|
13
|
+
* ownership safe when multiple gateway workers share the database.
|
|
15
14
|
*
|
|
16
15
|
* Why not bake in a specific driver? Hono workers run on Cloudflare (D1),
|
|
17
16
|
* Node (pg / sqlite), Bun, Deno. Burning a hard dependency on one client
|
|
@@ -41,7 +40,8 @@
|
|
|
41
40
|
* await store.migrate()
|
|
42
41
|
*/
|
|
43
42
|
|
|
44
|
-
import
|
|
43
|
+
import { inspectTaskExecution } from './execution-fence'
|
|
44
|
+
import { hasPendingPaymentRecovery, type TaskStore } from './task-store'
|
|
45
45
|
import type { Task } from './types'
|
|
46
46
|
|
|
47
47
|
/**
|
|
@@ -98,7 +98,9 @@ const TASKS_TABLE_DDL = (table: string) => `
|
|
|
98
98
|
context_id TEXT NOT NULL,
|
|
99
99
|
state TEXT NOT NULL,
|
|
100
100
|
payload TEXT NOT NULL,
|
|
101
|
-
updated_at INTEGER NOT NULL
|
|
101
|
+
updated_at INTEGER NOT NULL,
|
|
102
|
+
execution_request_id TEXT,
|
|
103
|
+
execution_lease_expires_at REAL
|
|
102
104
|
)
|
|
103
105
|
`
|
|
104
106
|
const CTX_INDEX_DDL = (table: string) => `
|
|
@@ -124,50 +126,236 @@ export class SqlTaskStore implements TaskStore {
|
|
|
124
126
|
return this.opts.table ?? 'a2a_tasks'
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
private async readRow(id: string): Promise<{
|
|
130
|
+
payload: string
|
|
131
|
+
updatedAt: number
|
|
132
|
+
executionRequestId: string | null
|
|
133
|
+
executionLeaseExpiresAt: number | null
|
|
134
|
+
} | undefined> {
|
|
135
|
+
const rows = await this.db.query<{
|
|
136
|
+
payload: string
|
|
137
|
+
updated_at: number
|
|
138
|
+
execution_request_id?: string | null
|
|
139
|
+
execution_lease_expires_at?: number | null
|
|
140
|
+
}>(
|
|
141
|
+
`SELECT payload, updated_at, execution_request_id, execution_lease_expires_at FROM ${this.table} WHERE id = ?`,
|
|
142
|
+
[id],
|
|
143
|
+
)
|
|
144
|
+
const row = rows[0]
|
|
145
|
+
return row
|
|
146
|
+
? {
|
|
147
|
+
payload: row.payload,
|
|
148
|
+
updatedAt: row.updated_at,
|
|
149
|
+
executionRequestId: row.execution_request_id ?? null,
|
|
150
|
+
executionLeaseExpiresAt: row.execution_lease_expires_at ?? null,
|
|
151
|
+
}
|
|
152
|
+
: undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private isExpired(updatedAt: number, task: Task): boolean {
|
|
156
|
+
return Date.now() - updatedAt > this.ttlMs && !hasPendingPaymentRecovery(task)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async deleteObservedRow(
|
|
160
|
+
id: string,
|
|
161
|
+
payload: string,
|
|
162
|
+
updatedAt: number,
|
|
163
|
+
): Promise<number> {
|
|
164
|
+
const result = await this.db.exec(
|
|
165
|
+
`DELETE FROM ${this.table} WHERE id = ? AND payload = ? AND updated_at = ?`,
|
|
166
|
+
[id, payload, updatedAt],
|
|
167
|
+
)
|
|
168
|
+
return result.rowsAffected
|
|
169
|
+
}
|
|
170
|
+
|
|
127
171
|
/** Idempotent. Call once at deploy. */
|
|
128
172
|
async migrate(): Promise<void> {
|
|
129
173
|
await this.db.exec(TASKS_TABLE_DDL(this.table))
|
|
174
|
+
for (const column of [
|
|
175
|
+
'execution_request_id TEXT',
|
|
176
|
+
'execution_lease_expires_at REAL',
|
|
177
|
+
]) {
|
|
178
|
+
try {
|
|
179
|
+
await this.db.exec(`ALTER TABLE ${this.table} ADD COLUMN ${column}`)
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
|
|
182
|
+
if (!message.includes('duplicate column') && !message.includes('already exists')) throw error
|
|
183
|
+
}
|
|
184
|
+
}
|
|
130
185
|
await this.db.exec(CTX_INDEX_DDL(this.table))
|
|
131
186
|
}
|
|
132
187
|
|
|
133
188
|
async get(id: string): Promise<Task | undefined> {
|
|
134
|
-
const
|
|
135
|
-
`SELECT payload, updated_at FROM ${this.table} WHERE id = ?`,
|
|
136
|
-
[id],
|
|
137
|
-
)
|
|
138
|
-
const row = rows[0]
|
|
189
|
+
const row = await this.readRow(id)
|
|
139
190
|
if (!row) return undefined
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
void this.db.exec(`DELETE FROM ${this.table} WHERE id = ?`, [id])
|
|
191
|
+
const task = JSON.parse(row.payload) as Task
|
|
192
|
+
if (this.isExpired(row.updatedAt, task)) {
|
|
193
|
+
// Delete only the version that was observed as stale. A refresh can reuse
|
|
194
|
+
// the same payload, so payload equality and updated_at both fence the row.
|
|
195
|
+
await this.deleteObservedRow(id, row.payload, row.updatedAt)
|
|
146
196
|
return undefined
|
|
147
197
|
}
|
|
148
|
-
return
|
|
198
|
+
return task
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async insert(task: Task): Promise<number> {
|
|
202
|
+
const payload = JSON.stringify(task)
|
|
203
|
+
const [executionRequestId, executionLeaseExpiresAt] = executionColumns(task)
|
|
204
|
+
const result = await this.db.exec(
|
|
205
|
+
`INSERT INTO ${this.table} (id, context_id, state, payload, updated_at, execution_request_id, execution_lease_expires_at)
|
|
206
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
207
|
+
[
|
|
208
|
+
task.id,
|
|
209
|
+
task.contextId,
|
|
210
|
+
task.status.state,
|
|
211
|
+
payload,
|
|
212
|
+
Date.now(),
|
|
213
|
+
executionRequestId,
|
|
214
|
+
executionLeaseExpiresAt,
|
|
215
|
+
],
|
|
216
|
+
)
|
|
217
|
+
return result.rowsAffected
|
|
149
218
|
}
|
|
150
219
|
|
|
151
220
|
async put(task: Task): Promise<void> {
|
|
152
221
|
const payload = JSON.stringify(task)
|
|
153
222
|
const updatedAt = Date.now()
|
|
223
|
+
const [executionRequestId, executionLeaseExpiresAt] = executionColumns(task)
|
|
154
224
|
// Adapter-agnostic upsert: try update, fall back to insert if no row
|
|
155
225
|
// existed. Avoids needing ON CONFLICT (postgres) vs INSERT OR REPLACE
|
|
156
226
|
// (sqlite/libSQL) divergence at the SQL layer.
|
|
157
227
|
const updated = await this.db.exec(
|
|
158
|
-
`UPDATE ${this.table}
|
|
159
|
-
|
|
228
|
+
`UPDATE ${this.table}
|
|
229
|
+
SET context_id = ?, state = ?, payload = ?, updated_at = ?,
|
|
230
|
+
execution_request_id = ?, execution_lease_expires_at = ?
|
|
231
|
+
WHERE id = ?`,
|
|
232
|
+
[
|
|
233
|
+
task.contextId,
|
|
234
|
+
task.status.state,
|
|
235
|
+
payload,
|
|
236
|
+
updatedAt,
|
|
237
|
+
executionRequestId,
|
|
238
|
+
executionLeaseExpiresAt,
|
|
239
|
+
task.id,
|
|
240
|
+
],
|
|
160
241
|
)
|
|
161
242
|
if (updated.rowsAffected === 0) {
|
|
162
243
|
await this.db.exec(
|
|
163
|
-
`INSERT INTO ${this.table} (id, context_id, state, payload, updated_at
|
|
164
|
-
|
|
244
|
+
`INSERT INTO ${this.table} (id, context_id, state, payload, updated_at, execution_request_id, execution_lease_expires_at)
|
|
245
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
246
|
+
[
|
|
247
|
+
task.id,
|
|
248
|
+
task.contextId,
|
|
249
|
+
task.status.state,
|
|
250
|
+
payload,
|
|
251
|
+
updatedAt,
|
|
252
|
+
executionRequestId,
|
|
253
|
+
executionLeaseExpiresAt,
|
|
254
|
+
],
|
|
165
255
|
)
|
|
166
256
|
}
|
|
167
257
|
}
|
|
168
258
|
|
|
259
|
+
async createIfAbsent(task: Task): Promise<boolean> {
|
|
260
|
+
try {
|
|
261
|
+
return (await this.insert(task)) === 1
|
|
262
|
+
} catch (error) {
|
|
263
|
+
// SQL dialects report duplicate primary keys as errors. Inspect the raw
|
|
264
|
+
// row so an expired row can be removed and retried in the same call.
|
|
265
|
+
const row = await this.readRow(task.id)
|
|
266
|
+
if (!row) throw error
|
|
267
|
+
const existing = JSON.parse(row.payload) as Task
|
|
268
|
+
if (!this.isExpired(row.updatedAt, existing)) return false
|
|
269
|
+
|
|
270
|
+
await this.deleteObservedRow(task.id, row.payload, row.updatedAt)
|
|
271
|
+
try {
|
|
272
|
+
return (await this.insert(task)) === 1
|
|
273
|
+
} catch (retryError) {
|
|
274
|
+
// Another writer may have won the retry after the stale row was
|
|
275
|
+
// removed. Return the normal idempotency result in that case.
|
|
276
|
+
if (await this.get(task.id)) return false
|
|
277
|
+
throw retryError
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async compareAndSet(expected: Task, next: Task): Promise<boolean> {
|
|
283
|
+
const expectedPayload = JSON.stringify(expected)
|
|
284
|
+
const payload = JSON.stringify(next)
|
|
285
|
+
const [executionRequestId, executionLeaseExpiresAt] = executionColumns(next)
|
|
286
|
+
const result = await this.db.exec(
|
|
287
|
+
`UPDATE ${this.table}
|
|
288
|
+
SET context_id = ?, state = ?, payload = ?, updated_at = ?,
|
|
289
|
+
execution_request_id = ?, execution_lease_expires_at = ?
|
|
290
|
+
WHERE id = ? AND payload = ?`,
|
|
291
|
+
[
|
|
292
|
+
next.contextId,
|
|
293
|
+
next.status.state,
|
|
294
|
+
payload,
|
|
295
|
+
Date.now(),
|
|
296
|
+
executionRequestId,
|
|
297
|
+
executionLeaseExpiresAt,
|
|
298
|
+
expected.id,
|
|
299
|
+
expectedPayload,
|
|
300
|
+
],
|
|
301
|
+
)
|
|
302
|
+
return result.rowsAffected === 1
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async compareAndSetExecution(
|
|
306
|
+
expected: Task,
|
|
307
|
+
next: Task,
|
|
308
|
+
requestId: string,
|
|
309
|
+
now: number,
|
|
310
|
+
): Promise<boolean> {
|
|
311
|
+
const expectedMarker = inspectTaskExecution(expected)
|
|
312
|
+
const nextMarker = inspectTaskExecution(next)
|
|
313
|
+
if (
|
|
314
|
+
expectedMarker.state !== 'valid' ||
|
|
315
|
+
nextMarker.state !== 'valid' ||
|
|
316
|
+
expectedMarker.marker.requestId !== requestId ||
|
|
317
|
+
nextMarker.marker.requestId !== requestId ||
|
|
318
|
+
expectedMarker.marker.lease.expiresAt <= now ||
|
|
319
|
+
nextMarker.marker.lease.expiresAt <= now
|
|
320
|
+
) return false
|
|
321
|
+
const expectedPayload = JSON.stringify(expected)
|
|
322
|
+
const payload = JSON.stringify(next)
|
|
323
|
+
const updatedAt = Date.now()
|
|
324
|
+
// Both NULL columns identify a legacy row whose payload still owns the fence.
|
|
325
|
+
const result = await this.db.exec(
|
|
326
|
+
`UPDATE ${this.table}
|
|
327
|
+
SET context_id = ?, state = ?, payload = ?, updated_at = ?,
|
|
328
|
+
execution_request_id = ?, execution_lease_expires_at = ?
|
|
329
|
+
WHERE id = ?
|
|
330
|
+
AND payload = ?
|
|
331
|
+
AND state = 'working'
|
|
332
|
+
AND (
|
|
333
|
+
(execution_request_id = ? AND execution_lease_expires_at > ?)
|
|
334
|
+
OR (execution_request_id IS NULL AND execution_lease_expires_at IS NULL)
|
|
335
|
+
)`,
|
|
336
|
+
[
|
|
337
|
+
next.contextId,
|
|
338
|
+
next.status.state,
|
|
339
|
+
payload,
|
|
340
|
+
updatedAt,
|
|
341
|
+
nextMarker.marker.requestId,
|
|
342
|
+
nextMarker.marker.lease.expiresAt,
|
|
343
|
+
expected.id,
|
|
344
|
+
expectedPayload,
|
|
345
|
+
requestId,
|
|
346
|
+
now,
|
|
347
|
+
],
|
|
348
|
+
)
|
|
349
|
+
return result.rowsAffected === 1
|
|
350
|
+
}
|
|
351
|
+
|
|
169
352
|
async delete(id: string): Promise<void> {
|
|
170
|
-
await this.
|
|
353
|
+
const task = await this.get(id)
|
|
354
|
+
if (!task || hasPendingPaymentRecovery(task)) return
|
|
355
|
+
await this.db.exec(
|
|
356
|
+
`DELETE FROM ${this.table} WHERE id = ? AND payload = ?`,
|
|
357
|
+
[id, JSON.stringify(task)],
|
|
358
|
+
)
|
|
171
359
|
}
|
|
172
360
|
|
|
173
361
|
/**
|
|
@@ -183,7 +371,17 @@ export class SqlTaskStore implements TaskStore {
|
|
|
183
371
|
)
|
|
184
372
|
const now = Date.now()
|
|
185
373
|
return rows
|
|
186
|
-
.
|
|
187
|
-
.
|
|
374
|
+
.map((r) => ({ task: JSON.parse(r.payload) as Task, updatedAt: r.updated_at }))
|
|
375
|
+
.filter(({ task, updatedAt }) =>
|
|
376
|
+
now - updatedAt <= this.ttlMs || hasPendingPaymentRecovery(task),
|
|
377
|
+
)
|
|
378
|
+
.map(({ task }) => task)
|
|
188
379
|
}
|
|
189
380
|
}
|
|
381
|
+
|
|
382
|
+
function executionColumns(task: Task): [string | null, number | null] {
|
|
383
|
+
const inspection = inspectTaskExecution(task)
|
|
384
|
+
return inspection.state === 'valid'
|
|
385
|
+
? [inspection.marker.requestId, inspection.marker.lease.expiresAt]
|
|
386
|
+
: [null, null]
|
|
387
|
+
}
|
package/src/a2a/task-store.ts
CHANGED
|
@@ -6,10 +6,25 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Task } from './types'
|
|
9
|
+
import { inspectTaskExecution } from './execution-fence'
|
|
10
|
+
import { hasPendingPaymentRecovery } from './task-recovery'
|
|
11
|
+
|
|
12
|
+
export { hasPendingPaymentRecovery } from './task-recovery'
|
|
9
13
|
|
|
10
14
|
export interface TaskStore {
|
|
11
15
|
get(id: string): Promise<Task | undefined>
|
|
12
16
|
put(task: Task): Promise<void>
|
|
17
|
+
/** Insert only when the task id is absent. Required outside explicit demo mode. */
|
|
18
|
+
createIfAbsent?(task: Task): Promise<boolean>
|
|
19
|
+
/** Replace only when the stored task still equals `expected`. Required for production races. */
|
|
20
|
+
compareAndSet?(expected: Task, next: Task): Promise<boolean>
|
|
21
|
+
/** Replace an execution marker only while its owner lease is still live. */
|
|
22
|
+
compareAndSetExecution?(
|
|
23
|
+
expected: Task,
|
|
24
|
+
next: Task,
|
|
25
|
+
requestId: string,
|
|
26
|
+
now: number,
|
|
27
|
+
): Promise<boolean>
|
|
13
28
|
delete(id: string): Promise<void>
|
|
14
29
|
}
|
|
15
30
|
|
|
@@ -32,7 +47,47 @@ export class InMemoryTaskStore implements TaskStore {
|
|
|
32
47
|
this.entries.set(task.id, { task: clone(task), expiresAt: Date.now() + this.ttlMs })
|
|
33
48
|
}
|
|
34
49
|
|
|
50
|
+
async createIfAbsent(task: Task): Promise<boolean> {
|
|
51
|
+
this.gc()
|
|
52
|
+
if (this.entries.has(task.id)) return false
|
|
53
|
+
this.entries.set(task.id, { task: clone(task), expiresAt: Date.now() + this.ttlMs })
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async compareAndSet(expected: Task, next: Task): Promise<boolean> {
|
|
58
|
+
this.gc()
|
|
59
|
+
const entry = this.entries.get(expected.id)
|
|
60
|
+
if (!entry || JSON.stringify(entry.task) !== JSON.stringify(expected)) return false
|
|
61
|
+
this.entries.set(expected.id, { task: clone(next), expiresAt: Date.now() + this.ttlMs })
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async compareAndSetExecution(
|
|
66
|
+
expected: Task,
|
|
67
|
+
next: Task,
|
|
68
|
+
requestId: string,
|
|
69
|
+
now: number,
|
|
70
|
+
): Promise<boolean> {
|
|
71
|
+
this.gc()
|
|
72
|
+
const entry = this.entries.get(expected.id)
|
|
73
|
+
if (!entry || JSON.stringify(entry.task) !== JSON.stringify(expected)) return false
|
|
74
|
+
const expectedMarker = inspectTaskExecution(expected)
|
|
75
|
+
const nextMarker = inspectTaskExecution(next)
|
|
76
|
+
if (
|
|
77
|
+
expectedMarker.state !== 'valid' ||
|
|
78
|
+
nextMarker.state !== 'valid' ||
|
|
79
|
+
expectedMarker.marker.requestId !== requestId ||
|
|
80
|
+
nextMarker.marker.requestId !== requestId ||
|
|
81
|
+
expectedMarker.marker.lease.expiresAt <= now ||
|
|
82
|
+
nextMarker.marker.lease.expiresAt <= now
|
|
83
|
+
) return false
|
|
84
|
+
this.entries.set(expected.id, { task: clone(next), expiresAt: Date.now() + this.ttlMs })
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
35
88
|
async delete(id: string): Promise<void> {
|
|
89
|
+
const entry = this.entries.get(id)
|
|
90
|
+
if (entry && hasPendingPaymentRecovery(entry.task)) return
|
|
36
91
|
this.entries.delete(id)
|
|
37
92
|
}
|
|
38
93
|
|
|
@@ -43,7 +98,9 @@ export class InMemoryTaskStore implements TaskStore {
|
|
|
43
98
|
private gc(): void {
|
|
44
99
|
const now = Date.now()
|
|
45
100
|
for (const [id, entry] of this.entries) {
|
|
46
|
-
if (entry.expiresAt <= now
|
|
101
|
+
if (entry.expiresAt <= now && !hasPendingPaymentRecovery(entry.task)) {
|
|
102
|
+
this.entries.delete(id)
|
|
103
|
+
}
|
|
47
104
|
}
|
|
48
105
|
}
|
|
49
106
|
}
|