@agent-native/core 0.98.5 → 0.98.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +18 -0
- package/corpus/core/docs/content/external-agents.mdx +66 -7
- package/corpus/core/docs/content/locales/ar-SA/external-agents.mdx +40 -7
- package/corpus/core/docs/content/locales/de-DE/external-agents.mdx +41 -7
- package/corpus/core/docs/content/locales/es-ES/external-agents.mdx +41 -7
- package/corpus/core/docs/content/locales/fr-FR/external-agents.mdx +41 -7
- package/corpus/core/docs/content/locales/hi-IN/external-agents.mdx +40 -7
- package/corpus/core/docs/content/locales/ja-JP/external-agents.mdx +40 -7
- package/corpus/core/docs/content/locales/ko-KR/external-agents.mdx +40 -7
- package/corpus/core/docs/content/locales/pt-BR/external-agents.mdx +41 -7
- package/corpus/core/docs/content/locales/zh-CN/external-agents.mdx +39 -7
- package/corpus/core/docs/content/locales/zh-TW/external-agents.mdx +39 -7
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/handlers.ts +101 -26
- package/corpus/core/src/a2a/task-store.ts +128 -3
- package/corpus/core/src/client/session-replay.ts +67 -5
- package/corpus/core/src/integrations/adapters/slack.ts +207 -17
- package/corpus/core/src/integrations/identity-links-store.ts +210 -0
- package/corpus/core/src/integrations/identity.ts +196 -0
- package/corpus/core/src/integrations/index.ts +6 -0
- package/corpus/core/src/integrations/plugin.ts +130 -1
- package/corpus/core/src/integrations/types.ts +37 -0
- package/corpus/core/src/integrations/webhook-handler.ts +1 -0
- package/corpus/core/src/mcp/build-server.ts +127 -22
- package/corpus/core/src/mcp/builtin-tools.ts +5 -2
- package/corpus/core/src/mcp/external-agent-policy.ts +18 -0
- package/corpus/core/src/mcp/index.ts +1 -0
- package/corpus/core/src/server/agent-chat/plugin-options.ts +12 -1
- package/corpus/core/src/server/agent-chat/script-entries.ts +20 -3
- package/corpus/core/src/server/agent-chat-plugin.ts +3 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +4 -1
- package/corpus/templates/analytics/AGENTS.md +20 -5
- package/corpus/templates/analytics/actions/create-session-replay-agent-link.ts +0 -1
- package/corpus/templates/analytics/actions/get-session-replay-events.ts +0 -1
- package/corpus/templates/analytics/actions/get-session-replay-timeline.ts +40 -0
- package/corpus/templates/analytics/actions/list-error-issues.ts +12 -0
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +4 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +48 -17
- package/corpus/templates/analytics/changelog/2026-07-12-analytics-uses-the-full-in-app-agent-for-multi-step-incident.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-connected-external-agents-can-now-look-up-sessions-error-iss.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-session-identities-stay-visible-when-demo-mode-is-off.md +6 -0
- package/corpus/templates/analytics/server/handlers/session-replay.ts +15 -9
- package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +2 -2
- package/corpus/templates/analytics/server/lib/error-capture.ts +47 -5
- package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +72 -22
- package/corpus/templates/analytics/server/lib/session-replay.ts +41 -11
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +7 -0
- package/corpus/templates/analytics/server/plugins/db.ts +15 -0
- package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +8 -5
- package/corpus/templates/analytics/server/routes/api/session-replay/agent-events.json.get.ts +8 -3
- package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +12 -2
- package/corpus/templates/forms/app/global.css +54 -8
- package/corpus/templates/forms/app/pages/AskPage.tsx +35 -13
- package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +11 -17
- package/corpus/templates/forms/app/pages/ResponsesPage.tsx +8 -5
- package/corpus/templates/forms/changelog/2026-07-12-ask-forms-suggestions-now-sit-beneath-the-composer-for-a-tig.md +6 -0
- package/corpus/templates/forms/changelog/2026-07-12-builder-response-tables-now-fill-the-view-without-a-redundan.md +6 -0
- package/corpus/templates/forms/changelog/2026-07-12-response-tables-now-fill-the-available-pane-with-a-flexible-.md +6 -0
- package/corpus/templates/mail/AGENTS.md +3 -1
- package/corpus/templates/mail/server/lib/mail-integrations.ts +3 -0
- package/dist/a2a/handlers.d.ts.map +1 -1
- package/dist/a2a/handlers.js +89 -20
- package/dist/a2a/handlers.js.map +1 -1
- package/dist/a2a/task-store.d.ts +32 -1
- package/dist/a2a/task-store.d.ts.map +1 -1
- package/dist/a2a/task-store.js +111 -4
- package/dist/a2a/task-store.js.map +1 -1
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +53 -8
- package/dist/client/session-replay.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/integrations/adapters/slack.d.ts.map +1 -1
- package/dist/integrations/adapters/slack.js +176 -17
- package/dist/integrations/adapters/slack.js.map +1 -1
- package/dist/integrations/identity-links-store.d.ts +23 -0
- package/dist/integrations/identity-links-store.d.ts.map +1 -0
- package/dist/integrations/identity-links-store.js +158 -0
- package/dist/integrations/identity-links-store.js.map +1 -0
- package/dist/integrations/identity.d.ts +28 -0
- package/dist/integrations/identity.d.ts.map +1 -0
- package/dist/integrations/identity.js +142 -0
- package/dist/integrations/identity.js.map +1 -0
- package/dist/integrations/index.d.ts +2 -0
- package/dist/integrations/index.d.ts.map +1 -1
- package/dist/integrations/index.js +2 -0
- package/dist/integrations/index.js.map +1 -1
- package/dist/integrations/plugin.d.ts.map +1 -1
- package/dist/integrations/plugin.js +93 -1
- package/dist/integrations/plugin.js.map +1 -1
- package/dist/integrations/types.d.ts +31 -0
- package/dist/integrations/types.d.ts.map +1 -1
- package/dist/integrations/types.js.map +1 -1
- package/dist/integrations/webhook-handler.js +1 -0
- package/dist/integrations/webhook-handler.js.map +1 -1
- package/dist/mcp/build-server.d.ts +9 -2
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +97 -20
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/mcp/builtin-tools.d.ts.map +1 -1
- package/dist/mcp/builtin-tools.js +5 -2
- package/dist/mcp/builtin-tools.js.map +1 -1
- package/dist/mcp/external-agent-policy.d.ts +19 -0
- package/dist/mcp/external-agent-policy.d.ts.map +1 -0
- package/dist/mcp/external-agent-policy.js +2 -0
- package/dist/mcp/external-agent-policy.js.map +1 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/server/agent-chat/plugin-options.d.ts +11 -1
- package/dist/server/agent-chat/plugin-options.d.ts.map +1 -1
- package/dist/server/agent-chat/plugin-options.js.map +1 -1
- package/dist/server/agent-chat/script-entries.d.ts.map +1 -1
- package/dist/server/agent-chat/script-entries.js +16 -2
- package/dist/server/agent-chat/script-entries.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +3 -0
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
- package/docs/content/external-agents.mdx +66 -7
- package/docs/content/locales/ar-SA/external-agents.mdx +40 -7
- package/docs/content/locales/de-DE/external-agents.mdx +41 -7
- package/docs/content/locales/es-ES/external-agents.mdx +41 -7
- package/docs/content/locales/fr-FR/external-agents.mdx +41 -7
- package/docs/content/locales/hi-IN/external-agents.mdx +40 -7
- package/docs/content/locales/ja-JP/external-agents.mdx +40 -7
- package/docs/content/locales/ko-KR/external-agents.mdx +40 -7
- package/docs/content/locales/pt-BR/external-agents.mdx +41 -7
- package/docs/content/locales/zh-CN/external-agents.mdx +39 -7
- package/docs/content/locales/zh-TW/external-agents.mdx +39 -7
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
|
@@ -200,11 +200,12 @@ export async function getA2ATaskDispatchState(id: string): Promise<{
|
|
|
200
200
|
statusState: string;
|
|
201
201
|
metadata: Record<string, unknown> | undefined;
|
|
202
202
|
updatedAt: number;
|
|
203
|
+
createdAt: number;
|
|
203
204
|
} | null> {
|
|
204
205
|
await ensureTable();
|
|
205
206
|
const client = getDbExec();
|
|
206
207
|
const { rows } = await client.execute({
|
|
207
|
-
sql: `SELECT id, status_state, metadata, updated_at FROM a2a_tasks WHERE id = ?`,
|
|
208
|
+
sql: `SELECT id, status_state, metadata, created_at, updated_at FROM a2a_tasks WHERE id = ?`,
|
|
208
209
|
args: [id],
|
|
209
210
|
});
|
|
210
211
|
const row = rows[0] as any;
|
|
@@ -214,6 +215,7 @@ export async function getA2ATaskDispatchState(id: string): Promise<{
|
|
|
214
215
|
statusState: row.status_state as string,
|
|
215
216
|
metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
|
|
216
217
|
updatedAt: Number(row.updated_at ?? 0),
|
|
218
|
+
createdAt: Number(row.created_at ?? 0),
|
|
217
219
|
};
|
|
218
220
|
}
|
|
219
221
|
|
|
@@ -269,10 +271,21 @@ export async function resetStuckA2ATaskForRetry(
|
|
|
269
271
|
return affected !== 0;
|
|
270
272
|
}
|
|
271
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Fail a processing task once it is stuck. Two independent conditions can
|
|
276
|
+
* trigger this, either of which alone is sufficient:
|
|
277
|
+
* - `updated_at <= processingCutoff`: no heartbeat/progress touch in a
|
|
278
|
+
* while — the processor likely died.
|
|
279
|
+
* - `created_at <= createdAtCutoff` — a hard wall on total run time. A
|
|
280
|
+
* hung await inside a still-alive process keeps `updated_at` fresh via
|
|
281
|
+
* the liveness heartbeat forever, so staleness alone never trips; age
|
|
282
|
+
* since creation is the only bound that catches it.
|
|
283
|
+
*/
|
|
272
284
|
export async function failStuckA2ATask(
|
|
273
285
|
id: string,
|
|
274
286
|
processingCutoff: number,
|
|
275
287
|
reason: string,
|
|
288
|
+
createdAtCutoff?: number,
|
|
276
289
|
): Promise<boolean> {
|
|
277
290
|
await ensureTable();
|
|
278
291
|
const client = getDbExec();
|
|
@@ -282,6 +295,10 @@ export async function failStuckA2ATask(
|
|
|
282
295
|
role: "agent",
|
|
283
296
|
parts: [{ type: "text", text: reason }],
|
|
284
297
|
};
|
|
298
|
+
const ageCondition =
|
|
299
|
+
createdAtCutoff !== undefined
|
|
300
|
+
? `AND (updated_at <= ? OR created_at <= ?)`
|
|
301
|
+
: `AND updated_at <= ?`;
|
|
285
302
|
const result = await client.execute({
|
|
286
303
|
sql: `UPDATE a2a_tasks
|
|
287
304
|
SET status_state = 'failed',
|
|
@@ -290,8 +307,53 @@ export async function failStuckA2ATask(
|
|
|
290
307
|
updated_at = ?
|
|
291
308
|
WHERE id = ?
|
|
292
309
|
AND status_state = 'processing'
|
|
293
|
-
|
|
294
|
-
args:
|
|
310
|
+
${ageCondition}`,
|
|
311
|
+
args:
|
|
312
|
+
createdAtCutoff !== undefined
|
|
313
|
+
? [
|
|
314
|
+
JSON.stringify(message),
|
|
315
|
+
timestamp,
|
|
316
|
+
now,
|
|
317
|
+
id,
|
|
318
|
+
processingCutoff,
|
|
319
|
+
createdAtCutoff,
|
|
320
|
+
]
|
|
321
|
+
: [JSON.stringify(message), timestamp, now, id, processingCutoff],
|
|
322
|
+
});
|
|
323
|
+
const affected = getAffectedRowCount(result);
|
|
324
|
+
return affected !== 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Fail a queued (submitted/working) task whose age since creation exceeds
|
|
329
|
+
* `createdAtCutoff` — the dispatch-retry loop kept throttling/refiring
|
|
330
|
+
* without ever reaching `processing`. Mirrors `failStuckA2ATask` but is
|
|
331
|
+
* gated on the queued state set and on `created_at` (queued tasks have no
|
|
332
|
+
* heartbeat, so staleness of `updated_at` isn't a meaningful signal here).
|
|
333
|
+
*/
|
|
334
|
+
export async function failStuckQueuedA2ATask(
|
|
335
|
+
id: string,
|
|
336
|
+
createdAtCutoff: number,
|
|
337
|
+
reason: string,
|
|
338
|
+
): Promise<boolean> {
|
|
339
|
+
await ensureTable();
|
|
340
|
+
const client = getDbExec();
|
|
341
|
+
const now = Date.now();
|
|
342
|
+
const timestamp = new Date().toISOString();
|
|
343
|
+
const message: Message = {
|
|
344
|
+
role: "agent",
|
|
345
|
+
parts: [{ type: "text", text: reason }],
|
|
346
|
+
};
|
|
347
|
+
const result = await client.execute({
|
|
348
|
+
sql: `UPDATE a2a_tasks
|
|
349
|
+
SET status_state = 'failed',
|
|
350
|
+
status_message = ?,
|
|
351
|
+
status_timestamp = ?,
|
|
352
|
+
updated_at = ?
|
|
353
|
+
WHERE id = ?
|
|
354
|
+
AND status_state IN ('submitted', 'working')
|
|
355
|
+
AND created_at <= ?`,
|
|
356
|
+
args: [JSON.stringify(message), timestamp, now, id, createdAtCutoff],
|
|
295
357
|
});
|
|
296
358
|
const affected = getAffectedRowCount(result);
|
|
297
359
|
return affected !== 0;
|
|
@@ -361,6 +423,69 @@ export async function updateTask(
|
|
|
361
423
|
return task;
|
|
362
424
|
}
|
|
363
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Persist the terminal result produced by the async processor, but only while
|
|
428
|
+
* that processor still owns a task in `processing`. A tasks/get request may
|
|
429
|
+
* fail an over-lifetime processor while its handler is still running; the
|
|
430
|
+
* handler cannot be canceled reliably, so this compare-and-set is what keeps
|
|
431
|
+
* its eventual completion (or error) from overwriting the timeout result.
|
|
432
|
+
*/
|
|
433
|
+
export async function settleProcessingA2ATask(
|
|
434
|
+
id: string,
|
|
435
|
+
update: {
|
|
436
|
+
state: "completed" | "failed";
|
|
437
|
+
message?: Message;
|
|
438
|
+
artifacts?: Artifact[];
|
|
439
|
+
},
|
|
440
|
+
): Promise<Task | null> {
|
|
441
|
+
await ensureTable();
|
|
442
|
+
const client = getDbExec();
|
|
443
|
+
|
|
444
|
+
const { rows } = await client.execute({
|
|
445
|
+
sql: `SELECT * FROM a2a_tasks WHERE id = ?`,
|
|
446
|
+
args: [id],
|
|
447
|
+
});
|
|
448
|
+
if (rows.length === 0) return null;
|
|
449
|
+
|
|
450
|
+
const task = taskFromRow(rows[0]);
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
task.status = {
|
|
453
|
+
state: update.state,
|
|
454
|
+
message: update.message ?? task.status.message,
|
|
455
|
+
timestamp: new Date().toISOString(),
|
|
456
|
+
};
|
|
457
|
+
if (update.message && task.history) {
|
|
458
|
+
task.history.push(update.message);
|
|
459
|
+
}
|
|
460
|
+
if (update.artifacts) {
|
|
461
|
+
task.artifacts = [...(task.artifacts ?? []), ...update.artifacts];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const result = await client.execute({
|
|
465
|
+
sql: `UPDATE a2a_tasks
|
|
466
|
+
SET status_state = ?,
|
|
467
|
+
status_message = ?,
|
|
468
|
+
status_timestamp = ?,
|
|
469
|
+
history = ?,
|
|
470
|
+
artifacts = ?,
|
|
471
|
+
updated_at = ?
|
|
472
|
+
WHERE id = ?
|
|
473
|
+
AND status_state = 'processing'`,
|
|
474
|
+
args: [
|
|
475
|
+
task.status.state,
|
|
476
|
+
task.status.message ? JSON.stringify(task.status.message) : null,
|
|
477
|
+
task.status.timestamp,
|
|
478
|
+
JSON.stringify(task.history),
|
|
479
|
+
JSON.stringify(task.artifacts),
|
|
480
|
+
now,
|
|
481
|
+
id,
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
const affected = getAffectedRowCount(result);
|
|
485
|
+
if (affected === 0) return null;
|
|
486
|
+
return task;
|
|
487
|
+
}
|
|
488
|
+
|
|
364
489
|
export async function updateTaskStatusMessage(
|
|
365
490
|
id: string,
|
|
366
491
|
message: Message,
|
|
@@ -65,6 +65,8 @@ interface RrwebRecordModule {
|
|
|
65
65
|
interface SessionReplayState {
|
|
66
66
|
active: boolean;
|
|
67
67
|
startPromise: Promise<SessionReplayStartResult> | null;
|
|
68
|
+
/** Invalidates deferred recorder startup when stop is requested mid-start. */
|
|
69
|
+
startGeneration: number;
|
|
68
70
|
replayId: string | null;
|
|
69
71
|
startedAtMs: number | null;
|
|
70
72
|
sequence: number;
|
|
@@ -432,6 +434,7 @@ function getState(): SessionReplayState {
|
|
|
432
434
|
g[SESSION_REPLAY_STATE_KEY] = {
|
|
433
435
|
active: false,
|
|
434
436
|
startPromise: null,
|
|
437
|
+
startGeneration: 0,
|
|
435
438
|
replayId: null,
|
|
436
439
|
startedAtMs: null,
|
|
437
440
|
sequence: 0,
|
|
@@ -464,6 +467,7 @@ function getState(): SessionReplayState {
|
|
|
464
467
|
state.pendingFlushReason ??= null;
|
|
465
468
|
state.pendingFlushWaiters ??= [];
|
|
466
469
|
state.awaitingFullSnapshot ??= false;
|
|
470
|
+
state.startGeneration ??= 0;
|
|
467
471
|
return state;
|
|
468
472
|
}
|
|
469
473
|
|
|
@@ -1054,12 +1058,14 @@ const REPLAY_RESOURCE_TAGS = new Set([
|
|
|
1054
1058
|
"audio",
|
|
1055
1059
|
"track",
|
|
1056
1060
|
"input",
|
|
1061
|
+
"object",
|
|
1057
1062
|
"link",
|
|
1058
1063
|
]);
|
|
1059
1064
|
const NO_REPLAY_RESOURCE_ATTRIBUTES = new Set<string>();
|
|
1060
1065
|
const REPLAY_SRC_ATTRIBUTES = new Set(["src"]);
|
|
1061
1066
|
const REPLAY_SRCSET_ATTRIBUTES = new Set(["src", "srcset"]);
|
|
1062
1067
|
const REPLAY_VIDEO_ATTRIBUTES = new Set(["src", "poster"]);
|
|
1068
|
+
const REPLAY_OBJECT_ATTRIBUTES = new Set(["data"]);
|
|
1063
1069
|
const REPLAY_HREF_ATTRIBUTES = new Set(["href"]);
|
|
1064
1070
|
|
|
1065
1071
|
function replayAttributeString(
|
|
@@ -1101,6 +1107,8 @@ function replayPreservedResourceAttributes(
|
|
|
1101
1107
|
case "audio":
|
|
1102
1108
|
case "track":
|
|
1103
1109
|
return REPLAY_SRC_ATTRIBUTES;
|
|
1110
|
+
case "object":
|
|
1111
|
+
return REPLAY_OBJECT_ATTRIBUTES;
|
|
1104
1112
|
case "input":
|
|
1105
1113
|
return node.type === "image"
|
|
1106
1114
|
? REPLAY_SRC_ATTRIBUTES
|
|
@@ -1452,12 +1460,12 @@ class ReplayUploadHttpError extends Error {
|
|
|
1452
1460
|
}
|
|
1453
1461
|
}
|
|
1454
1462
|
|
|
1455
|
-
/** 4xx statuses where retrying the exact same batch can never succeed
|
|
1456
|
-
*
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1463
|
+
/** 4xx statuses where retrying the exact same batch can never succeed.
|
|
1464
|
+
* Keep this deliberately narrow: 401/403/404 can be temporary during auth or
|
|
1465
|
+
* deploy transitions, and stopping forever on one of those would silently
|
|
1466
|
+
* black out the rest of a long-lived SPA session. */
|
|
1459
1467
|
function isDefinitiveReplayUploadClientError(status: number): boolean {
|
|
1460
|
-
return status
|
|
1468
|
+
return status === 400 || status === 409 || status === 413 || status === 422;
|
|
1461
1469
|
}
|
|
1462
1470
|
|
|
1463
1471
|
async function sendReplayUpload(
|
|
@@ -1903,6 +1911,31 @@ async function restartSessionReplayAfterConflict(
|
|
|
1903
1911
|
state: SessionReplayState,
|
|
1904
1912
|
options: NormalizedSessionReplayOptions,
|
|
1905
1913
|
sessionId: string,
|
|
1914
|
+
): Promise<SessionReplayStartResult> {
|
|
1915
|
+
// Share the public-start mutex. A consumer may call start in the await gap
|
|
1916
|
+
// after the rejected recorder stops; both paths must converge on one rrweb
|
|
1917
|
+
// instance rather than racing two fresh identities.
|
|
1918
|
+
if (state.startPromise) return state.startPromise;
|
|
1919
|
+
|
|
1920
|
+
const startGeneration = ++state.startGeneration;
|
|
1921
|
+
let startPromise: Promise<SessionReplayStartResult>;
|
|
1922
|
+
startPromise = restartSessionReplayAfterConflictInternal(
|
|
1923
|
+
state,
|
|
1924
|
+
options,
|
|
1925
|
+
sessionId,
|
|
1926
|
+
startGeneration,
|
|
1927
|
+
).finally(() => {
|
|
1928
|
+
if (state.startPromise === startPromise) state.startPromise = null;
|
|
1929
|
+
});
|
|
1930
|
+
state.startPromise = startPromise;
|
|
1931
|
+
return startPromise;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
async function restartSessionReplayAfterConflictInternal(
|
|
1935
|
+
state: SessionReplayState,
|
|
1936
|
+
options: NormalizedSessionReplayOptions,
|
|
1937
|
+
sessionId: string,
|
|
1938
|
+
startGeneration: number,
|
|
1906
1939
|
): Promise<SessionReplayStartResult> {
|
|
1907
1940
|
if (options.shouldStart && !options.shouldStart()) {
|
|
1908
1941
|
return { started: false, reason: "disabled", sessionId, sampled: true };
|
|
@@ -1932,6 +1965,7 @@ async function restartSessionReplayAfterConflict(
|
|
|
1932
1965
|
sessionId,
|
|
1933
1966
|
true,
|
|
1934
1967
|
initialProperties,
|
|
1968
|
+
startGeneration,
|
|
1935
1969
|
);
|
|
1936
1970
|
}
|
|
1937
1971
|
|
|
@@ -2879,6 +2913,11 @@ export async function startSessionReplay(
|
|
|
2879
2913
|
}
|
|
2880
2914
|
if (state.startPromise) return state.startPromise;
|
|
2881
2915
|
|
|
2916
|
+
// This is a new caller-initiated recording episode. A prior episode's
|
|
2917
|
+
// conflict-loop guard must not prevent this one from recovering once.
|
|
2918
|
+
state.automaticConflictRestartAttempted = false;
|
|
2919
|
+
const startGeneration = ++state.startGeneration;
|
|
2920
|
+
|
|
2882
2921
|
let startPromise: Promise<SessionReplayStartResult>;
|
|
2883
2922
|
startPromise = startSessionReplayRecorder(
|
|
2884
2923
|
state,
|
|
@@ -2886,6 +2925,7 @@ export async function startSessionReplay(
|
|
|
2886
2925
|
sessionId,
|
|
2887
2926
|
sampled,
|
|
2888
2927
|
initialProperties,
|
|
2928
|
+
startGeneration,
|
|
2889
2929
|
).finally(() => {
|
|
2890
2930
|
if (state.startPromise === startPromise) {
|
|
2891
2931
|
state.startPromise = null;
|
|
@@ -2901,6 +2941,7 @@ async function startSessionReplayRecorder(
|
|
|
2901
2941
|
sessionId: string,
|
|
2902
2942
|
sampled: boolean,
|
|
2903
2943
|
initialProperties: Record<string, unknown> | undefined,
|
|
2944
|
+
startGeneration: number,
|
|
2904
2945
|
): Promise<SessionReplayStartResult> {
|
|
2905
2946
|
if (state.active && state.replayId) {
|
|
2906
2947
|
return {
|
|
@@ -2918,6 +2959,9 @@ async function startSessionReplayRecorder(
|
|
|
2918
2959
|
} catch {
|
|
2919
2960
|
return { started: false, reason: "import-failed", sessionId, sampled };
|
|
2920
2961
|
}
|
|
2962
|
+
if (state.startGeneration !== startGeneration) {
|
|
2963
|
+
return { started: false, reason: "disabled", sessionId, sampled };
|
|
2964
|
+
}
|
|
2921
2965
|
if (normalized.shouldStart && !normalized.shouldStart()) {
|
|
2922
2966
|
return { started: false, reason: "disabled", sessionId, sampled };
|
|
2923
2967
|
}
|
|
@@ -2950,6 +2994,20 @@ async function startSessionReplayRecorder(
|
|
|
2950
2994
|
};
|
|
2951
2995
|
}
|
|
2952
2996
|
}
|
|
2997
|
+
// stopSessionReplay may be called while the duplicate-tab probe is waiting.
|
|
2998
|
+
// Recheck both cancellation and the caller's live eligibility before rrweb
|
|
2999
|
+
// is activated so a deferred start cannot escape a route/auth teardown.
|
|
3000
|
+
if (
|
|
3001
|
+
state.startGeneration !== startGeneration ||
|
|
3002
|
+
(normalized.shouldStart && !normalized.shouldStart())
|
|
3003
|
+
) {
|
|
3004
|
+
try {
|
|
3005
|
+
replayChannel?.close();
|
|
3006
|
+
} catch {
|
|
3007
|
+
// best-effort cleanup
|
|
3008
|
+
}
|
|
3009
|
+
return { started: false, reason: "disabled", sessionId, sampled };
|
|
3010
|
+
}
|
|
2953
3011
|
state.options = normalized;
|
|
2954
3012
|
state.replayId = replaySession.replayId;
|
|
2955
3013
|
state.startedAtMs = replaySession.startedAtMs;
|
|
@@ -3049,6 +3107,10 @@ async function startSessionReplayRecorder(
|
|
|
3049
3107
|
|
|
3050
3108
|
export async function stopSessionReplay(reason = "manual"): Promise<void> {
|
|
3051
3109
|
const state = getState();
|
|
3110
|
+
// Invalidate an import/probe that has not set `active` yet. Without this,
|
|
3111
|
+
// stop during the duplicated-tab claim window was a no-op and rrweb started
|
|
3112
|
+
// after the caller believed recording had been disabled.
|
|
3113
|
+
state.startGeneration += 1;
|
|
3052
3114
|
if (!state.active) return;
|
|
3053
3115
|
// Restore console/fetch/XHR before tearing down the recorder: the restore
|
|
3054
3116
|
// flushes any pending collapsed console duplicate, which must still be able
|
|
@@ -38,6 +38,48 @@ const SLACK_PERMALINK_TIMEOUT_MS = 1_000;
|
|
|
38
38
|
const SLACK_CONTEXT_MESSAGE_LIMIT = 15;
|
|
39
39
|
const SLACK_CONTEXT_TEXT_LIMIT = 2_000;
|
|
40
40
|
const SLACK_CALM_PROGRESS_THRESHOLD_SECONDS = 30;
|
|
41
|
+
const SLACK_IDENTITY_TIMEOUT_MS = 1_000;
|
|
42
|
+
const SLACK_IDENTITY_CACHE_TTL_MS = 10 * 60 * 1_000;
|
|
43
|
+
// Failed users.info lookups only get a short negative TTL: a transient Slack
|
|
44
|
+
// API blip must not fail-close a sender's identity (and DMs) for 10 minutes.
|
|
45
|
+
const SLACK_IDENTITY_NEGATIVE_CACHE_TTL_MS = 30 * 1_000;
|
|
46
|
+
const SLACK_IDENTITY_CACHE_MAX_ENTRIES = 1_000;
|
|
47
|
+
|
|
48
|
+
interface SlackUserIdentity {
|
|
49
|
+
email: string | null;
|
|
50
|
+
name: string | null;
|
|
51
|
+
memberType: "owner" | "admin" | "member" | "guest" | "external" | "unknown";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const slackIdentityCache = new Map<
|
|
55
|
+
string,
|
|
56
|
+
{ identity: SlackUserIdentity | null; expiresAt: number }
|
|
57
|
+
>();
|
|
58
|
+
|
|
59
|
+
// Deduped system notices send at most once per key per TTL so senders are
|
|
60
|
+
// informed without being spammed per message. Callers pick the window: the
|
|
61
|
+
// anonymous-tier heads-up uses the default day-long TTL; decline replies pass
|
|
62
|
+
// a short TTL so a persistent condition still reminds the sender occasionally.
|
|
63
|
+
const SLACK_SYSTEM_NOTICE_DEDUPE_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
64
|
+
const SLACK_SYSTEM_NOTICE_CACHE_MAX_ENTRIES = 1_000;
|
|
65
|
+
const slackSystemNoticeCache = new Map<string, number>();
|
|
66
|
+
|
|
67
|
+
/** Returns true when a deduped notice should send now, claiming the slot. */
|
|
68
|
+
function claimSlackSystemNoticeSlot(
|
|
69
|
+
key: string,
|
|
70
|
+
ttlMs = SLACK_SYSTEM_NOTICE_DEDUPE_TTL_MS,
|
|
71
|
+
): boolean {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const expiresAt = slackSystemNoticeCache.get(key);
|
|
74
|
+
if (expiresAt && expiresAt > now) return false;
|
|
75
|
+
if (expiresAt) slackSystemNoticeCache.delete(key);
|
|
76
|
+
if (slackSystemNoticeCache.size >= SLACK_SYSTEM_NOTICE_CACHE_MAX_ENTRIES) {
|
|
77
|
+
const oldestKey = slackSystemNoticeCache.keys().next().value;
|
|
78
|
+
if (oldestKey) slackSystemNoticeCache.delete(oldestKey);
|
|
79
|
+
}
|
|
80
|
+
slackSystemNoticeCache.set(key, now + ttlMs);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
41
83
|
|
|
42
84
|
export interface SlackAdapterOptions {
|
|
43
85
|
/** Resolve the bot token for the exact Slack installation. */
|
|
@@ -247,7 +289,10 @@ export function slackAdapter(
|
|
|
247
289
|
triggerKind: isDm ? "dm" : isMention ? "mention" : "thread_reply",
|
|
248
290
|
conversationType: isDm ? "dm" : "unknown",
|
|
249
291
|
tenantId: teamId,
|
|
250
|
-
|
|
292
|
+
// The signed Slack envelope authenticates the workspace, not the
|
|
293
|
+
// sender's membership tier. `users.info` hydration below is the
|
|
294
|
+
// authority that may promote this to a verified member.
|
|
295
|
+
actorTrust: { memberType: "unknown", verified: false },
|
|
251
296
|
platformContext: {
|
|
252
297
|
channelId: e.channel,
|
|
253
298
|
channelType: e.channel_type,
|
|
@@ -356,6 +401,14 @@ export function slackAdapter(
|
|
|
356
401
|
return hydrateSlackContext(token, incoming);
|
|
357
402
|
},
|
|
358
403
|
|
|
404
|
+
async hydrateIncomingIdentity(
|
|
405
|
+
incoming: IncomingMessage,
|
|
406
|
+
): Promise<IncomingMessage> {
|
|
407
|
+
const token = await resolveBotToken(incoming);
|
|
408
|
+
if (!token) return incoming;
|
|
409
|
+
return hydrateSlackIdentity(token, incoming);
|
|
410
|
+
},
|
|
411
|
+
|
|
359
412
|
async startRunProgress(
|
|
360
413
|
incoming: IncomingMessage,
|
|
361
414
|
): Promise<PlatformRunProgress | null> {
|
|
@@ -468,6 +521,48 @@ export function slackAdapter(
|
|
|
468
521
|
}
|
|
469
522
|
},
|
|
470
523
|
|
|
524
|
+
async sendSystemNotice(
|
|
525
|
+
incoming: IncomingMessage,
|
|
526
|
+
text: string,
|
|
527
|
+
opts?: { dedupeKey?: string; dedupeTtlMs?: number },
|
|
528
|
+
): Promise<void> {
|
|
529
|
+
if (!text.trim()) return;
|
|
530
|
+
const dedupeKey = opts?.dedupeKey;
|
|
531
|
+
if (
|
|
532
|
+
dedupeKey &&
|
|
533
|
+
!claimSlackSystemNoticeSlot(dedupeKey, opts?.dedupeTtlMs)
|
|
534
|
+
) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
const token = await resolveBotToken(incoming);
|
|
539
|
+
if (!token) {
|
|
540
|
+
if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const channelId = incoming.platformContext.channelId;
|
|
544
|
+
if (typeof channelId !== "string" || !channelId) {
|
|
545
|
+
if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const threadTs =
|
|
549
|
+
typeof incoming.platformContext.threadTs === "string"
|
|
550
|
+
? incoming.platformContext.threadTs
|
|
551
|
+
: undefined;
|
|
552
|
+
await postFresh(token, channelId, threadTs, {
|
|
553
|
+
text,
|
|
554
|
+
unfurl_links: false,
|
|
555
|
+
unfurl_media: false,
|
|
556
|
+
mrkdwn: true,
|
|
557
|
+
});
|
|
558
|
+
} catch (error) {
|
|
559
|
+
// A failed delivery did not inform the sender, so release the slot and
|
|
560
|
+
// allow the next message to retry instead of suppressing notices for a day.
|
|
561
|
+
if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
|
|
471
566
|
async sendMessageToTarget(
|
|
472
567
|
message: OutgoingMessage,
|
|
473
568
|
target: OutboundTarget,
|
|
@@ -1070,6 +1165,102 @@ async function slackJson(
|
|
|
1070
1165
|
}
|
|
1071
1166
|
}
|
|
1072
1167
|
|
|
1168
|
+
function slackIdentityCacheKey(incoming: IncomingMessage): string | null {
|
|
1169
|
+
const teamId = incoming.platformContext.teamId;
|
|
1170
|
+
const senderId = incoming.senderId;
|
|
1171
|
+
if (typeof senderId !== "string" || !senderId.trim()) return null;
|
|
1172
|
+
return `${typeof teamId === "string" && teamId ? teamId : "unknown"}:${senderId}`;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async function resolveSlackUserIdentity(
|
|
1176
|
+
token: string,
|
|
1177
|
+
incoming: IncomingMessage,
|
|
1178
|
+
): Promise<SlackUserIdentity | null> {
|
|
1179
|
+
const cacheKey = slackIdentityCacheKey(incoming);
|
|
1180
|
+
if (!cacheKey) return null;
|
|
1181
|
+
const cached = slackIdentityCache.get(cacheKey);
|
|
1182
|
+
if (cached && cached.expiresAt > Date.now()) return cached.identity;
|
|
1183
|
+
if (cached) slackIdentityCache.delete(cacheKey);
|
|
1184
|
+
|
|
1185
|
+
const user = await slackJson(
|
|
1186
|
+
token,
|
|
1187
|
+
"users.info",
|
|
1188
|
+
{ user: incoming.senderId! },
|
|
1189
|
+
SLACK_IDENTITY_TIMEOUT_MS,
|
|
1190
|
+
);
|
|
1191
|
+
const profile = user?.user?.profile;
|
|
1192
|
+
const identity: SlackUserIdentity | null = user?.user
|
|
1193
|
+
? {
|
|
1194
|
+
email:
|
|
1195
|
+
typeof profile?.email === "string" && profile.email.trim()
|
|
1196
|
+
? profile.email.trim().toLowerCase()
|
|
1197
|
+
: null,
|
|
1198
|
+
name:
|
|
1199
|
+
typeof profile?.real_name === "string" && profile.real_name.trim()
|
|
1200
|
+
? profile.real_name.trim()
|
|
1201
|
+
: typeof profile?.display_name === "string" &&
|
|
1202
|
+
profile.display_name.trim()
|
|
1203
|
+
? profile.display_name.trim()
|
|
1204
|
+
: typeof user.user.real_name === "string" &&
|
|
1205
|
+
user.user.real_name.trim()
|
|
1206
|
+
? user.user.real_name.trim()
|
|
1207
|
+
: typeof user.user.name === "string" && user.user.name.trim()
|
|
1208
|
+
? user.user.name.trim()
|
|
1209
|
+
: null,
|
|
1210
|
+
// is_stranger marks Slack Connect DM participants from another
|
|
1211
|
+
// workspace; they must map to "external", never "member".
|
|
1212
|
+
memberType:
|
|
1213
|
+
user.user.is_stranger || user.user.is_ultra_restricted
|
|
1214
|
+
? "external"
|
|
1215
|
+
: user.user.is_restricted
|
|
1216
|
+
? "guest"
|
|
1217
|
+
: user.user.is_owner
|
|
1218
|
+
? "owner"
|
|
1219
|
+
: user.user.is_admin
|
|
1220
|
+
? "admin"
|
|
1221
|
+
: "member",
|
|
1222
|
+
}
|
|
1223
|
+
: null;
|
|
1224
|
+
if (slackIdentityCache.size >= SLACK_IDENTITY_CACHE_MAX_ENTRIES) {
|
|
1225
|
+
const oldestKey = slackIdentityCache.keys().next().value;
|
|
1226
|
+
if (oldestKey) slackIdentityCache.delete(oldestKey);
|
|
1227
|
+
}
|
|
1228
|
+
slackIdentityCache.set(cacheKey, {
|
|
1229
|
+
identity,
|
|
1230
|
+
expiresAt:
|
|
1231
|
+
Date.now() +
|
|
1232
|
+
(identity
|
|
1233
|
+
? SLACK_IDENTITY_CACHE_TTL_MS
|
|
1234
|
+
: SLACK_IDENTITY_NEGATIVE_CACHE_TTL_MS),
|
|
1235
|
+
});
|
|
1236
|
+
return identity;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function hydrateSlackIdentity(
|
|
1240
|
+
token: string,
|
|
1241
|
+
incoming: IncomingMessage,
|
|
1242
|
+
): Promise<IncomingMessage> {
|
|
1243
|
+
const identity = await resolveSlackUserIdentity(token, incoming);
|
|
1244
|
+
if (!identity) {
|
|
1245
|
+
return {
|
|
1246
|
+
...incoming,
|
|
1247
|
+
senderVerified: false,
|
|
1248
|
+
actorTrust: { memberType: "unknown", verified: false },
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
return {
|
|
1252
|
+
...incoming,
|
|
1253
|
+
...(identity.name ? { senderName: identity.name } : {}),
|
|
1254
|
+
...(identity.email
|
|
1255
|
+
? { senderEmail: identity.email, senderVerified: true }
|
|
1256
|
+
: { senderVerified: false }),
|
|
1257
|
+
actorTrust: {
|
|
1258
|
+
memberType: identity.memberType,
|
|
1259
|
+
verified: true,
|
|
1260
|
+
},
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1073
1264
|
async function hydrateSlackContext(
|
|
1074
1265
|
token: string,
|
|
1075
1266
|
incoming: IncomingMessage,
|
|
@@ -1081,7 +1272,7 @@ async function hydrateSlackContext(
|
|
|
1081
1272
|
return incoming;
|
|
1082
1273
|
}
|
|
1083
1274
|
|
|
1084
|
-
const [thread, history, pins, conversation,
|
|
1275
|
+
const [thread, history, pins, conversation, identity] = await Promise.all([
|
|
1085
1276
|
slackJson(token, "conversations.replies", {
|
|
1086
1277
|
channel: channelId,
|
|
1087
1278
|
ts: threadTs,
|
|
@@ -1095,7 +1286,7 @@ async function hydrateSlackContext(
|
|
|
1095
1286
|
}),
|
|
1096
1287
|
slackJson(token, "pins.list", { channel: channelId }),
|
|
1097
1288
|
slackJson(token, "conversations.info", { channel: channelId }),
|
|
1098
|
-
senderId ?
|
|
1289
|
+
senderId ? resolveSlackUserIdentity(token, incoming) : null,
|
|
1099
1290
|
]);
|
|
1100
1291
|
|
|
1101
1292
|
// Agent View can attach the Slack surface the user is currently viewing to
|
|
@@ -1186,9 +1377,7 @@ async function hydrateSlackContext(
|
|
|
1186
1377
|
})
|
|
1187
1378
|
: [];
|
|
1188
1379
|
|
|
1189
|
-
const profile =
|
|
1190
|
-
const isGuest = user?.user?.is_restricted === true;
|
|
1191
|
-
const isExternal = user?.user?.is_ultra_restricted === true;
|
|
1380
|
+
const profile = identity;
|
|
1192
1381
|
const isPrivate = conversation?.channel?.is_private === true;
|
|
1193
1382
|
const isExternalShared = conversation?.channel?.is_ext_shared === true;
|
|
1194
1383
|
const isMpim = conversation?.channel?.is_mpim === true;
|
|
@@ -1231,19 +1420,20 @@ async function hydrateSlackContext(
|
|
|
1231
1420
|
);
|
|
1232
1421
|
return {
|
|
1233
1422
|
...incoming,
|
|
1234
|
-
...(
|
|
1235
|
-
|
|
1236
|
-
: typeof profile?.real_name === "string" && profile.real_name
|
|
1237
|
-
? { senderName: profile.real_name }
|
|
1238
|
-
: {}),
|
|
1239
|
-
...(typeof profile?.email === "string"
|
|
1423
|
+
...(profile?.name ? { senderName: profile.name } : {}),
|
|
1424
|
+
...(profile?.email
|
|
1240
1425
|
? { senderEmail: profile.email, senderVerified: true }
|
|
1241
|
-
: {}),
|
|
1426
|
+
: { senderVerified: false }),
|
|
1242
1427
|
conversationType,
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1428
|
+
// Conversation hydration must not promote a caller when users.info was
|
|
1429
|
+
// unavailable. Preserve the result of the earlier identity-only hydration
|
|
1430
|
+
// unless this request independently resolved the Slack user.
|
|
1431
|
+
actorTrust: identity
|
|
1432
|
+
? { memberType: identity.memberType, verified: true }
|
|
1433
|
+
: (incoming.actorTrust ?? {
|
|
1434
|
+
memberType: "unknown",
|
|
1435
|
+
verified: false,
|
|
1436
|
+
}),
|
|
1247
1437
|
contextMessages: hydratedMessages,
|
|
1248
1438
|
...(hydratedFiles.length ? { files: hydratedFiles.slice(0, 20) } : {}),
|
|
1249
1439
|
platformContext: {
|