@agent-native/core 0.119.5 → 0.120.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 (118) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +18 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/durable-background.ts +1 -0
  5. package/corpus/core/src/cli/index.ts +64 -3
  6. package/corpus/core/src/deploy/build.ts +144 -2
  7. package/corpus/core/src/deploy/workspace-deploy.ts +123 -1
  8. package/corpus/core/src/integrations/integration-durable-dispatch-config.ts +22 -0
  9. package/corpus/core/src/integrations/integration-durable-dispatch.ts +190 -0
  10. package/corpus/core/src/integrations/pending-tasks-retry-job.ts +175 -87
  11. package/corpus/core/src/integrations/pending-tasks-store.ts +114 -11
  12. package/corpus/core/src/integrations/plugin.ts +113 -60
  13. package/corpus/core/src/integrations/task-queue-stats.ts +68 -15
  14. package/corpus/core/src/integrations/webhook-handler.ts +21 -46
  15. package/corpus/core/src/server/auth.ts +6 -0
  16. package/corpus/core/src/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  17. package/corpus/core/src/templates/default/package.json +1 -1
  18. package/corpus/core/src/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  19. package/corpus/core/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
  20. package/corpus/templates/assets/package.json +1 -1
  21. package/corpus/templates/brain/package.json +1 -1
  22. package/corpus/templates/calendar/package.json +1 -1
  23. package/corpus/templates/clips/package.json +1 -1
  24. package/corpus/templates/content/actions/add-database-item.ts +1 -0
  25. package/corpus/templates/content/actions/set-document-property.ts +0 -4
  26. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +10 -1
  27. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +10 -4
  28. package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +5 -1
  29. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -0
  30. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +52 -33
  31. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +16 -4
  32. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +16 -7
  33. package/corpus/templates/content/app/hooks/use-create-page.ts +8 -3
  34. package/corpus/templates/content/app/hooks/use-document-properties.ts +1 -0
  35. package/corpus/templates/content/app/hooks/use-documents.ts +3 -1
  36. package/corpus/templates/content/app/lib/optimistic-document.ts +29 -0
  37. package/corpus/templates/content/changelog/2026-07-21-editing-a-database-property-no-longer-refreshes-unrelated-co.md +6 -0
  38. package/corpus/templates/content/changelog/2026-07-23-new-database-ready.md +6 -0
  39. package/corpus/templates/content/package.json +1 -1
  40. package/corpus/templates/content/shared/api.ts +1 -0
  41. package/corpus/templates/design/package.json +1 -1
  42. package/corpus/templates/dispatch/changelog/2026-07-21-recover-stalled-slack-agent-work.md +6 -0
  43. package/corpus/templates/dispatch/package.json +1 -1
  44. package/corpus/templates/forms/package.json +1 -1
  45. package/corpus/templates/macros/package.json +1 -1
  46. package/corpus/templates/mail/package.json +1 -1
  47. package/corpus/templates/slides/package.json +1 -1
  48. package/dist/agent/durable-background.d.ts +1 -0
  49. package/dist/agent/durable-background.d.ts.map +1 -1
  50. package/dist/agent/durable-background.js +1 -0
  51. package/dist/agent/durable-background.js.map +1 -1
  52. package/dist/cli/index.js +53 -2
  53. package/dist/cli/index.js.map +1 -1
  54. package/dist/collab/routes.d.ts +1 -1
  55. package/dist/deploy/build.d.ts +3 -0
  56. package/dist/deploy/build.d.ts.map +1 -1
  57. package/dist/deploy/build.js +106 -3
  58. package/dist/deploy/build.js.map +1 -1
  59. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  60. package/dist/deploy/workspace-deploy.js +103 -2
  61. package/dist/deploy/workspace-deploy.js.map +1 -1
  62. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  63. package/dist/integrations/integration-durable-dispatch-config.d.ts +7 -0
  64. package/dist/integrations/integration-durable-dispatch-config.d.ts.map +1 -0
  65. package/dist/integrations/integration-durable-dispatch-config.js +16 -0
  66. package/dist/integrations/integration-durable-dispatch-config.js.map +1 -0
  67. package/dist/integrations/integration-durable-dispatch.d.ts +23 -0
  68. package/dist/integrations/integration-durable-dispatch.d.ts.map +1 -0
  69. package/dist/integrations/integration-durable-dispatch.js +115 -0
  70. package/dist/integrations/integration-durable-dispatch.js.map +1 -0
  71. package/dist/integrations/pending-tasks-retry-job.d.ts +13 -1
  72. package/dist/integrations/pending-tasks-retry-job.d.ts.map +1 -1
  73. package/dist/integrations/pending-tasks-retry-job.js +133 -74
  74. package/dist/integrations/pending-tasks-retry-job.js.map +1 -1
  75. package/dist/integrations/pending-tasks-store.d.ts +14 -2
  76. package/dist/integrations/pending-tasks-store.d.ts.map +1 -1
  77. package/dist/integrations/pending-tasks-store.js +75 -11
  78. package/dist/integrations/pending-tasks-store.js.map +1 -1
  79. package/dist/integrations/plugin.d.ts.map +1 -1
  80. package/dist/integrations/plugin.js +88 -55
  81. package/dist/integrations/plugin.js.map +1 -1
  82. package/dist/integrations/task-queue-stats.d.ts +15 -3
  83. package/dist/integrations/task-queue-stats.d.ts.map +1 -1
  84. package/dist/integrations/task-queue-stats.js +45 -15
  85. package/dist/integrations/task-queue-stats.js.map +1 -1
  86. package/dist/integrations/webhook-handler.d.ts +1 -1
  87. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  88. package/dist/integrations/webhook-handler.js +18 -42
  89. package/dist/integrations/webhook-handler.js.map +1 -1
  90. package/dist/observability/routes.d.ts +6 -6
  91. package/dist/provider-api/actions/custom-provider-registration.d.ts +5 -5
  92. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  93. package/dist/resources/handlers.d.ts +1 -1
  94. package/dist/server/auth.d.ts.map +1 -1
  95. package/dist/server/auth.js +5 -0
  96. package/dist/server/auth.js.map +1 -1
  97. package/dist/server/transcribe-voice.d.ts +1 -1
  98. package/dist/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  99. package/dist/templates/default/package.json +1 -1
  100. package/dist/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  101. package/dist/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
  102. package/package.json +1 -1
  103. package/src/agent/durable-background.ts +1 -0
  104. package/src/cli/index.ts +64 -3
  105. package/src/deploy/build.ts +144 -2
  106. package/src/deploy/workspace-deploy.ts +123 -1
  107. package/src/integrations/integration-durable-dispatch-config.ts +22 -0
  108. package/src/integrations/integration-durable-dispatch.ts +190 -0
  109. package/src/integrations/pending-tasks-retry-job.ts +175 -87
  110. package/src/integrations/pending-tasks-store.ts +114 -11
  111. package/src/integrations/plugin.ts +113 -60
  112. package/src/integrations/task-queue-stats.ts +68 -15
  113. package/src/integrations/webhook-handler.ts +21 -46
  114. package/src/server/auth.ts +6 -0
  115. package/src/templates/default/.agents/skills/integration-webhooks/SKILL.md +63 -22
  116. package/src/templates/default/package.json +1 -1
  117. package/src/templates/headless/.agents/skills/integration-webhooks/SKILL.md +63 -22
  118. package/src/templates/workspace-core/.agents/skills/integration-webhooks/SKILL.md +63 -22
