@agent-native/core 0.122.2 → 0.122.3

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 (71) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/run-loop-with-resume.ts +12 -0
  5. package/corpus/core/src/integrations/a2a-continuation-processor.ts +182 -0
  6. package/corpus/core/src/integrations/a2a-continuations-store.ts +122 -12
  7. package/corpus/core/src/integrations/integration-campaign-recovery.ts +127 -0
  8. package/corpus/core/src/integrations/integration-campaigns-store.ts +1039 -0
  9. package/corpus/core/src/integrations/integration-durable-dispatch.ts +28 -3
  10. package/corpus/core/src/integrations/pending-tasks-retry-job.ts +8 -0
  11. package/corpus/core/src/integrations/plugin.ts +453 -35
  12. package/corpus/core/src/integrations/webhook-handler.ts +469 -11
  13. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +7 -3
  14. package/corpus/templates/content/app/components/editor/body-hydration.ts +10 -0
  15. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +1 -0
  16. package/corpus/templates/content/app/root.tsx +12 -1
  17. package/corpus/templates/content/changelog/2026-07-23-new-pages-open-immediately-while-slow-network-work-continues.md +6 -0
  18. package/corpus/templates/content/changelog/2026-07-25-database-preview-actions-open-reliably.md +6 -0
  19. package/corpus/templates/content/e2e/playwright.config.ts +1 -1
  20. package/dist/agent/run-loop-with-resume.d.ts +7 -1
  21. package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
  22. package/dist/agent/run-loop-with-resume.js +7 -0
  23. package/dist/agent/run-loop-with-resume.js.map +1 -1
  24. package/dist/collab/awareness.d.ts +2 -2
  25. package/dist/collab/awareness.d.ts.map +1 -1
  26. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  27. package/dist/integrations/a2a-continuation-processor.d.ts +14 -0
  28. package/dist/integrations/a2a-continuation-processor.d.ts.map +1 -1
  29. package/dist/integrations/a2a-continuation-processor.js +134 -1
  30. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  31. package/dist/integrations/a2a-continuations-store.d.ts +23 -0
  32. package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
  33. package/dist/integrations/a2a-continuations-store.js +99 -13
  34. package/dist/integrations/a2a-continuations-store.js.map +1 -1
  35. package/dist/integrations/integration-campaign-recovery.d.ts +16 -0
  36. package/dist/integrations/integration-campaign-recovery.d.ts.map +1 -0
  37. package/dist/integrations/integration-campaign-recovery.js +94 -0
  38. package/dist/integrations/integration-campaign-recovery.js.map +1 -0
  39. package/dist/integrations/integration-campaigns-store.d.ts +121 -0
  40. package/dist/integrations/integration-campaigns-store.d.ts.map +1 -0
  41. package/dist/integrations/integration-campaigns-store.js +784 -0
  42. package/dist/integrations/integration-campaigns-store.js.map +1 -0
  43. package/dist/integrations/integration-durable-dispatch.d.ts +3 -0
  44. package/dist/integrations/integration-durable-dispatch.d.ts.map +1 -1
  45. package/dist/integrations/integration-durable-dispatch.js +22 -3
  46. package/dist/integrations/integration-durable-dispatch.js.map +1 -1
  47. package/dist/integrations/pending-tasks-retry-job.d.ts.map +1 -1
  48. package/dist/integrations/pending-tasks-retry-job.js +6 -0
  49. package/dist/integrations/pending-tasks-retry-job.js.map +1 -1
  50. package/dist/integrations/plugin.d.ts.map +1 -1
  51. package/dist/integrations/plugin.js +325 -18
  52. package/dist/integrations/plugin.js.map +1 -1
  53. package/dist/integrations/webhook-handler.d.ts +16 -1
  54. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  55. package/dist/integrations/webhook-handler.js +368 -13
  56. package/dist/integrations/webhook-handler.js.map +1 -1
  57. package/dist/notifications/routes.d.ts +3 -3
  58. package/dist/provider-api/actions/custom-provider-registration.d.ts +14 -14
  59. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  60. package/dist/resources/handlers.d.ts +1 -1
  61. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  62. package/package.json +1 -1
  63. package/src/agent/run-loop-with-resume.ts +12 -0
  64. package/src/integrations/a2a-continuation-processor.ts +182 -0
  65. package/src/integrations/a2a-continuations-store.ts +122 -12
  66. package/src/integrations/integration-campaign-recovery.ts +127 -0
  67. package/src/integrations/integration-campaigns-store.ts +1039 -0
  68. package/src/integrations/integration-durable-dispatch.ts +28 -3
  69. package/src/integrations/pending-tasks-retry-job.ts +8 -0
  70. package/src/integrations/plugin.ts +453 -35
  71. package/src/integrations/webhook-handler.ts +469 -11
