@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,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 — the gateway is single-writer per task id so a
13
- * stale row is invisible to callers regardless of when the row is physically
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 type { TaskStore } from './task-store'
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 rows = await this.db.query<{ payload: string; updated_at: number }>(
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
- if (Date.now() - row.updated_at > this.ttlMs) {
141
- // Lazy GC. If the delete loses a race with another reader, that reader
142
- // observes either the stale-then-deleted task (returning undefined here)
143
- // or, after this delete commits, observes undefined directly either
144
- // way callers see consistent "expired" semantics.
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 JSON.parse(row.payload) as Task
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} SET context_id = ?, state = ?, payload = ?, updated_at = ? WHERE id = ?`,
159
- [task.contextId, task.status.state, payload, updatedAt, task.id],
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) VALUES (?, ?, ?, ?, ?)`,
164
- [task.id, task.contextId, task.status.state, payload, updatedAt],
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.db.exec(`DELETE FROM ${this.table} WHERE id = ?`, [id])
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
- .filter((r) => now - r.updated_at <= this.ttlMs)
187
- .map((r) => JSON.parse(r.payload) as Task)
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
+ }
@@ -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) this.entries.delete(id)
101
+ if (entry.expiresAt <= now && !hasPendingPaymentRecovery(entry.task)) {
102
+ this.entries.delete(id)
103
+ }
47
104
  }
48
105
  }
49
106
  }