@@ -33,6 +33,10 @@ async function ensureTable(): Promise<void> {
33
33
  org_id TEXT,
34
34
  status TEXT NOT NULL,
35
35
  attempts ${intType()} NOT NULL DEFAULT 0,
36
+ dispatch_attempts ${intType()} NOT NULL DEFAULT 0,
37
+ last_dispatch_at ${intType()},
38
+ last_dispatch_outcome TEXT,
39
+ dispatch_scope TEXT,
36
40
  error_message TEXT,
37
41
  created_at ${intType()} NOT NULL,
38
42
  updated_at ${intType()} NOT NULL,
@@ -47,6 +51,26 @@ async function ensureTable(): Promise<void> {
47
51
  "external_event_key",
48
52
  `ALTER TABLE integration_pending_tasks ADD COLUMN IF NOT EXISTS external_event_key TEXT`,
49
53
  );
54
+ await ensureColumnExists(
55
+ "integration_pending_tasks",
56
+ "dispatch_attempts",
57
+ `ALTER TABLE integration_pending_tasks ADD COLUMN IF NOT EXISTS dispatch_attempts ${intType()} NOT NULL DEFAULT 0`,
58
+ );
59
+ await ensureColumnExists(
60
+ "integration_pending_tasks",
61
+ "last_dispatch_at",
62
+ `ALTER TABLE integration_pending_tasks ADD COLUMN IF NOT EXISTS last_dispatch_at ${intType()}`,
63
+ );
64
+ await ensureColumnExists(
65
+ "integration_pending_tasks",
66
+ "last_dispatch_outcome",
67
+ `ALTER TABLE integration_pending_tasks ADD COLUMN IF NOT EXISTS last_dispatch_outcome TEXT`,
68
+ );
69
+ await ensureColumnExists(
70
+ "integration_pending_tasks",
71
+ "dispatch_scope",
72
+ `ALTER TABLE integration_pending_tasks ADD COLUMN IF NOT EXISTS dispatch_scope TEXT`,
73
+ );
50
74
  await ensureIndexExists(
51
75
  "idx_pending_tasks_status_created",
52
76
  `CREATE INDEX IF NOT EXISTS idx_pending_tasks_status_created ON integration_pending_tasks(status, created_at)`,
@@ -55,6 +79,10 @@ async function ensureTable(): Promise<void> {
55
79
  "idx_pending_tasks_event_key",
56
80
  `CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_tasks_event_key ON integration_pending_tasks(platform, external_event_key)`,
57
81
  );
82
+ await ensureIndexExists(
83
+ "idx_pending_tasks_dispatch_scope",
84
+ `CREATE INDEX IF NOT EXISTS idx_pending_tasks_dispatch_scope ON integration_pending_tasks(platform, dispatch_scope)`,
85
+ );
58
86
  return;
59
87
  }
60
88
 
@@ -69,6 +97,10 @@ async function ensureTable(): Promise<void> {
69
97
  // unique index ensures a duplicate INSERT raises an error we can
70
98
  // catch as "already-enqueued".
71
99
  await ensureExternalEventKey(client);
100
+ await ensureDispatchColumns(client);
101
+ await client.execute(
102
+ `CREATE INDEX IF NOT EXISTS idx_pending_tasks_dispatch_scope ON integration_pending_tasks(platform, dispatch_scope)`,
103
+ );
72
104
  await client.execute(
73
105
  `CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_tasks_event_key ON integration_pending_tasks(platform, external_event_key)`,
74
106
  );
@@ -81,6 +113,10 @@ async function ensureTable(): Promise<void> {
81
113
  return _initPromise;
82
114
  }
83
115
 
116
+ export async function ensurePendingTasksTable(): Promise<void> {
117
+ await ensureTable();
118
+ }
119
+
84
120
  async function ensureExternalEventKey(
85
121
  client: ReturnType<typeof getDbExec>,
86
122
  ): Promise<void> {
@@ -107,6 +143,32 @@ async function ensureExternalEventKey(
107
143
  }
108
144
  }
109
145
 
146
+ async function ensureDispatchColumns(
147
+ client: ReturnType<typeof getDbExec>,
148
+ ): Promise<void> {
149
+ const columns = [
150
+ `dispatch_attempts ${intType()} NOT NULL DEFAULT 0`,
151
+ `last_dispatch_at ${intType()}`,
152
+ "last_dispatch_outcome TEXT",
153
+ "dispatch_scope TEXT",
154
+ ];
155
+ for (const column of columns) {
156
+ try {
157
+ await client.execute(
158
+ `ALTER TABLE integration_pending_tasks ADD COLUMN ${column}`,
159
+ );
160
+ } catch (err: any) {
161
+ if (
162
+ !String(err?.message ?? err)
163
+ .toLowerCase()
164
+ .includes("duplicate")
165
+ ) {
166
+ throw err;
167
+ }
168
+ }
169
+ }
170
+ }
171
+
110
172
  /** Status values for an integration pending task. */
111
173
  export type PendingTaskStatus =
112
174
  | "pending"
@@ -123,6 +185,10 @@ export interface PendingTask {
123
185
  orgId: string | null;
124
186
  status: PendingTaskStatus;
125
187
  attempts: number;
188
+ dispatchAttempts: number;
189
+ lastDispatchAt: number | null;
190
+ lastDispatchOutcome: string | null;
191
+ dispatchScope: string | null;
126
192
  errorMessage: string | null;
127
193
  createdAt: number;
128
194
  updatedAt: number;
@@ -164,6 +230,13 @@ function rowToTask(row: Record<string, unknown>): PendingTask {
164
230
  orgId: (row.org_id as string | null) ?? null,
165
231
  status: row.status as PendingTaskStatus,
166
232
  attempts: Number(row.attempts ?? 0),
233
+ dispatchAttempts: Number(row.dispatch_attempts ?? 0),
234
+ lastDispatchAt:
235
+ row.last_dispatch_at == null
236
+ ? null
237
+ : Number(row.last_dispatch_at as number),
238
+ lastDispatchOutcome: (row.last_dispatch_outcome as string | null) ?? null,
239
+ dispatchScope: (row.dispatch_scope as string | null) ?? null,
167
240
  errorMessage: (row.error_message as string | null) ?? null,
168
241
  createdAt: Number(row.created_at ?? 0),
169
242
  updatedAt: Number(row.updated_at ?? 0),
@@ -189,14 +262,15 @@ export async function insertPendingTask(input: {
189
262
  ownerEmail: string;
190
263
  orgId?: string | null;
191
264
  externalEventKey?: string | null;
265
+ dispatchScope?: string | null;
192
266
  }): Promise<void> {
193
267
  await ensureTable();
194
268
  const client = getDbExec();
195
269
  const now = Date.now();
196
270
  await client.execute({
197
271
  sql: `INSERT INTO integration_pending_tasks
198
- (id, platform, external_thread_id, payload, owner_email, org_id, status, attempts, created_at, updated_at, external_event_key)
199
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
272
+ (id, platform, external_thread_id, payload, owner_email, org_id, status, attempts, created_at, updated_at, external_event_key, dispatch_scope)
273
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
200
274
  args: [
201
275
  input.id,
202
276
  input.platform,
@@ -209,6 +283,7 @@ export async function insertPendingTask(input: {
209
283
  now,
210
284
  now,
211
285
  input.externalEventKey ?? null,
286
+ input.dispatchScope ?? null,
212
287
  ],
213
288
  });
214
289
  }
@@ -238,7 +313,7 @@ export async function getPendingTask(id: string): Promise<PendingTask | null> {
238
313
  await ensureTable();
239
314
  const client = getDbExec();
240
315
  const { rows } = await client.execute({
241
- sql: `SELECT id, platform, external_thread_id, payload, owner_email, org_id, status, attempts, error_message, created_at, updated_at, completed_at
316
+ sql: `SELECT id, platform, external_thread_id, payload, owner_email, org_id, status, attempts, dispatch_attempts, last_dispatch_at, last_dispatch_outcome, dispatch_scope, error_message, created_at, updated_at, completed_at
242
317
  FROM integration_pending_tasks WHERE id = ? LIMIT 1`,
243
318
  args: [id],
244
319
  });
@@ -342,6 +417,7 @@ export async function resolveIntegrationSourceContext(
342
417
  */
343
418
  export async function claimPendingTask(
344
419
  id: string,
420
+ options?: { dispatchOutcome?: string },
345
421
  ): Promise<PendingTask | null> {
346
422
  await ensureTable();
347
423
  const client = getDbExec();
@@ -352,7 +428,8 @@ export async function claimPendingTask(
352
428
  const result = await client.execute({
353
429
  sql: isPostgres()
354
430
  ? `UPDATE integration_pending_tasks
355
- SET status = ?, attempts = attempts + 1, updated_at = ?
431
+ SET status = ?, attempts = attempts + 1, updated_at = ?,
432
+ last_dispatch_outcome = COALESCE(?, last_dispatch_outcome)
356
433
  WHERE id = ? AND status = 'pending'
357
434
  AND NOT EXISTS (
358
435
  SELECT 1 FROM integration_pending_tasks active
@@ -374,9 +451,10 @@ export async function claimPendingTask(
374
451
  )
375
452
  )
376
453
  )
377
- RETURNING id, platform, external_thread_id, payload, owner_email, org_id, status, attempts, error_message, created_at, updated_at, completed_at`
454
+ RETURNING id, platform, external_thread_id, payload, owner_email, org_id, status, attempts, dispatch_attempts, last_dispatch_at, last_dispatch_outcome, dispatch_scope, error_message, created_at, updated_at, completed_at`
378
455
  : `UPDATE integration_pending_tasks
379
- SET status = ?, attempts = attempts + 1, updated_at = ?
456
+ SET status = ?, attempts = attempts + 1, updated_at = ?,
457
+ last_dispatch_outcome = COALESCE(?, last_dispatch_outcome)
380
458
  WHERE id = ? AND status = 'pending'
381
459
  AND NOT EXISTS (
382
460
  SELECT 1 FROM integration_pending_tasks active
@@ -398,7 +476,7 @@ export async function claimPendingTask(
398
476
  )
399
477
  )
400
478
  )`,
401
- args: ["processing", now, id],
479
+ args: ["processing", now, options?.dispatchOutcome ?? null, id],
402
480
  });
403
481
  const rows = result.rows ?? [];
404
482
 
@@ -418,19 +496,44 @@ export async function claimPendingTask(
418
496
  return fetched;
419
497
  }
420
498
 
499
+ export async function recordPendingTaskDispatchAttempt(
500
+ id: string,
501
+ outcome: string,
502
+ ): Promise<void> {
503
+ await ensureTable();
504
+ await getDbExec().execute({
505
+ sql: `UPDATE integration_pending_tasks
506
+ SET dispatch_attempts = dispatch_attempts + 1,
507
+ last_dispatch_at = ?,
508
+ last_dispatch_outcome = CASE
509
+ WHEN status = 'processing'
510
+ AND last_dispatch_outcome = 'background-acknowledged'
511
+ THEN last_dispatch_outcome
512
+ ELSE ?
513
+ END
514
+ WHERE id = ?`,
515
+ args: [Date.now(), outcome.slice(0, 80), id],
516
+ });
517
+ }
518
+
421
519
  /** Next queued turn for a provider thread after its current task completes. */
422
- export async function getNextPendingTaskIdForThread(
520
+ export async function getNextPendingTaskForThread(
423
521
  platform: string,
424
522
  externalThreadId: string,
425
- ): Promise<string | null> {
523
+ ): Promise<{ id: string; dispatchScope: string | null } | null> {
426
524
  await ensureTable();
427
525
  const { rows } = await getDbExec().execute({
428
- sql: `SELECT id FROM integration_pending_tasks
526
+ sql: `SELECT id, dispatch_scope FROM integration_pending_tasks
429
527
  WHERE platform = ? AND external_thread_id = ? AND status = 'pending'
430
528
  ORDER BY created_at ASC, id ASC LIMIT 1`,
431
529
  args: [platform, externalThreadId],
432
530
  });
433
- return rows[0]?.id ? String(rows[0].id) : null;
531
+ return rows[0]?.id
532
+ ? {
533
+ id: String(rows[0].id),
534
+ dispatchScope: (rows[0].dispatch_scope as string | null) ?? null,
535
+ }
536
+ : null;
434
537
  }
435
538
 
436
539
  /** Mark a task as completed. */
@@ -9,6 +9,10 @@ import {
9
9
  import { getRequestHeader } from "h3";
10
10
  import { createRemoteJWKSet, jwtVerify } from "jose";
11
11
 
12
+ import {
13
+ AGENT_BACKGROUND_PROCESSOR_FIELD,
14
+ AGENT_BACKGROUND_PROCESSOR_INTEGRATION,
15
+ } from "../agent/durable-background.js";
12
16
  import { abortRun } from "../agent/run-manager.js";
13
17
  import { getOrgContext, resolveOrgIdForEmail } from "../org/context.js";
14
18
  import { loadResourcesForPrompt } from "../server/agent-chat-plugin.js";
@@ -65,22 +69,27 @@ import {
65
69
  updateIntegrationInstallation,
66
70
  upsertIntegrationInstallation,
67
71
  } from "./installations-store.js";
72
+ import {
73
+ dispatchPendingIntegrationTask,
74
+ INTEGRATION_RETRY_SWEEP_TOKEN_SUBJECT,
75
+ integrationDispatchScopeValue,
76
+ isIntegrationDurableDispatchConfigured,
77
+ } from "./integration-durable-dispatch.js";
68
78
  import {
69
79
  forgetIntegrationMemory,
70
80
  integrationMemoryActions,
71
81
  listIntegrationMemory,
72
82
  rememberForIntegrationScope,
73
83
  } from "./integration-memory.js";
84
+ import { extractBearerToken, verifyInternalToken } from "./internal-token.js";
74
85
  import {
75
- extractBearerToken,
76
- signInternalToken,
77
- verifyInternalToken,
78
- } from "./internal-token.js";
79
- import { startPendingTasksRetryJob } from "./pending-tasks-retry-job.js";
86
+ retryStuckPendingTasks,
87
+ startPendingTasksRetryJob,
88
+ } from "./pending-tasks-retry-job.js";
80
89
  import {
81
90
  claimPendingTask,
82
91
  failTaskDeliveryTransition,
83
- getNextPendingTaskIdForThread,
92
+ getNextPendingTaskForThread,
84
93
  insertPendingTask,
85
94
  isDuplicateEventError,
86
95
  MAX_PENDING_TASK_ATTEMPTS,
@@ -725,40 +734,29 @@ export function createIntegrationsPlugin(
725
734
  externalEventKey: opts?.dedupeKey
726
735
  ? systemNoticeEventKey(opts.dedupeKey, dedupeTtlMs)
727
736
  : undefined,
737
+ dispatchScope: integrationDispatchScopeValue({
738
+ platform: incoming.platform,
739
+ externalThreadId: noticeThreadId,
740
+ platformContext: incoming.platformContext,
741
+ }),
728
742
  });
729
743
  } catch (err) {
730
744
  if (isDuplicateEventError(err)) return;
731
745
  throw err;
732
746
  }
733
747
 
734
- // The SQL row is the durable source of truth. This best-effort self-call
735
- // only reduces latency; the recurring pending-task sweep retries a row
736
- // if the serverless host freezes this webhook execution immediately.
737
- let token: string | undefined;
738
- try {
739
- token = signInternalToken(taskId);
740
- } catch (err) {
741
- if (process.env.NODE_ENV === "production") throw err;
742
- }
743
- void fetch(`${getBaseUrl(event)}${P}/process-task`, {
744
- method: "POST",
745
- headers: {
746
- "Content-Type": "application/json",
747
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
748
+ await dispatchPendingIntegrationTask({
749
+ taskId,
750
+ task: {
751
+ platform: incoming.platform,
752
+ externalThreadId: noticeThreadId,
753
+ platformContext: incoming.platformContext,
748
754
  },
749
- body: JSON.stringify({ taskId }),
750
- }).catch((err) => {
751
- console.warn("[integrations] System notice dispatch failed:", err);
755
+ event,
756
+ baseUrl: getBaseUrl(event),
752
757
  });
753
758
  }
754
759
 
755
- async function requireSession(event: any): Promise<boolean> {
756
- const session = await getSession(event).catch(() => null);
757
- if (session?.email) return true;
758
- setResponseStatus(event, 401);
759
- return false;
760
- }
761
-
762
760
  async function requireSessionContext(
763
761
  event: any,
764
762
  ): Promise<{ ownerEmail: string; orgId: string | null } | null> {
@@ -903,9 +901,10 @@ export function createIntegrationsPlugin(
903
901
  setResponseStatus(event, 405);
904
902
  return { error: "Method not allowed" };
905
903
  }
906
- if (!(await requireSession(event))) return { error: "unauthorized" };
904
+ const scope = await requireSessionContext(event);
905
+ if (!scope) return { error: "unauthorized" };
907
906
  try {
908
- return await getTaskQueueStats();
907
+ return await getTaskQueueStats(scope);
909
908
  } catch (err: any) {
910
909
  setResponseStatus(event, 500);
911
910
  return { error: err?.message ?? String(err) };
@@ -1595,10 +1594,53 @@ export function createIntegrationsPlugin(
1595
1594
  }),
1596
1595
  );
1597
1596
 
1597
+ // ─── Durable pending-task recovery sweep ─────────────────────
1598
+ h3.use(
1599
+ `${P}/retry-stuck-tasks`,
1600
+ defineEventHandler(async (event) => {
1601
+ if (getMethod(event) !== "POST") {
1602
+ setResponseStatus(event, 405);
1603
+ return { error: "Method not allowed" };
1604
+ }
1605
+ const body = (await readBody(event)) as {
1606
+ taskId?: string;
1607
+ [AGENT_BACKGROUND_PROCESSOR_FIELD]?: string;
1608
+ };
1609
+ if (body?.taskId !== INTEGRATION_RETRY_SWEEP_TOKEN_SUBJECT) {
1610
+ setResponseStatus(event, 400);
1611
+ return { error: "invalid sweep subject" };
1612
+ }
1613
+ if (!process.env.A2A_SECRET) {
1614
+ setResponseStatus(event, 503);
1615
+ return { error: "durable integration recovery is not configured" };
1616
+ }
1617
+ const token = extractBearerToken(
1618
+ getRequestHeader(event, "authorization"),
1619
+ );
1620
+ if (
1621
+ !token ||
1622
+ !verifyInternalToken(INTEGRATION_RETRY_SWEEP_TOKEN_SUBJECT, token)
1623
+ ) {
1624
+ setResponseStatus(event, 401);
1625
+ return { error: "Invalid or expired internal token" };
1626
+ }
1627
+ if (!isIntegrationDurableDispatchConfigured()) {
1628
+ return { ok: true, disabled: true };
1629
+ }
1630
+ const result = await retryStuckPendingTasks({
1631
+ webhookBaseUrl: getBaseUrl(event),
1632
+ limit: 20,
1633
+ durableOnly: true,
1634
+ });
1635
+ return { ok: true, ...result };
1636
+ }),
1637
+ );
1638
+
1598
1639
  // ─── Process pending task (cross-platform task queue) ────────
1599
1640
  // POST /_agent-native/integrations/process-task
1600
- // Internal endpoint invoked via fire-and-forget self-webhook from the
1601
- // public webhook handler. Auth: HMAC bearer signed with A2A_SECRET.
1641
+ // Internal endpoint invoked from the public webhook handler through either
1642
+ // the portable self-dispatch path or an acknowledged background handoff.
1643
+ // Auth: HMAC bearer signed with A2A_SECRET.
1602
1644
  // Each invocation runs the agent loop in a fresh function execution.
1603
1645
  h3.use(
1604
1646
  `${P}/process-task`,
@@ -1608,7 +1650,10 @@ export function createIntegrationsPlugin(
1608
1650
  return { error: "Method not allowed" };
1609
1651
  }
1610
1652
 
1611
- const body = (await readBody(event)) as { taskId?: string };
1653
+ const body = (await readBody(event)) as {
1654
+ taskId?: string;
1655
+ [AGENT_BACKGROUND_PROCESSOR_FIELD]?: string;
1656
+ };
1612
1657
  const taskId = body?.taskId;
1613
1658
  if (!taskId) {
1614
1659
  setResponseStatus(event, 400);
@@ -1645,7 +1690,12 @@ export function createIntegrationsPlugin(
1645
1690
  }
1646
1691
 
1647
1692
  // Atomic claim: only one invocation gets to process this task
1648
- const task = await claimPendingTask(taskId);
1693
+ const dispatchOutcome =
1694
+ body[AGENT_BACKGROUND_PROCESSOR_FIELD] ===
1695
+ AGENT_BACKGROUND_PROCESSOR_INTEGRATION
1696
+ ? "background-acknowledged"
1697
+ : "portable-unconfirmed";
1698
+ const task = await claimPendingTask(taskId, { dispatchOutcome });
1649
1699
  if (!task) {
1650
1700
  setResponseStatus(event, 200);
1651
1701
  return { ok: true, skipped: "already-claimed-or-missing" };
@@ -1779,24 +1829,22 @@ export function createIntegrationsPlugin(
1779
1829
  return { ok: true, taskId, retrying: "response-delivery" };
1780
1830
  }
1781
1831
  await markTaskCompleted(taskId);
1782
- const nextTaskId = await getNextPendingTaskIdForThread(
1832
+ const nextTask = await getNextPendingTaskForThread(
1783
1833
  task.platform,
1784
1834
  task.externalThreadId,
1785
1835
  );
1786
- if (nextTaskId) {
1787
- const nextToken = signInternalToken(nextTaskId);
1788
- void fetch(`${getBaseUrl(event)}${P}/process-task`, {
1789
- method: "POST",
1790
- headers: {
1791
- "Content-Type": "application/json",
1792
- ...(nextToken ? { Authorization: `Bearer ${nextToken}` } : {}),
1836
+ if (nextTask) {
1837
+ await dispatchPendingIntegrationTask({
1838
+ taskId: nextTask.id,
1839
+ task: {
1840
+ platform: task.platform,
1841
+ externalThreadId: task.externalThreadId,
1842
+ platformContext: nextTask.dispatchScope
1843
+ ? { channelId: nextTask.dispatchScope }
1844
+ : undefined,
1793
1845
  },
1794
- body: JSON.stringify({ taskId: nextTaskId }),
1795
- }).catch((err) => {
1796
- console.error(
1797
- "[integrations] Failed to dispatch queued thread turn:",
1798
- err,
1799
- );
1846
+ event,
1847
+ baseUrl: getBaseUrl(event),
1800
1848
  });
1801
1849
  }
1802
1850
  await processDueA2AContinuations({
@@ -2018,18 +2066,21 @@ export function createIntegrationsPlugin(
2018
2066
  ownerEmail: control.ownerEmail,
2019
2067
  orgId: control.orgId,
2020
2068
  externalEventKey: `control:${control.id}`,
2069
+ dispatchScope: integrationDispatchScopeValue({
2070
+ platform: incoming.platform,
2071
+ externalThreadId: incoming.externalThreadId,
2072
+ platformContext: incoming.platformContext,
2073
+ }),
2021
2074
  });
2022
- const processUrl = `${getBaseUrl(event)}${P}/process-task`;
2023
- const token = signInternalToken(taskId);
2024
- void fetch(processUrl, {
2025
- method: "POST",
2026
- headers: {
2027
- "Content-Type": "application/json",
2028
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
2075
+ await dispatchPendingIntegrationTask({
2076
+ taskId,
2077
+ task: {
2078
+ platform: incoming.platform,
2079
+ externalThreadId: incoming.externalThreadId,
2080
+ platformContext: incoming.platformContext,
2029
2081
  },
2030
- body: JSON.stringify({ taskId }),
2031
- }).catch((err) => {
2032
- console.error("[slack] Approval dispatch failed:", err);
2082
+ event,
2083
+ baseUrl: getBaseUrl(event),
2033
2084
  });
2034
2085
  } catch (err) {
2035
2086
  console.error("[slack] Interaction handling failed:", err);
@@ -2480,6 +2531,8 @@ export function createIntegrationsPlugin(
2480
2531
  if (parts[0] === "remote") return;
2481
2532
  // Already handled by the dedicated /process-task route above
2482
2533
  if (parts[0] === "process-task") return;
2534
+ // Already handled by the signed durable recovery route above
2535
+ if (parts[0] === "retry-stuck-tasks") return;
2483
2536
  // Already handled by the dedicated /process-a2a-continuation route above
2484
2537
  if (parts[0] === "process-a2a-continuation") return;
2485
2538
  // These are framework-owned control-plane routes, not integration
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * Lives in its own file so it stays out of `pending-tasks-store.ts`, which is
5
5
  * actively being edited by the agent that owns the queue itself. These
6
- * helpers only SELECT they never write and they degrade gracefully if
7
- * the `integration_pending_tasks` table doesn't exist yet (returning zeroed
8
- * stats instead of throwing).
6
+ * Queue reads never expose payloads or user text. The helper first runs the
7
+ * additive schema guard so older deployments gain the dispatch diagnostic
8
+ * columns before the SELECTs execute.
9
9
  */
10
10
  import { getDbExec } from "../db/client.js";
11
+ import { ensurePendingTasksTable } from "./pending-tasks-store.js";
11
12
 
12
13
  export interface RecentFailure {
13
14
  id: string;
@@ -23,6 +24,20 @@ export interface TaskQueueStats {
23
24
  failed_last_hour: number;
24
25
  oldest_pending_age_seconds: number;
25
26
  recent_failures: RecentFailure[];
27
+ recent_tasks: Array<{
28
+ id: string;
29
+ platform: string;
30
+ status: string;
31
+ attempts: number;
32
+ dispatch_attempts: number;
33
+ last_dispatch_outcome: string | null;
34
+ age_seconds: number;
35
+ }>;
36
+ }
37
+
38
+ export interface TaskQueueStatsScope {
39
+ ownerEmail: string;
40
+ orgId: string | null;
26
41
  }
27
42
 
28
43
  const ZERO_STATS: TaskQueueStats = {
@@ -32,6 +47,7 @@ const ZERO_STATS: TaskQueueStats = {
32
47
  failed_last_hour: 0,
33
48
  oldest_pending_age_seconds: 0,
34
49
  recent_failures: [],
50
+ recent_tasks: [],
35
51
  };
36
52
 
37
53
  function isMissingTableError(err: unknown): boolean {
@@ -44,21 +60,27 @@ function isMissingTableError(err: unknown): boolean {
44
60
  /**
45
61
  * Get a snapshot of the integration task queue health.
46
62
  *
47
- * Returns zeros if the table doesn't exist yet safe to call before the
48
- * pending-tasks store has initialised the schema.
63
+ * Safe to call before the pending-tasks store has initialized the schema.
49
64
  */
50
- export async function getTaskQueueStats(): Promise<TaskQueueStats> {
65
+ export async function getTaskQueueStats(
66
+ scope: TaskQueueStatsScope,
67
+ ): Promise<TaskQueueStats> {
68
+ await ensurePendingTasksTable();
51
69
  const client = getDbExec();
52
70
  const now = Date.now();
53
71
  const oneHourAgo = now - 60 * 60 * 1000;
72
+ const scopeSql = `owner_email = ?
73
+ AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)`;
74
+ const scopeArgs = [scope.ownerEmail, scope.orgId, scope.orgId];
54
75
 
55
76
  try {
56
77
  // Status counts (pending, processing) — only need the live ones.
57
78
  const liveCounts = await client.execute({
58
79
  sql: `SELECT status, COUNT(*) AS c FROM integration_pending_tasks
59
- WHERE status IN ('pending', 'processing')
80
+ WHERE ${scopeSql}
81
+ AND status IN ('pending', 'processing')
60
82
  GROUP BY status`,
61
- args: [],
83
+ args: scopeArgs,
62
84
  });
63
85
 
64
86
  let pending = 0;
@@ -75,9 +97,10 @@ export async function getTaskQueueStats(): Promise<TaskQueueStats> {
75
97
  // miss tasks queued >1h ago that just finished now.
76
98
  const lastHourCounts = await client.execute({
77
99
  sql: `SELECT status, COUNT(*) AS c FROM integration_pending_tasks
78
- WHERE status IN ('completed', 'failed') AND updated_at >= ?
100
+ WHERE ${scopeSql}
101
+ AND status IN ('completed', 'failed') AND updated_at >= ?
79
102
  GROUP BY status`,
80
- args: [oneHourAgo],
103
+ args: [...scopeArgs, oneHourAgo],
81
104
  });
82
105
 
83
106
  let completedLastHour = 0;
@@ -94,10 +117,11 @@ export async function getTaskQueueStats(): Promise<TaskQueueStats> {
94
117
  if (pending > 0) {
95
118
  const oldest = await client.execute({
96
119
  sql: `SELECT created_at FROM integration_pending_tasks
97
- WHERE status = 'pending'
120
+ WHERE ${scopeSql}
121
+ AND status = 'pending'
98
122
  ORDER BY created_at ASC
99
123
  LIMIT 1`,
100
- args: [],
124
+ args: scopeArgs,
101
125
  });
102
126
  const oldestRow = oldest.rows[0] as Record<string, unknown> | undefined;
103
127
  if (oldestRow) {
@@ -113,10 +137,11 @@ export async function getTaskQueueStats(): Promise<TaskQueueStats> {
113
137
  // blowing up the response payload.
114
138
  const failures = await client.execute({
115
139
  sql: `SELECT id, platform, error_message, attempts FROM integration_pending_tasks
116
- WHERE status = 'failed' AND updated_at >= ?
140
+ WHERE ${scopeSql}
141
+ AND status = 'failed' AND updated_at >= ?
117
142
  ORDER BY updated_at DESC
118
143
  LIMIT 5`,
119
- args: [oneHourAgo],
144
+ args: [...scopeArgs, oneHourAgo],
120
145
  });
121
146
  const recentFailures: RecentFailure[] = (
122
147
  failures.rows as Array<Record<string, unknown>>
@@ -127,6 +152,33 @@ export async function getTaskQueueStats(): Promise<TaskQueueStats> {
127
152
  attempts: Number(row.attempts ?? 0),
128
153
  }));
129
154
 
155
+ const recent = await client.execute({
156
+ sql: `SELECT id, platform, status, attempts, dispatch_attempts,
157
+ last_dispatch_outcome, created_at
158
+ FROM integration_pending_tasks
159
+ WHERE ${scopeSql}
160
+ ORDER BY created_at DESC
161
+ LIMIT 10`,
162
+ args: scopeArgs,
163
+ });
164
+ const recentTasks = (recent.rows as Array<Record<string, unknown>>).map(
165
+ (row) => ({
166
+ id: String(row.id ?? ""),
167
+ platform: String(row.platform ?? ""),
168
+ status: String(row.status ?? ""),
169
+ attempts: Number(row.attempts ?? 0),
170
+ dispatch_attempts: Number(row.dispatch_attempts ?? 0),
171
+ last_dispatch_outcome:
172
+ row.last_dispatch_outcome == null
173
+ ? null
174
+ : String(row.last_dispatch_outcome),
175
+ age_seconds: Math.max(
176
+ 0,
177
+ Math.floor((now - Number(row.created_at ?? now)) / 1000),
178
+ ),
179
+ }),
180
+ );
181
+
130
182
  return {
131
183
  pending,
132
184
  processing,
@@ -134,10 +186,11 @@ export async function getTaskQueueStats(): Promise<TaskQueueStats> {
134
186
  failed_last_hour: failedLastHour,
135
187
  oldest_pending_age_seconds: oldestPendingAgeSeconds,
136
188
  recent_failures: recentFailures,
189
+ recent_tasks: recentTasks,
137
190
  };
138
191
  } catch (err) {
139
192
  if (isMissingTableError(err)) {
140
- return { ...ZERO_STATS, recent_failures: [] };
193
+ return { ...ZERO_STATS, recent_failures: [], recent_tasks: [] };
141
194
  }
142
195
  throw err;
143
196
  }