@@ -0,0 +1,1039 @@
1
+ import {
2
+ getDbExec,
3
+ intType,
4
+ isPostgres,
5
+ type DbExec,
6
+ type DbExecStatement,
7
+ } from "../db/client.js";
8
+ import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js";
9
+
10
+ let initPromise: Promise<void> | undefined;
11
+
12
+ const MAX_PROGRESS_REF_CHARS = 1_024;
13
+ const MAX_CHECKPOINT_CHARS = 8_192;
14
+ const MAX_ERROR_MESSAGE_CHARS = 2_000;
15
+ const MAX_DUE_LIST_LIMIT = 100;
16
+ const A2A_WAITING_CHECKPOINT = '{"waitingForA2A":true}';
17
+
18
+ export type IntegrationCampaignStatus =
19
+ | "pending"
20
+ | "processing"
21
+ | "waiting"
22
+ | "completed"
23
+ | "failed";
24
+
25
+ export interface IntegrationCampaign {
26
+ id: string;
27
+ integrationTaskId: string;
28
+ threadId: string;
29
+ turnId: string;
30
+ status: IntegrationCampaignStatus;
31
+ chunkCount: number;
32
+ currentRunId: string | null;
33
+ leaseToken: string | null;
34
+ leaseExpiresAt: number | null;
35
+ nextRunAt: number;
36
+ progressRef: string | null;
37
+ checkpoint: string | null;
38
+ errorMessage: string | null;
39
+ createdAt: number;
40
+ updatedAt: number;
41
+ completedAt: number | null;
42
+ }
43
+
44
+ export type ClaimIntegrationCampaignResult =
45
+ | { kind: "claimed"; campaign: IntegrationCampaign }
46
+ | { kind: "not-due" }
47
+ | { kind: "chunk-limit"; campaign: IntegrationCampaign };
48
+
49
+ function buildCreateSql(): string {
50
+ return `CREATE TABLE IF NOT EXISTS integration_campaigns (
51
+ id TEXT PRIMARY KEY,
52
+ integration_task_id TEXT NOT NULL UNIQUE,
53
+ thread_id TEXT NOT NULL,
54
+ turn_id TEXT NOT NULL,
55
+ status TEXT NOT NULL,
56
+ chunk_count ${intType()} NOT NULL DEFAULT 0,
57
+ current_run_id TEXT,
58
+ lease_token TEXT,
59
+ lease_expires_at ${intType()},
60
+ next_run_at ${intType()} NOT NULL,
61
+ progress_ref TEXT,
62
+ checkpoint TEXT,
63
+ error_message TEXT,
64
+ created_at ${intType()} NOT NULL,
65
+ updated_at ${intType()} NOT NULL,
66
+ completed_at ${intType()}
67
+ )`;
68
+ }
69
+
70
+ async function ensureTable(): Promise<void> {
71
+ if (!initPromise) {
72
+ initPromise = (async () => {
73
+ const createSql = buildCreateSql();
74
+ if (isPostgres()) {
75
+ await ensureTableExists("integration_campaigns", createSql);
76
+ await ensureIndexExists(
77
+ "idx_integration_campaigns_due",
78
+ "CREATE INDEX IF NOT EXISTS idx_integration_campaigns_due ON integration_campaigns(status, next_run_at)",
79
+ );
80
+ await ensureIndexExists(
81
+ "idx_integration_campaigns_lease",
82
+ "CREATE INDEX IF NOT EXISTS idx_integration_campaigns_lease ON integration_campaigns(status, lease_expires_at)",
83
+ );
84
+ return;
85
+ }
86
+
87
+ const client = getDbExec();
88
+ await client.execute(createSql);
89
+ await client.execute(
90
+ "CREATE INDEX IF NOT EXISTS idx_integration_campaigns_due ON integration_campaigns(status, next_run_at)",
91
+ );
92
+ await client.execute(
93
+ "CREATE INDEX IF NOT EXISTS idx_integration_campaigns_lease ON integration_campaigns(status, lease_expires_at)",
94
+ );
95
+ })().catch((error) => {
96
+ initPromise = undefined;
97
+ throw error;
98
+ });
99
+ }
100
+ return initPromise;
101
+ }
102
+
103
+ export async function ensureIntegrationCampaignsTable(): Promise<void> {
104
+ await ensureTable();
105
+ }
106
+
107
+ function boundedOpaqueValue(
108
+ value: string | null | undefined,
109
+ maxLength: number,
110
+ name: string,
111
+ ): string | null {
112
+ if (value == null) return null;
113
+ if (value.length > maxLength) {
114
+ throw new Error(`${name} exceeds ${maxLength} characters`);
115
+ }
116
+ return value;
117
+ }
118
+
119
+ function rowToCampaign(row: Record<string, unknown>): IntegrationCampaign {
120
+ return {
121
+ id: String(row.id),
122
+ integrationTaskId: String(row.integration_task_id),
123
+ threadId: String(row.thread_id),
124
+ turnId: String(row.turn_id),
125
+ status: row.status as IntegrationCampaignStatus,
126
+ chunkCount: Number(row.chunk_count ?? 0),
127
+ currentRunId: (row.current_run_id as string | null) ?? null,
128
+ leaseToken: (row.lease_token as string | null) ?? null,
129
+ leaseExpiresAt:
130
+ row.lease_expires_at == null ? null : Number(row.lease_expires_at),
131
+ nextRunAt: Number(row.next_run_at ?? 0),
132
+ progressRef: (row.progress_ref as string | null) ?? null,
133
+ checkpoint: (row.checkpoint as string | null) ?? null,
134
+ errorMessage: (row.error_message as string | null) ?? null,
135
+ createdAt: Number(row.created_at ?? 0),
136
+ updatedAt: Number(row.updated_at ?? 0),
137
+ completedAt: row.completed_at == null ? null : Number(row.completed_at),
138
+ };
139
+ }
140
+
141
+ function isDuplicateCampaignError(error: unknown): boolean {
142
+ const candidate = error as { code?: string; message?: string } | null;
143
+ if (!candidate) return false;
144
+ if (candidate.code === "23505") return true;
145
+ const message = String(candidate.message ?? "").toLowerCase();
146
+ return (
147
+ message.includes("unique") ||
148
+ message.includes("duplicate entry") ||
149
+ message.includes("duplicate key")
150
+ );
151
+ }
152
+
153
+ export async function getIntegrationCampaign(
154
+ id: string,
155
+ ): Promise<IntegrationCampaign | null> {
156
+ await ensureTable();
157
+ const { rows } = await getDbExec().execute({
158
+ sql: "SELECT * FROM integration_campaigns WHERE id = ? LIMIT 1",
159
+ args: [id],
160
+ });
161
+ return rows[0] ? rowToCampaign(rows[0] as Record<string, unknown>) : null;
162
+ }
163
+
164
+ export async function getIntegrationCampaignForTask(
165
+ integrationTaskId: string,
166
+ ): Promise<IntegrationCampaign | null> {
167
+ await ensureTable();
168
+ const { rows } = await getDbExec().execute({
169
+ sql: "SELECT * FROM integration_campaigns WHERE integration_task_id = ? LIMIT 1",
170
+ args: [integrationTaskId],
171
+ });
172
+ return rows[0] ? rowToCampaign(rows[0] as Record<string, unknown>) : null;
173
+ }
174
+
175
+ export async function createIntegrationCampaign(input: {
176
+ integrationTaskId: string;
177
+ threadId: string;
178
+ turnId: string;
179
+ progressRef?: string | null;
180
+ checkpoint?: string | null;
181
+ nextRunAt?: number;
182
+ }): Promise<IntegrationCampaign> {
183
+ await ensureTable();
184
+ const now = Date.now();
185
+ const id = `integration-campaign-${now}-${Math.random().toString(36).slice(2, 10)}`;
186
+ const progressRef = boundedOpaqueValue(
187
+ input.progressRef,
188
+ MAX_PROGRESS_REF_CHARS,
189
+ "progressRef",
190
+ );
191
+ const checkpoint = boundedOpaqueValue(
192
+ input.checkpoint,
193
+ MAX_CHECKPOINT_CHARS,
194
+ "checkpoint",
195
+ );
196
+
197
+ try {
198
+ await getDbExec().execute({
199
+ sql: `INSERT INTO integration_campaigns
200
+ (id, integration_task_id, thread_id, turn_id, status, chunk_count,
201
+ next_run_at, progress_ref, checkpoint, created_at, updated_at)
202
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
203
+ args: [
204
+ id,
205
+ input.integrationTaskId,
206
+ input.threadId,
207
+ input.turnId,
208
+ "pending",
209
+ 0,
210
+ input.nextRunAt ?? now,
211
+ progressRef,
212
+ checkpoint,
213
+ now,
214
+ now,
215
+ ],
216
+ });
217
+ } catch (error) {
218
+ if (!isDuplicateCampaignError(error)) throw error;
219
+ const existing = await getIntegrationCampaignForTask(
220
+ input.integrationTaskId,
221
+ );
222
+ if (existing) return existing;
223
+ throw error;
224
+ }
225
+
226
+ const campaign = await getIntegrationCampaign(id);
227
+ if (!campaign) throw new Error("Created integration campaign was not found");
228
+ return campaign;
229
+ }
230
+
231
+ function affectedRows(result: unknown): number {
232
+ const value = result as { rowsAffected?: number; rowCount?: number };
233
+ return Number(value.rowsAffected ?? value.rowCount ?? 0);
234
+ }
235
+
236
+ export async function claimIntegrationCampaign(
237
+ id: string,
238
+ input: {
239
+ runId: string;
240
+ leaseToken: string;
241
+ leaseDurationMs: number;
242
+ maxChunks: number;
243
+ },
244
+ ): Promise<ClaimIntegrationCampaignResult> {
245
+ await ensureTable();
246
+ if (!Number.isFinite(input.leaseDurationMs) || input.leaseDurationMs <= 0) {
247
+ throw new Error("leaseDurationMs must be positive");
248
+ }
249
+ if (!Number.isInteger(input.maxChunks) || input.maxChunks <= 0) {
250
+ throw new Error("maxChunks must be a positive integer");
251
+ }
252
+
253
+ const now = Date.now();
254
+ const leaseExpiresAt = now + input.leaseDurationMs;
255
+ const dueClause = `(status IN ('pending', 'waiting') AND next_run_at <= ?)
256
+ OR (status = 'processing' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?)`;
257
+ const result = await getDbExec().execute({
258
+ sql: isPostgres()
259
+ ? `UPDATE integration_campaigns
260
+ SET status = ?, chunk_count = CASE WHEN status = 'waiting' OR checkpoint = ? THEN chunk_count ELSE chunk_count + 1 END, current_run_id = ?,
261
+ lease_token = ?, lease_expires_at = ?, updated_at = ?, error_message = NULL
262
+ WHERE id = ? AND (status = 'waiting' OR checkpoint = ? OR chunk_count < ?) AND (${dueClause})
263
+ RETURNING *`
264
+ : `UPDATE integration_campaigns
265
+ SET status = ?, chunk_count = CASE WHEN status = 'waiting' OR checkpoint = ? THEN chunk_count ELSE chunk_count + 1 END, current_run_id = ?,
266
+ lease_token = ?, lease_expires_at = ?, updated_at = ?, error_message = NULL
267
+ WHERE id = ? AND (status = 'waiting' OR checkpoint = ? OR chunk_count < ?) AND (${dueClause})`,
268
+ args: [
269
+ "processing",
270
+ A2A_WAITING_CHECKPOINT,
271
+ input.runId,
272
+ input.leaseToken,
273
+ leaseExpiresAt,
274
+ now,
275
+ id,
276
+ A2A_WAITING_CHECKPOINT,
277
+ input.maxChunks,
278
+ now,
279
+ now,
280
+ ],
281
+ });
282
+ if (isPostgres()) {
283
+ const row = result.rows?.[0];
284
+ if (row)
285
+ return {
286
+ kind: "claimed",
287
+ campaign: rowToCampaign(row as Record<string, unknown>),
288
+ };
289
+ } else if (affectedRows(result) > 0) {
290
+ const campaign = await getIntegrationCampaign(id);
291
+ if (
292
+ campaign &&
293
+ campaign.status === "processing" &&
294
+ campaign.currentRunId === input.runId &&
295
+ campaign.leaseToken === input.leaseToken
296
+ ) {
297
+ return { kind: "claimed", campaign };
298
+ }
299
+ return { kind: "not-due" };
300
+ }
301
+
302
+ const campaign = await getIntegrationCampaign(id);
303
+ const waitingForA2A = campaign?.checkpoint === A2A_WAITING_CHECKPOINT;
304
+ const exhaustedAndRecoverable =
305
+ campaign &&
306
+ campaign.chunkCount >= input.maxChunks &&
307
+ !waitingForA2A &&
308
+ (campaign.status === "pending" ||
309
+ (campaign.status === "processing" &&
310
+ (campaign.leaseExpiresAt == null || campaign.leaseExpiresAt <= now)));
311
+ if (exhaustedAndRecoverable) {
312
+ return { kind: "chunk-limit", campaign };
313
+ }
314
+ return { kind: "not-due" };
315
+ }
316
+
317
+ export async function claimIntegrationCampaignDeliveryForTask(
318
+ integrationTaskId: string,
319
+ input: { runId: string; leaseToken: string; leaseDurationMs: number },
320
+ ): Promise<IntegrationCampaign | null> {
321
+ await ensureTable();
322
+ if (!Number.isFinite(input.leaseDurationMs) || input.leaseDurationMs <= 0) {
323
+ throw new Error("leaseDurationMs must be positive");
324
+ }
325
+ const now = Date.now();
326
+ const result = await getDbExec().execute({
327
+ sql: isPostgres()
328
+ ? `UPDATE integration_campaigns
329
+ SET status = 'processing', current_run_id = ?, lease_token = ?,
330
+ lease_expires_at = ?, updated_at = ?
331
+ WHERE integration_task_id = ?
332
+ AND (status IN ('pending', 'waiting') OR
333
+ (status = 'processing' AND
334
+ (lease_expires_at IS NULL OR lease_expires_at <= ?)))
335
+ RETURNING *`
336
+ : `UPDATE integration_campaigns
337
+ SET status = 'processing', current_run_id = ?, lease_token = ?,
338
+ lease_expires_at = ?, updated_at = ?
339
+ WHERE integration_task_id = ?
340
+ AND (status IN ('pending', 'waiting') OR
341
+ (status = 'processing' AND
342
+ (lease_expires_at IS NULL OR lease_expires_at <= ?)))`,
343
+ args: [
344
+ input.runId,
345
+ input.leaseToken,
346
+ now + input.leaseDurationMs,
347
+ now,
348
+ integrationTaskId,
349
+ now,
350
+ ],
351
+ });
352
+ if (isPostgres()) {
353
+ const row = result.rows?.[0];
354
+ return row ? rowToCampaign(row as Record<string, unknown>) : null;
355
+ }
356
+ if (affectedRows(result) === 0) return null;
357
+ const campaign = await getIntegrationCampaignForTask(integrationTaskId);
358
+ return campaign?.currentRunId === input.runId &&
359
+ campaign.leaseToken === input.leaseToken
360
+ ? campaign
361
+ : null;
362
+ }
363
+
364
+ function leaseWhere(): string {
365
+ return "id = ? AND status = 'processing' AND current_run_id = ? AND lease_token = ?";
366
+ }
367
+
368
+ export async function heartbeatIntegrationCampaign(
369
+ id: string,
370
+ input: { runId: string; leaseToken: string; leaseDurationMs: number },
371
+ ): Promise<boolean> {
372
+ await ensureTable();
373
+ if (!Number.isFinite(input.leaseDurationMs) || input.leaseDurationMs <= 0) {
374
+ throw new Error("leaseDurationMs must be positive");
375
+ }
376
+ const now = Date.now();
377
+ const result = await getDbExec().execute({
378
+ sql: `UPDATE integration_campaigns
379
+ SET lease_expires_at = ?, updated_at = ?
380
+ WHERE ${leaseWhere()}`,
381
+ args: [now + input.leaseDurationMs, now, id, input.runId, input.leaseToken],
382
+ });
383
+ return affectedRows(result) > 0;
384
+ }
385
+
386
+ export async function scheduleNextIntegrationCampaign(
387
+ id: string,
388
+ input: {
389
+ runId: string;
390
+ leaseToken: string;
391
+ nextRunAt: number;
392
+ progressRef?: string | null;
393
+ checkpoint?: string | null;
394
+ },
395
+ ): Promise<boolean> {
396
+ await ensureTable();
397
+ const now = Date.now();
398
+ const progressRef = boundedOpaqueValue(
399
+ input.progressRef,
400
+ MAX_PROGRESS_REF_CHARS,
401
+ "progressRef",
402
+ );
403
+ const checkpoint = boundedOpaqueValue(
404
+ input.checkpoint,
405
+ MAX_CHECKPOINT_CHARS,
406
+ "checkpoint",
407
+ );
408
+ const result = await getDbExec().execute({
409
+ sql: `UPDATE integration_campaigns
410
+ SET status = ?, current_run_id = NULL, lease_token = NULL, lease_expires_at = NULL,
411
+ next_run_at = ?, progress_ref = COALESCE(?, progress_ref),
412
+ checkpoint = COALESCE(?, checkpoint), updated_at = ?
413
+ WHERE ${leaseWhere()}`,
414
+ args: [
415
+ "pending",
416
+ input.nextRunAt,
417
+ progressRef,
418
+ checkpoint,
419
+ now,
420
+ id,
421
+ input.runId,
422
+ input.leaseToken,
423
+ ],
424
+ });
425
+ return affectedRows(result) > 0;
426
+ }
427
+
428
+ export async function waitForA2AIntegrationCampaign(
429
+ id: string,
430
+ input: {
431
+ runId: string;
432
+ leaseToken: string;
433
+ nextRunAt: number;
434
+ progressRef?: string | null;
435
+ },
436
+ ): Promise<boolean> {
437
+ await ensureTable();
438
+ const now = Date.now();
439
+ const progressRef = boundedOpaqueValue(
440
+ input.progressRef,
441
+ MAX_PROGRESS_REF_CHARS,
442
+ "progressRef",
443
+ );
444
+ const result = await getDbExec().execute({
445
+ sql: `UPDATE integration_campaigns
446
+ SET status = 'waiting', current_run_id = NULL, lease_token = NULL,
447
+ lease_expires_at = NULL, next_run_at = ?,
448
+ progress_ref = COALESCE(?, progress_ref),
449
+ checkpoint = ?, updated_at = ?
450
+ WHERE ${leaseWhere()}`,
451
+ args: [
452
+ input.nextRunAt,
453
+ progressRef,
454
+ A2A_WAITING_CHECKPOINT,
455
+ now,
456
+ id,
457
+ input.runId,
458
+ input.leaseToken,
459
+ ],
460
+ });
461
+ return affectedRows(result) > 0;
462
+ }
463
+
464
+ async function finishIntegrationCampaign(
465
+ id: string,
466
+ input: {
467
+ runId: string;
468
+ leaseToken: string;
469
+ status: "completed" | "failed";
470
+ errorMessage?: string;
471
+ },
472
+ ): Promise<boolean> {
473
+ await ensureTable();
474
+ const now = Date.now();
475
+ const errorMessage = boundedOpaqueValue(
476
+ input.errorMessage,
477
+ MAX_ERROR_MESSAGE_CHARS,
478
+ "errorMessage",
479
+ );
480
+ const result = await getDbExec().execute({
481
+ sql: `UPDATE integration_campaigns
482
+ SET status = ?, current_run_id = NULL, lease_token = NULL, lease_expires_at = NULL,
483
+ progress_ref = NULL, checkpoint = NULL, error_message = ?,
484
+ updated_at = ?, completed_at = ?
485
+ WHERE ${leaseWhere()}`,
486
+ args: [
487
+ input.status,
488
+ errorMessage,
489
+ now,
490
+ now,
491
+ id,
492
+ input.runId,
493
+ input.leaseToken,
494
+ ],
495
+ });
496
+ return affectedRows(result) > 0;
497
+ }
498
+
499
+ export async function completeIntegrationCampaign(
500
+ id: string,
501
+ input: { runId: string; leaseToken: string },
502
+ ): Promise<boolean> {
503
+ return finishIntegrationCampaign(id, { ...input, status: "completed" });
504
+ }
505
+
506
+ export async function completeIntegrationCampaignTask(
507
+ id: string,
508
+ input: {
509
+ integrationTaskId: string;
510
+ runId: string;
511
+ leaseToken: string;
512
+ },
513
+ ): Promise<boolean> {
514
+ await ensureTable();
515
+ const now = Date.now();
516
+ const previousWriteGuard = isPostgres() ? "" : "AND changes() = 1";
517
+ const statements: DbExecStatement[] = [
518
+ {
519
+ sql: `UPDATE integration_campaigns
520
+ SET status = 'completed', current_run_id = NULL,
521
+ lease_token = NULL, lease_expires_at = NULL,
522
+ progress_ref = NULL, checkpoint = NULL, error_message = NULL,
523
+ updated_at = ?, completed_at = ?
524
+ WHERE id = ? AND integration_task_id = ?
525
+ AND status = 'processing'
526
+ AND current_run_id = ? AND lease_token = ?`,
527
+ args: [
528
+ now,
529
+ now,
530
+ id,
531
+ input.integrationTaskId,
532
+ input.runId,
533
+ input.leaseToken,
534
+ ],
535
+ },
536
+ {
537
+ sql: `UPDATE integration_pending_tasks
538
+ SET status = 'completed', payload = '{}', error_message = NULL,
539
+ updated_at = ?, completed_at = ?
540
+ WHERE id = ? AND status = 'processing'
541
+ ${previousWriteGuard}
542
+ AND EXISTS (
543
+ SELECT 1 FROM integration_campaigns
544
+ WHERE id = ? AND integration_task_id = ?
545
+ AND status = 'completed' AND updated_at = ?
546
+ AND completed_at = ?
547
+ )`,
548
+ args: [
549
+ now,
550
+ now,
551
+ input.integrationTaskId,
552
+ id,
553
+ input.integrationTaskId,
554
+ now,
555
+ now,
556
+ ],
557
+ },
558
+ ];
559
+ const db = getDbExec();
560
+ if (db.atomicBatch) {
561
+ const results = await db.atomicBatch(statements);
562
+ return (
563
+ results.length === statements.length &&
564
+ results.every((result) => affectedRows(result) > 0)
565
+ );
566
+ }
567
+ if (db.transaction) {
568
+ return await db.transaction(async (tx) => {
569
+ const campaignResult = await tx.execute(statements[0]!);
570
+ if (affectedRows(campaignResult) === 0) return false;
571
+ const taskResult = await tx.execute(statements[1]!);
572
+ if (affectedRows(taskResult) === 0) {
573
+ throw new Error("Campaign completion transition lost its task custody");
574
+ }
575
+ return true;
576
+ });
577
+ }
578
+ throw new Error("Database does not support atomic campaign completion");
579
+ }
580
+
581
+ export async function failIntegrationCampaign(
582
+ id: string,
583
+ input: { runId: string; leaseToken: string; errorMessage: string },
584
+ ): Promise<boolean> {
585
+ return finishIntegrationCampaign(id, { ...input, status: "failed" });
586
+ }
587
+
588
+ export async function failExhaustedIntegrationCampaign(
589
+ id: string,
590
+ input: { maxChunks: number; errorMessage: string },
591
+ ): Promise<boolean> {
592
+ await ensureTable();
593
+ const now = Date.now();
594
+ const errorMessage = boundedOpaqueValue(
595
+ input.errorMessage,
596
+ MAX_ERROR_MESSAGE_CHARS,
597
+ "errorMessage",
598
+ );
599
+ const result = await getDbExec().execute({
600
+ sql: `UPDATE integration_campaigns
601
+ SET status = 'failed', current_run_id = NULL, lease_token = NULL,
602
+ lease_expires_at = NULL, progress_ref = NULL, checkpoint = NULL,
603
+ error_message = ?, updated_at = ?, completed_at = ?
604
+ WHERE id = ? AND chunk_count >= ?
605
+ AND (checkpoint IS NULL OR checkpoint <> ?)
606
+ AND (status = 'pending' OR
607
+ (status = 'processing' AND
608
+ (lease_expires_at IS NULL OR lease_expires_at <= ?)))`,
609
+ args: [
610
+ errorMessage,
611
+ now,
612
+ now,
613
+ id,
614
+ input.maxChunks,
615
+ A2A_WAITING_CHECKPOINT,
616
+ now,
617
+ ],
618
+ });
619
+ return affectedRows(result) > 0;
620
+ }
621
+
622
+ export async function terminalizeIntegrationCampaignForTask(
623
+ integrationTaskId: string,
624
+ input: { status: "completed" | "failed"; errorMessage?: string },
625
+ ): Promise<boolean> {
626
+ await ensureTable();
627
+ const now = Date.now();
628
+ const errorMessage = boundedOpaqueValue(
629
+ input.errorMessage,
630
+ MAX_ERROR_MESSAGE_CHARS,
631
+ "errorMessage",
632
+ );
633
+ const result = await getDbExec().execute({
634
+ sql: `UPDATE integration_campaigns
635
+ SET status = ?, current_run_id = NULL, lease_token = NULL,
636
+ lease_expires_at = NULL, progress_ref = NULL, checkpoint = NULL,
637
+ error_message = ?, updated_at = ?, completed_at = ?
638
+ WHERE integration_task_id = ?
639
+ AND status IN ('pending', 'processing', 'waiting')`,
640
+ args: [input.status, errorMessage, now, now, integrationTaskId],
641
+ });
642
+ return affectedRows(result) > 0;
643
+ }
644
+
645
+ export async function failIntegrationCampaignTaskDeliveryContainment(
646
+ integrationTaskId: string,
647
+ errorMessage: string,
648
+ ): Promise<boolean> {
649
+ await ensureTable();
650
+ const now = Date.now();
651
+ const boundedError =
652
+ boundedOpaqueValue(errorMessage, MAX_ERROR_MESSAGE_CHARS, "errorMessage") ??
653
+ "Delivery transition failed";
654
+ const statements: DbExecStatement[] = [
655
+ {
656
+ sql: `UPDATE integration_campaigns
657
+ SET status = 'failed', current_run_id = NULL,
658
+ lease_token = NULL, lease_expires_at = NULL,
659
+ progress_ref = NULL, checkpoint = NULL, error_message = ?,
660
+ updated_at = ?, completed_at = ?
661
+ WHERE integration_task_id = ?
662
+ AND status IN ('pending', 'processing', 'waiting')`,
663
+ args: [boundedError, now, now, integrationTaskId],
664
+ },
665
+ {
666
+ sql: `UPDATE integration_pending_tasks
667
+ SET status = 'failed', payload = '{}', error_message = ?,
668
+ updated_at = ?, completed_at = ?
669
+ WHERE id = ? AND status = 'processing'
670
+ AND NOT EXISTS (
671
+ SELECT 1 FROM integration_campaigns
672
+ WHERE integration_task_id = ?
673
+ AND status IN ('pending', 'processing', 'waiting')
674
+ )`,
675
+ args: [boundedError, now, now, integrationTaskId, integrationTaskId],
676
+ },
677
+ ];
678
+ const db = getDbExec();
679
+ if (db.atomicBatch) {
680
+ const results = await db.atomicBatch(statements);
681
+ return (
682
+ results.length === statements.length && affectedRows(results[1]!) > 0
683
+ );
684
+ }
685
+ if (db.transaction) {
686
+ return await db.transaction(async (tx) => {
687
+ await tx.execute(statements[0]!);
688
+ const taskResult = await tx.execute(statements[1]!);
689
+ return affectedRows(taskResult) > 0;
690
+ });
691
+ }
692
+ throw new Error("Database does not support atomic delivery containment");
693
+ }
694
+
695
+ function disabledCampaignAndTaskStatements(
696
+ integrationTaskId: string,
697
+ errorMessage: string,
698
+ ): DbExecStatement[] {
699
+ const now = Date.now();
700
+ return [
701
+ {
702
+ sql: `UPDATE integration_campaigns
703
+ SET status = 'failed', current_run_id = NULL, lease_token = NULL,
704
+ lease_expires_at = NULL, progress_ref = NULL, checkpoint = NULL,
705
+ error_message = ?, updated_at = ?, completed_at = ?
706
+ WHERE integration_task_id = ?
707
+ AND status IN ('pending', 'processing', 'waiting')`,
708
+ args: [errorMessage, now, now, integrationTaskId],
709
+ },
710
+ {
711
+ sql: `UPDATE integration_pending_tasks
712
+ SET status = 'failed', payload = '{}', external_event_key = NULL,
713
+ error_message = ?, updated_at = ?, completed_at = ?
714
+ WHERE id = ? AND status = 'processing'`,
715
+ args: [errorMessage, now, now, integrationTaskId],
716
+ },
717
+ ];
718
+ }
719
+
720
+ async function executeStatementsWithDb(
721
+ db: DbExec,
722
+ statements: readonly DbExecStatement[],
723
+ ): Promise<void> {
724
+ for (const statement of statements) await db.execute(statement);
725
+ }
726
+
727
+ /**
728
+ * Fail closed when a durable campaign's rollout scope is removed. Both rows
729
+ * transition in one transaction so the per-thread processing lock cannot be
730
+ * stranded while the campaign is no longer recoverable.
731
+ */
732
+ export async function failDisabledIntegrationCampaignTask(
733
+ integrationTaskId: string,
734
+ errorMessage = "Durable integration campaign was disabled for this scope",
735
+ ): Promise<void> {
736
+ await ensureTable();
737
+ const boundedError =
738
+ boundedOpaqueValue(errorMessage, MAX_ERROR_MESSAGE_CHARS, "errorMessage") ??
739
+ "Durable integration campaign was disabled";
740
+ const db = getDbExec();
741
+ const statements = disabledCampaignAndTaskStatements(
742
+ integrationTaskId,
743
+ boundedError,
744
+ );
745
+ if (db.atomicBatch) {
746
+ await db.atomicBatch(statements);
747
+ return;
748
+ }
749
+ if (db.transaction) {
750
+ await db.transaction((tx) => executeStatementsWithDb(tx, statements));
751
+ return;
752
+ }
753
+ throw new Error("Database does not support atomic campaign cancellation");
754
+ }
755
+
756
+ export async function transitionIntegrationCampaignTaskToDeliveryRetry(
757
+ integrationTaskId: string,
758
+ input: {
759
+ payload: string;
760
+ errorMessage: string;
761
+ campaignStatus: "completed" | "failed";
762
+ campaignId: string;
763
+ runId: string;
764
+ leaseToken: string;
765
+ },
766
+ ): Promise<boolean> {
767
+ await ensureTable();
768
+ const now = Date.now();
769
+ const errorMessage =
770
+ boundedOpaqueValue(
771
+ input.errorMessage,
772
+ MAX_ERROR_MESSAGE_CHARS,
773
+ "errorMessage",
774
+ ) ?? "Integration response delivery needs retry";
775
+ const previousWriteGuard = isPostgres() ? "" : "AND changes() = 1";
776
+ const statements: DbExecStatement[] = [
777
+ {
778
+ sql: `UPDATE integration_campaigns
779
+ SET status = ?, current_run_id = NULL, lease_token = NULL,
780
+ lease_expires_at = NULL, progress_ref = NULL, checkpoint = NULL,
781
+ error_message = ?, updated_at = ?, completed_at = ?
782
+ WHERE id = ? AND integration_task_id = ?
783
+ AND status = 'processing'
784
+ AND current_run_id = ? AND lease_token = ?`,
785
+ args: [
786
+ input.campaignStatus,
787
+ input.campaignStatus === "failed" ? errorMessage : null,
788
+ now,
789
+ now,
790
+ input.campaignId,
791
+ integrationTaskId,
792
+ input.runId,
793
+ input.leaseToken,
794
+ ],
795
+ },
796
+ {
797
+ sql: `UPDATE integration_pending_tasks
798
+ SET status = 'pending', payload = ?, error_message = ?, updated_at = ?
799
+ WHERE id = ? AND status = 'processing'
800
+ ${previousWriteGuard}
801
+ AND EXISTS (
802
+ SELECT 1 FROM integration_campaigns
803
+ WHERE id = ? AND integration_task_id = ?
804
+ AND status = ? AND updated_at = ? AND completed_at = ?
805
+ AND current_run_id IS NULL AND lease_token IS NULL
806
+ )`,
807
+ args: [
808
+ input.payload,
809
+ errorMessage,
810
+ now,
811
+ integrationTaskId,
812
+ input.campaignId,
813
+ integrationTaskId,
814
+ input.campaignStatus,
815
+ now,
816
+ now,
817
+ ],
818
+ },
819
+ ];
820
+ const db = getDbExec();
821
+ if (db.atomicBatch) {
822
+ const results = await db.atomicBatch(statements);
823
+ return (
824
+ results.length === statements.length &&
825
+ results.every((result) => affectedRows(result) > 0)
826
+ );
827
+ }
828
+ if (db.transaction) {
829
+ return await db.transaction(async (tx) => {
830
+ const campaignResult = await tx.execute(statements[0]!);
831
+ if (affectedRows(campaignResult) === 0) return false;
832
+ const taskResult = await tx.execute(statements[1]!);
833
+ if (affectedRows(taskResult) === 0) {
834
+ throw new Error(
835
+ "Campaign delivery retry lease transition lost its race",
836
+ );
837
+ }
838
+ return true;
839
+ });
840
+ }
841
+ throw new Error("Database does not support atomic campaign delivery retry");
842
+ }
843
+
844
+ export async function transitionIntegrationCampaignTaskToA2AReceiptRetry(
845
+ integrationTaskId: string,
846
+ input: {
847
+ payload: string;
848
+ errorMessage: string;
849
+ campaignId: string;
850
+ runId: string;
851
+ leaseToken: string;
852
+ nextRunAt: number;
853
+ },
854
+ ): Promise<boolean> {
855
+ await ensureTable();
856
+ const now = Date.now();
857
+ const errorMessage =
858
+ boundedOpaqueValue(
859
+ input.errorMessage,
860
+ MAX_ERROR_MESSAGE_CHARS,
861
+ "errorMessage",
862
+ ) ?? "A2A partial response history needs retry";
863
+ const previousWriteGuard = isPostgres() ? "" : "AND changes() = 1";
864
+ const statements: DbExecStatement[] = [
865
+ {
866
+ sql: `UPDATE integration_campaigns
867
+ SET status = 'waiting', current_run_id = NULL, lease_token = NULL,
868
+ lease_expires_at = NULL, next_run_at = ?, checkpoint = ?,
869
+ error_message = NULL, updated_at = ?
870
+ WHERE id = ? AND integration_task_id = ?
871
+ AND status = 'processing'
872
+ AND current_run_id = ? AND lease_token = ?`,
873
+ args: [
874
+ input.nextRunAt,
875
+ A2A_WAITING_CHECKPOINT,
876
+ now,
877
+ input.campaignId,
878
+ integrationTaskId,
879
+ input.runId,
880
+ input.leaseToken,
881
+ ],
882
+ },
883
+ {
884
+ sql: `UPDATE integration_pending_tasks
885
+ SET payload = ?, error_message = ?, updated_at = ?
886
+ WHERE id = ? AND status = 'processing'
887
+ ${previousWriteGuard}
888
+ AND EXISTS (
889
+ SELECT 1 FROM integration_campaigns
890
+ WHERE id = ? AND integration_task_id = ?
891
+ AND status = 'waiting' AND checkpoint = ?
892
+ AND updated_at = ? AND current_run_id IS NULL
893
+ AND lease_token IS NULL
894
+ )`,
895
+ args: [
896
+ input.payload,
897
+ errorMessage,
898
+ now,
899
+ integrationTaskId,
900
+ input.campaignId,
901
+ integrationTaskId,
902
+ A2A_WAITING_CHECKPOINT,
903
+ now,
904
+ ],
905
+ },
906
+ ];
907
+ const db = getDbExec();
908
+ if (db.atomicBatch) {
909
+ const results = await db.atomicBatch(statements);
910
+ return (
911
+ results.length === statements.length &&
912
+ results.every((result) => affectedRows(result) > 0)
913
+ );
914
+ }
915
+ if (db.transaction) {
916
+ return await db.transaction(async (tx) => {
917
+ const campaignResult = await tx.execute(statements[0]!);
918
+ if (affectedRows(campaignResult) === 0) return false;
919
+ const taskResult = await tx.execute(statements[1]!);
920
+ if (affectedRows(taskResult) === 0) {
921
+ throw new Error("A2A receipt custody transition lost its race");
922
+ }
923
+ return true;
924
+ });
925
+ }
926
+ throw new Error("Database does not support atomic A2A receipt custody");
927
+ }
928
+
929
+ export async function refreshIntegrationCampaignTaskA2AReceiptRetry(
930
+ integrationTaskId: string,
931
+ input: { payload: string; errorMessage: string },
932
+ ): Promise<boolean> {
933
+ await ensureTable();
934
+ const result = await getDbExec().execute({
935
+ sql: `UPDATE integration_pending_tasks
936
+ SET payload = ?, error_message = ?, updated_at = ?
937
+ WHERE id = ? AND status = 'processing'
938
+ AND EXISTS (
939
+ SELECT 1 FROM integration_campaigns
940
+ WHERE integration_task_id = ? AND status = 'waiting'
941
+ AND checkpoint = ?
942
+ )`,
943
+ args: [
944
+ input.payload,
945
+ input.errorMessage.slice(0, MAX_ERROR_MESSAGE_CHARS),
946
+ Date.now(),
947
+ integrationTaskId,
948
+ integrationTaskId,
949
+ A2A_WAITING_CHECKPOINT,
950
+ ],
951
+ });
952
+ return affectedRows(result) > 0;
953
+ }
954
+
955
+ export async function completeIntegrationCampaignTaskAfterA2A(
956
+ integrationTaskId: string,
957
+ ): Promise<boolean> {
958
+ await ensureTable();
959
+ const now = Date.now();
960
+ const previousWriteGuard = isPostgres() ? "" : "AND changes() = 1";
961
+ const statements: DbExecStatement[] = [
962
+ {
963
+ sql: `UPDATE integration_campaigns
964
+ SET status = 'completed', current_run_id = NULL,
965
+ lease_token = NULL, lease_expires_at = NULL,
966
+ progress_ref = NULL, checkpoint = NULL, error_message = NULL,
967
+ updated_at = ?, completed_at = ?
968
+ WHERE integration_task_id = ? AND status = 'waiting'
969
+ AND checkpoint = ?`,
970
+ args: [now, now, integrationTaskId, A2A_WAITING_CHECKPOINT],
971
+ },
972
+ {
973
+ sql: `UPDATE integration_pending_tasks
974
+ SET status = 'completed', payload = '{}', error_message = NULL,
975
+ updated_at = ?, completed_at = ?
976
+ WHERE id = ? AND status = 'processing'
977
+ ${previousWriteGuard}
978
+ AND EXISTS (
979
+ SELECT 1 FROM integration_campaigns
980
+ WHERE integration_task_id = ? AND status = 'completed'
981
+ AND updated_at = ? AND completed_at = ?
982
+ )`,
983
+ args: [now, now, integrationTaskId, integrationTaskId, now, now],
984
+ },
985
+ ];
986
+ const db = getDbExec();
987
+ if (db.atomicBatch) {
988
+ const results = await db.atomicBatch(statements);
989
+ return (
990
+ results.length === statements.length &&
991
+ results.every((result) => affectedRows(result) > 0)
992
+ );
993
+ }
994
+ if (db.transaction) {
995
+ return await db.transaction(async (tx) => {
996
+ const campaignResult = await tx.execute(statements[0]!);
997
+ if (affectedRows(campaignResult) === 0) return false;
998
+ const taskResult = await tx.execute(statements[1]!);
999
+ if (affectedRows(taskResult) === 0) {
1000
+ throw new Error("A2A parent completion transition lost its race");
1001
+ }
1002
+ return true;
1003
+ });
1004
+ }
1005
+ throw new Error("Database does not support atomic A2A parent completion");
1006
+ }
1007
+
1008
+ export async function listDueIntegrationCampaignIds(
1009
+ limit = 25,
1010
+ ): Promise<string[]> {
1011
+ await ensureTable();
1012
+ const boundedLimit = Math.max(
1013
+ 1,
1014
+ Math.min(Math.floor(limit), MAX_DUE_LIST_LIMIT),
1015
+ );
1016
+ const now = Date.now();
1017
+ const { rows } = await getDbExec().execute({
1018
+ sql: `SELECT id FROM integration_campaigns
1019
+ WHERE (status IN ('pending', 'waiting') AND next_run_at <= ?)
1020
+ OR (status = 'processing' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?)
1021
+ ORDER BY next_run_at ASC, id ASC
1022
+ LIMIT ?`,
1023
+ args: [now, now, boundedLimit],
1024
+ });
1025
+ return rows.map((row) => String((row as Record<string, unknown>).id));
1026
+ }
1027
+
1028
+ export async function hasActiveIntegrationCampaign(
1029
+ integrationTaskId: string,
1030
+ ): Promise<boolean> {
1031
+ await ensureTable();
1032
+ const { rows } = await getDbExec().execute({
1033
+ sql: `SELECT 1 AS active FROM integration_campaigns
1034
+ WHERE integration_task_id = ? AND status IN ('pending', 'processing', 'waiting')
1035
+ LIMIT 1`,
1036
+ args: [integrationTaskId],
1037
+ });
1038
+ return rows.length > 0;
1039
+ }