@robota-sdk/agent-plugin 3.0.0-beta.64
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/LICENSE +21 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.ts +1724 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +48 -0
- package/src/conversation-history/__tests__/conversation-history-plugin.test.ts +221 -0
- package/src/conversation-history/__tests__/history-storages.test.ts +115 -0
- package/src/conversation-history/conversation-history-helpers.ts +120 -0
- package/src/conversation-history/conversation-history-plugin.ts +294 -0
- package/src/conversation-history/index.ts +11 -0
- package/src/conversation-history/storages/database-storage.ts +96 -0
- package/src/conversation-history/storages/file-storage.ts +95 -0
- package/src/conversation-history/storages/index.ts +3 -0
- package/src/conversation-history/storages/memory-storage.ts +44 -0
- package/src/conversation-history/types.ts +64 -0
- package/src/error-handling/__tests__/error-handling-plugin.test.ts +201 -0
- package/src/error-handling/context-adapter.ts +48 -0
- package/src/error-handling/error-handling-helpers.ts +53 -0
- package/src/error-handling/error-handling-plugin.ts +293 -0
- package/src/error-handling/index.ts +9 -0
- package/src/error-handling/types.ts +82 -0
- package/src/execution-analytics/__tests__/execution-analytics-plugin.test.ts +224 -0
- package/src/execution-analytics/analytics-aggregation.ts +88 -0
- package/src/execution-analytics/execution-analytics-helpers.ts +83 -0
- package/src/execution-analytics/execution-analytics-plugin.ts +315 -0
- package/src/execution-analytics/index.ts +9 -0
- package/src/execution-analytics/types.ts +97 -0
- package/src/index.ts +8 -0
- package/src/limits/__tests__/limits-plugin.test.ts +712 -0
- package/src/limits/index.ts +9 -0
- package/src/limits/limits-helpers.ts +185 -0
- package/src/limits/limits-plugin.ts +196 -0
- package/src/limits/types.ts +73 -0
- package/src/limits/validation.ts +81 -0
- package/src/logging/__tests__/formatters.test.ts +48 -0
- package/src/logging/__tests__/logging-plugin.test.ts +464 -0
- package/src/logging/__tests__/logging-storages.test.ts +95 -0
- package/src/logging/formatters.ts +28 -0
- package/src/logging/index.ts +15 -0
- package/src/logging/logging-helpers.ts +223 -0
- package/src/logging/logging-plugin.ts +288 -0
- package/src/logging/storages/console-storage.ts +44 -0
- package/src/logging/storages/file-storage.ts +44 -0
- package/src/logging/storages/index.ts +4 -0
- package/src/logging/storages/remote-storage.ts +78 -0
- package/src/logging/storages/silent-storage.ts +18 -0
- package/src/logging/types.ts +106 -0
- package/src/performance/__tests__/memory-storage.test.ts +86 -0
- package/src/performance/__tests__/performance-plugin.test.ts +208 -0
- package/src/performance/__tests__/system-metrics-collector.test.ts +33 -0
- package/src/performance/collectors/system-metrics-collector.ts +69 -0
- package/src/performance/index.ts +12 -0
- package/src/performance/performance-helpers.ts +86 -0
- package/src/performance/performance-plugin.ts +274 -0
- package/src/performance/storages/index.ts +1 -0
- package/src/performance/storages/memory-storage.ts +88 -0
- package/src/performance/types.ts +160 -0
- package/src/usage/__tests__/aggregate-usage-stats.test.ts +136 -0
- package/src/usage/__tests__/memory-storage.test.ts +83 -0
- package/src/usage/__tests__/silent-storage.test.ts +44 -0
- package/src/usage/__tests__/usage-plugin-helpers.test.ts +155 -0
- package/src/usage/__tests__/usage-plugin.test.ts +358 -0
- package/src/usage/aggregate-usage-stats.ts +142 -0
- package/src/usage/index.ts +14 -0
- package/src/usage/storages/file-storage.ts +115 -0
- package/src/usage/storages/index.ts +4 -0
- package/src/usage/storages/memory-storage.ts +61 -0
- package/src/usage/storages/remote-storage.ts +143 -0
- package/src/usage/storages/silent-storage.ts +38 -0
- package/src/usage/types.ts +132 -0
- package/src/usage/usage-plugin-helpers.ts +116 -0
- package/src/usage/usage-plugin.ts +296 -0
- package/src/webhook/__tests__/webhook-plugin.test.ts +560 -0
- package/src/webhook/http-client.ts +141 -0
- package/src/webhook/index.ts +9 -0
- package/src/webhook/transformer.ts +209 -0
- package/src/webhook/types.ts +201 -0
- package/src/webhook/webhook-helpers.ts +60 -0
- package/src/webhook/webhook-plugin.ts +298 -0
- package/src/webhook/webhook-queue.ts +148 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { PluginError } from '@robota-sdk/agent-core';
|
|
3
|
+
import type {
|
|
4
|
+
IPluginExecutionContext,
|
|
5
|
+
IPluginExecutionResult,
|
|
6
|
+
IPluginErrorContext,
|
|
7
|
+
} from '@robota-sdk/agent-core';
|
|
8
|
+
|
|
9
|
+
// Mock logger before importing WebhookPlugin
|
|
10
|
+
vi.mock('@robota-sdk/agent-core', async (importOriginal) => {
|
|
11
|
+
const actual = await importOriginal<typeof import('@robota-sdk/agent-core')>();
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
createLogger: vi.fn().mockReturnValue({
|
|
15
|
+
debug: vi.fn(),
|
|
16
|
+
info: vi.fn(),
|
|
17
|
+
warn: vi.fn(),
|
|
18
|
+
error: vi.fn(),
|
|
19
|
+
isDebugEnabled: vi.fn().mockReturnValue(false),
|
|
20
|
+
setLevel: vi.fn(),
|
|
21
|
+
getLevel: vi.fn().mockReturnValue('warn'),
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
import { WebhookPlugin } from '../webhook-plugin';
|
|
27
|
+
import type { IWebhookEndpoint, IWebhookEventData, TWebhookEventName } from '../types';
|
|
28
|
+
|
|
29
|
+
function createContext(overrides: Partial<IPluginExecutionContext> = {}): IPluginExecutionContext {
|
|
30
|
+
return {
|
|
31
|
+
executionId: 'exec_1',
|
|
32
|
+
sessionId: 'session_1',
|
|
33
|
+
userId: 'user_1',
|
|
34
|
+
messages: [
|
|
35
|
+
{
|
|
36
|
+
id: 'msg-1',
|
|
37
|
+
role: 'user',
|
|
38
|
+
content: 'hello',
|
|
39
|
+
state: 'complete' as const,
|
|
40
|
+
timestamp: new Date(),
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
config: { model: 'gpt-4' },
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createResult(overrides: Partial<IPluginExecutionResult> = {}): IPluginExecutionResult {
|
|
49
|
+
return {
|
|
50
|
+
success: true,
|
|
51
|
+
response: 'Hello there',
|
|
52
|
+
duration: 150,
|
|
53
|
+
tokensUsed: 42,
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createEndpoint(overrides: Partial<IWebhookEndpoint> = {}): IWebhookEndpoint {
|
|
59
|
+
return {
|
|
60
|
+
url: 'https://example.com/webhook',
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('WebhookPlugin', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
// Mock global fetch
|
|
68
|
+
vi.stubGlobal(
|
|
69
|
+
'fetch',
|
|
70
|
+
vi.fn().mockResolvedValue({
|
|
71
|
+
ok: true,
|
|
72
|
+
status: 200,
|
|
73
|
+
statusText: 'OK',
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
vi.unstubAllGlobals();
|
|
80
|
+
vi.clearAllMocks();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ----------------------------------------------------------------
|
|
84
|
+
// Construction
|
|
85
|
+
// ----------------------------------------------------------------
|
|
86
|
+
describe('construction', () => {
|
|
87
|
+
it('should create plugin with valid endpoint', () => {
|
|
88
|
+
const plugin = new WebhookPlugin({
|
|
89
|
+
endpoints: [createEndpoint()],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(plugin.name).toBe('WebhookPlugin');
|
|
93
|
+
expect(plugin.version).toBe('1.0.0');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should throw when no endpoints are provided', () => {
|
|
97
|
+
expect(
|
|
98
|
+
() =>
|
|
99
|
+
new WebhookPlugin({
|
|
100
|
+
endpoints: [],
|
|
101
|
+
}),
|
|
102
|
+
).toThrow(PluginError);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should throw when endpoints array is missing', () => {
|
|
106
|
+
expect(
|
|
107
|
+
() =>
|
|
108
|
+
new WebhookPlugin({
|
|
109
|
+
endpoints: undefined as unknown as IWebhookEndpoint[],
|
|
110
|
+
}),
|
|
111
|
+
).toThrow(PluginError);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should throw when endpoint URL is missing', () => {
|
|
115
|
+
expect(
|
|
116
|
+
() =>
|
|
117
|
+
new WebhookPlugin({
|
|
118
|
+
endpoints: [{ url: '' }],
|
|
119
|
+
}),
|
|
120
|
+
).toThrow(PluginError);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should throw when endpoint URL is invalid', () => {
|
|
124
|
+
expect(
|
|
125
|
+
() =>
|
|
126
|
+
new WebhookPlugin({
|
|
127
|
+
endpoints: [{ url: 'not-a-url' }],
|
|
128
|
+
}),
|
|
129
|
+
).toThrow(PluginError);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should accept multiple endpoints', () => {
|
|
133
|
+
const plugin = new WebhookPlugin({
|
|
134
|
+
endpoints: [
|
|
135
|
+
createEndpoint({ url: 'https://a.com/hook' }),
|
|
136
|
+
createEndpoint({ url: 'https://b.com/hook' }),
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const stats = plugin.getStats();
|
|
141
|
+
expect(stats.endpointCount).toBe(2);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should throw when endpoint URL uses file:// scheme', () => {
|
|
145
|
+
expect(
|
|
146
|
+
() =>
|
|
147
|
+
new WebhookPlugin({
|
|
148
|
+
endpoints: [{ url: 'file:///etc/passwd' }],
|
|
149
|
+
}),
|
|
150
|
+
).toThrow(PluginError);
|
|
151
|
+
expect(
|
|
152
|
+
() =>
|
|
153
|
+
new WebhookPlugin({
|
|
154
|
+
endpoints: [{ url: 'file:///etc/passwd' }],
|
|
155
|
+
}),
|
|
156
|
+
).toThrow('Webhook endpoint URL must use http or https');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should throw when endpoint URL uses ftp:// scheme', () => {
|
|
160
|
+
expect(
|
|
161
|
+
() =>
|
|
162
|
+
new WebhookPlugin({
|
|
163
|
+
endpoints: [{ url: 'ftp://example.com/data' }],
|
|
164
|
+
}),
|
|
165
|
+
).toThrow(PluginError);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should accept http:// endpoint URL', () => {
|
|
169
|
+
const plugin = new WebhookPlugin({
|
|
170
|
+
endpoints: [{ url: 'http://localhost:3000/hook' }],
|
|
171
|
+
});
|
|
172
|
+
expect(plugin.name).toBe('WebhookPlugin');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should accept https:// endpoint URL', () => {
|
|
176
|
+
const plugin = new WebhookPlugin({
|
|
177
|
+
endpoints: [{ url: 'https://example.com/hook' }],
|
|
178
|
+
});
|
|
179
|
+
expect(plugin.name).toBe('WebhookPlugin');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should throw on invalid event filter in endpoint', () => {
|
|
183
|
+
expect(
|
|
184
|
+
() =>
|
|
185
|
+
new WebhookPlugin({
|
|
186
|
+
endpoints: [
|
|
187
|
+
{
|
|
188
|
+
url: 'https://example.com/hook',
|
|
189
|
+
events: ['invalid.event' as TWebhookEventName],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
}),
|
|
193
|
+
).toThrow(PluginError);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ----------------------------------------------------------------
|
|
198
|
+
// Event filtering
|
|
199
|
+
// ----------------------------------------------------------------
|
|
200
|
+
describe('event filtering', () => {
|
|
201
|
+
it('should skip events not in plugin event list', async () => {
|
|
202
|
+
const plugin = new WebhookPlugin({
|
|
203
|
+
endpoints: [createEndpoint()],
|
|
204
|
+
events: ['error.occurred'],
|
|
205
|
+
async: false,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// This event is not in the events list, so fetch should not be called
|
|
209
|
+
await plugin.sendWebhook('custom', { executionId: 'test' });
|
|
210
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should send events that are in the plugin event list', async () => {
|
|
214
|
+
const plugin = new WebhookPlugin({
|
|
215
|
+
endpoints: [createEndpoint()],
|
|
216
|
+
events: ['custom'],
|
|
217
|
+
async: false,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await plugin.sendWebhook('custom', { executionId: 'test' });
|
|
221
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should filter endpoints by their event subscriptions', async () => {
|
|
225
|
+
const plugin = new WebhookPlugin({
|
|
226
|
+
endpoints: [
|
|
227
|
+
{ url: 'https://a.com/hook', events: ['error.occurred'] },
|
|
228
|
+
{ url: 'https://b.com/hook', events: ['custom'] },
|
|
229
|
+
],
|
|
230
|
+
events: ['custom', 'error.occurred'],
|
|
231
|
+
async: false,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await plugin.sendWebhook('custom', { executionId: 'test' });
|
|
235
|
+
|
|
236
|
+
// Only endpoint B should receive the custom event
|
|
237
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
238
|
+
const callUrl = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
239
|
+
expect(callUrl).toBe('https://b.com/hook');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should send to endpoint with no event filter (receives all)', async () => {
|
|
243
|
+
const plugin = new WebhookPlugin({
|
|
244
|
+
endpoints: [
|
|
245
|
+
{ url: 'https://all.com/hook' }, // no events filter = all events
|
|
246
|
+
],
|
|
247
|
+
events: ['custom'],
|
|
248
|
+
async: false,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await plugin.sendWebhook('custom', { executionId: 'test' });
|
|
252
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ----------------------------------------------------------------
|
|
257
|
+
// Lifecycle hooks
|
|
258
|
+
// ----------------------------------------------------------------
|
|
259
|
+
describe('lifecycle hooks', () => {
|
|
260
|
+
it('should send webhook on afterExecution', async () => {
|
|
261
|
+
const plugin = new WebhookPlugin({
|
|
262
|
+
endpoints: [createEndpoint()],
|
|
263
|
+
async: false,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await plugin.afterExecution(createContext(), createResult());
|
|
267
|
+
expect(fetch).toHaveBeenCalled();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should send webhook on afterConversation', async () => {
|
|
271
|
+
const plugin = new WebhookPlugin({
|
|
272
|
+
endpoints: [createEndpoint()],
|
|
273
|
+
async: false,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await plugin.afterConversation(createContext(), createResult());
|
|
277
|
+
expect(fetch).toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should send webhook on onError', async () => {
|
|
281
|
+
const plugin = new WebhookPlugin({
|
|
282
|
+
endpoints: [createEndpoint()],
|
|
283
|
+
async: false,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const error = new Error('Test error');
|
|
287
|
+
const errorContext: IPluginErrorContext = {
|
|
288
|
+
action: 'test',
|
|
289
|
+
executionId: 'exec_1',
|
|
290
|
+
sessionId: 'session_1',
|
|
291
|
+
userId: 'user_1',
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
await plugin.onError(error, errorContext);
|
|
295
|
+
// onError sends two webhooks: error.occurred and execution.error
|
|
296
|
+
expect(fetch).toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should send webhook on afterToolExecution with tool calls', async () => {
|
|
300
|
+
const plugin = new WebhookPlugin({
|
|
301
|
+
endpoints: [createEndpoint()],
|
|
302
|
+
async: false,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const resultWithTools = createResult({
|
|
306
|
+
toolCalls: [
|
|
307
|
+
{ id: 'tool-1', name: 'search', result: 'found' },
|
|
308
|
+
{ id: 'tool-2', name: 'calc', result: '42' },
|
|
309
|
+
],
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await plugin.afterToolExecution(createContext(), resultWithTools);
|
|
313
|
+
// Should send one webhook per tool call
|
|
314
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should not send on afterToolExecution with no tool calls', async () => {
|
|
318
|
+
const plugin = new WebhookPlugin({
|
|
319
|
+
endpoints: [createEndpoint()],
|
|
320
|
+
async: false,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await plugin.afterToolExecution(createContext(), createResult());
|
|
324
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// ----------------------------------------------------------------
|
|
329
|
+
// Custom webhooks
|
|
330
|
+
// ----------------------------------------------------------------
|
|
331
|
+
describe('sendCustomWebhook', () => {
|
|
332
|
+
it('should send a custom event webhook', async () => {
|
|
333
|
+
const plugin = new WebhookPlugin({
|
|
334
|
+
endpoints: [createEndpoint()],
|
|
335
|
+
events: ['custom'],
|
|
336
|
+
async: false,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await plugin.sendCustomWebhook({ executionId: 'test-exec' });
|
|
340
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
341
|
+
|
|
342
|
+
const body = JSON.parse(
|
|
343
|
+
(fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string,
|
|
344
|
+
) as Record<string, unknown>;
|
|
345
|
+
expect(body.event).toBe('custom');
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ----------------------------------------------------------------
|
|
350
|
+
// Payload structure
|
|
351
|
+
// ----------------------------------------------------------------
|
|
352
|
+
describe('payload structure', () => {
|
|
353
|
+
it('should include event, timestamp, and data in payload', async () => {
|
|
354
|
+
const plugin = new WebhookPlugin({
|
|
355
|
+
endpoints: [createEndpoint()],
|
|
356
|
+
events: ['custom'],
|
|
357
|
+
async: false,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
await plugin.sendWebhook('custom', {
|
|
361
|
+
executionId: 'exec-123',
|
|
362
|
+
sessionId: 'sess-456',
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const body = JSON.parse(
|
|
366
|
+
(fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string,
|
|
367
|
+
) as Record<string, unknown>;
|
|
368
|
+
expect(body.event).toBe('custom');
|
|
369
|
+
expect(body.timestamp).toBeDefined();
|
|
370
|
+
expect(body.executionId).toBe('exec-123');
|
|
371
|
+
expect(body.sessionId).toBe('sess-456');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should include metadata when provided', async () => {
|
|
375
|
+
const plugin = new WebhookPlugin({
|
|
376
|
+
endpoints: [createEndpoint()],
|
|
377
|
+
events: ['custom'],
|
|
378
|
+
async: false,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await plugin.sendWebhook('custom', { executionId: 'test' }, { source: 'test-suite' });
|
|
382
|
+
|
|
383
|
+
const body = JSON.parse(
|
|
384
|
+
(fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string,
|
|
385
|
+
) as Record<string, unknown>;
|
|
386
|
+
expect(body.metadata).toEqual({ source: 'test-suite' });
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ----------------------------------------------------------------
|
|
391
|
+
// Batching
|
|
392
|
+
// ----------------------------------------------------------------
|
|
393
|
+
describe('batching', () => {
|
|
394
|
+
it('should queue payloads when batching is enabled', async () => {
|
|
395
|
+
const plugin = new WebhookPlugin({
|
|
396
|
+
endpoints: [createEndpoint()],
|
|
397
|
+
events: ['custom'],
|
|
398
|
+
async: false,
|
|
399
|
+
batching: { enabled: true, maxSize: 5, flushInterval: 60000 },
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
await plugin.sendWebhook('custom', { executionId: '1' });
|
|
403
|
+
await plugin.sendWebhook('custom', { executionId: '2' });
|
|
404
|
+
|
|
405
|
+
// Not flushed yet because batch size is 5
|
|
406
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
407
|
+
|
|
408
|
+
const stats = plugin.getStats();
|
|
409
|
+
expect(stats.batchQueueLength).toBe(2);
|
|
410
|
+
|
|
411
|
+
await plugin.destroy();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should auto-flush when batch reaches maxSize', async () => {
|
|
415
|
+
const plugin = new WebhookPlugin({
|
|
416
|
+
endpoints: [createEndpoint()],
|
|
417
|
+
events: ['custom'],
|
|
418
|
+
async: false,
|
|
419
|
+
batching: { enabled: true, maxSize: 2, flushInterval: 60000 },
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
await plugin.sendWebhook('custom', { executionId: '1' });
|
|
423
|
+
await plugin.sendWebhook('custom', { executionId: '2' });
|
|
424
|
+
|
|
425
|
+
// Should have flushed since we hit maxSize of 2
|
|
426
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
427
|
+
|
|
428
|
+
await plugin.destroy();
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ----------------------------------------------------------------
|
|
433
|
+
// Statistics
|
|
434
|
+
// ----------------------------------------------------------------
|
|
435
|
+
describe('getStats', () => {
|
|
436
|
+
it('should return complete stats', async () => {
|
|
437
|
+
const plugin = new WebhookPlugin({
|
|
438
|
+
endpoints: [createEndpoint(), createEndpoint({ url: 'https://b.com/hook' })],
|
|
439
|
+
events: ['custom'],
|
|
440
|
+
async: false,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await plugin.sendWebhook('custom', { executionId: 'test' });
|
|
444
|
+
|
|
445
|
+
const stats = plugin.getStats();
|
|
446
|
+
expect(stats.endpointCount).toBe(2);
|
|
447
|
+
expect(stats.totalSent).toBe(2); // sent to both endpoints
|
|
448
|
+
expect(stats.totalErrors).toBe(0);
|
|
449
|
+
expect(stats.supportedEvents).toContain('custom');
|
|
450
|
+
expect(typeof stats.averageResponseTime).toBe('number');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should track errors in stats', async () => {
|
|
454
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
|
455
|
+
|
|
456
|
+
const plugin = new WebhookPlugin({
|
|
457
|
+
endpoints: [
|
|
458
|
+
{
|
|
459
|
+
url: 'https://example.com/hook',
|
|
460
|
+
retries: 0,
|
|
461
|
+
timeout: 100,
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
events: ['custom'],
|
|
465
|
+
async: false,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// sendWebhook catches errors internally and tracks them in stats
|
|
469
|
+
await plugin.sendWebhook('custom', { executionId: 'test' });
|
|
470
|
+
|
|
471
|
+
const stats = plugin.getStats();
|
|
472
|
+
expect(stats.totalErrors).toBeGreaterThan(0);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// ----------------------------------------------------------------
|
|
477
|
+
// Queue management
|
|
478
|
+
// ----------------------------------------------------------------
|
|
479
|
+
describe('clearQueue', () => {
|
|
480
|
+
it('should clear both request and batch queues', async () => {
|
|
481
|
+
const plugin = new WebhookPlugin({
|
|
482
|
+
endpoints: [createEndpoint()],
|
|
483
|
+
events: ['custom'],
|
|
484
|
+
async: false,
|
|
485
|
+
batching: { enabled: true, maxSize: 100, flushInterval: 60000 },
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await plugin.sendWebhook('custom', { executionId: '1' });
|
|
489
|
+
await plugin.sendWebhook('custom', { executionId: '2' });
|
|
490
|
+
|
|
491
|
+
plugin.clearQueue();
|
|
492
|
+
|
|
493
|
+
const stats = plugin.getStats();
|
|
494
|
+
expect(stats.queueLength).toBe(0);
|
|
495
|
+
expect(stats.batchQueueLength).toBe(0);
|
|
496
|
+
|
|
497
|
+
await plugin.destroy();
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ----------------------------------------------------------------
|
|
502
|
+
// Destroy
|
|
503
|
+
// ----------------------------------------------------------------
|
|
504
|
+
describe('destroy', () => {
|
|
505
|
+
it('should flush batched payloads on destroy', async () => {
|
|
506
|
+
const plugin = new WebhookPlugin({
|
|
507
|
+
endpoints: [createEndpoint()],
|
|
508
|
+
events: ['custom'],
|
|
509
|
+
async: false,
|
|
510
|
+
batching: { enabled: true, maxSize: 100, flushInterval: 60000 },
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
await plugin.sendWebhook('custom', { executionId: '1' });
|
|
514
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
515
|
+
|
|
516
|
+
await plugin.destroy();
|
|
517
|
+
expect(fetch).toHaveBeenCalled();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('should complete without error when queue is empty', async () => {
|
|
521
|
+
const plugin = new WebhookPlugin({
|
|
522
|
+
endpoints: [createEndpoint()],
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await expect(plugin.destroy()).resolves.not.toThrow();
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// ----------------------------------------------------------------
|
|
530
|
+
// Payload transformer
|
|
531
|
+
// ----------------------------------------------------------------
|
|
532
|
+
describe('payload transformer', () => {
|
|
533
|
+
it('should apply custom payload transformer', async () => {
|
|
534
|
+
const transformer = vi.fn((_event: TWebhookEventName, data: IWebhookEventData) => ({
|
|
535
|
+
...data,
|
|
536
|
+
executionId: 'transformed',
|
|
537
|
+
}));
|
|
538
|
+
|
|
539
|
+
const plugin = new WebhookPlugin({
|
|
540
|
+
endpoints: [createEndpoint()],
|
|
541
|
+
events: ['custom'],
|
|
542
|
+
async: false,
|
|
543
|
+
payloadTransformer: transformer,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
await plugin.sendWebhook('custom', { executionId: 'original' });
|
|
547
|
+
|
|
548
|
+
expect(transformer).toHaveBeenCalledWith(
|
|
549
|
+
'custom',
|
|
550
|
+
expect.objectContaining({ executionId: 'original' }),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const body = JSON.parse(
|
|
554
|
+
(fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string,
|
|
555
|
+
) as Record<string, unknown>;
|
|
556
|
+
const data = body.data as Record<string, unknown>;
|
|
557
|
+
expect(data.executionId).toBe('transformed');
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook HTTP client for sending webhook requests
|
|
3
|
+
* Handles retries, timeouts, and error scenarios
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import jsSHA from 'jssha';
|
|
7
|
+
import type { IWebhookRequest } from './types';
|
|
8
|
+
import type { ILogger } from '@robota-sdk/agent-core';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
11
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
12
|
+
const BACKOFF_BASE_MS = 1000;
|
|
13
|
+
const MAX_BACKOFF_MS = 10000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* HTTP client for webhook requests
|
|
17
|
+
*/
|
|
18
|
+
export class WebhookHttpClient {
|
|
19
|
+
private logger: ILogger;
|
|
20
|
+
|
|
21
|
+
constructor(logger: ILogger) {
|
|
22
|
+
this.logger = logger;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Send a single webhook request with retries
|
|
27
|
+
*/
|
|
28
|
+
async sendRequest(request: IWebhookRequest): Promise<void> {
|
|
29
|
+
const { endpoint, payload, attempt } = request;
|
|
30
|
+
const timeout = endpoint.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
31
|
+
const maxRetries = endpoint.retries ?? DEFAULT_MAX_RETRIES;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// Prepare request body
|
|
35
|
+
const body = JSON.stringify(payload);
|
|
36
|
+
|
|
37
|
+
// Prepare headers
|
|
38
|
+
const headers: Record<string, string> = {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
'User-Agent': 'robota-webhook/1.0.0',
|
|
41
|
+
...endpoint.headers,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Add signature if secret is provided
|
|
45
|
+
if (endpoint.secret) {
|
|
46
|
+
const signature = this.generateSignature(body, endpoint.secret);
|
|
47
|
+
headers['X-Webhook-Signature'] = signature;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Make HTTP request
|
|
51
|
+
const response = await this.makeHttpRequest(endpoint.url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers,
|
|
54
|
+
body,
|
|
55
|
+
timeout,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.logger.debug('Webhook sent successfully', {
|
|
63
|
+
url: endpoint.url,
|
|
64
|
+
event: payload.event,
|
|
65
|
+
attempt,
|
|
66
|
+
status: response.status,
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.logger.error('Webhook request failed', {
|
|
70
|
+
url: endpoint.url,
|
|
71
|
+
event: payload.event,
|
|
72
|
+
attempt,
|
|
73
|
+
error: error instanceof Error ? error.message : String(error),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Retry if we haven't exceeded max attempts
|
|
77
|
+
if (attempt < maxRetries) {
|
|
78
|
+
const retryRequest: IWebhookRequest = {
|
|
79
|
+
...request,
|
|
80
|
+
attempt: attempt + 1,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Exponential backoff
|
|
84
|
+
const delay = Math.min(BACKOFF_BASE_MS * Math.pow(2, attempt - 1), MAX_BACKOFF_MS);
|
|
85
|
+
await this.delay(delay);
|
|
86
|
+
|
|
87
|
+
return this.sendRequest(retryRequest);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Max retries exceeded
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate HMAC signature for webhook security using jsSHA (browser compatible)
|
|
97
|
+
*/
|
|
98
|
+
private generateSignature(body: string, secret: string): string {
|
|
99
|
+
const shaObj = new jsSHA('SHA-256', 'TEXT', {
|
|
100
|
+
hmacKey: { value: secret, format: 'TEXT' },
|
|
101
|
+
});
|
|
102
|
+
shaObj.update(body);
|
|
103
|
+
return shaObj.getHash('HEX').toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Make HTTP request with timeout support
|
|
108
|
+
*/
|
|
109
|
+
private async makeHttpRequest(
|
|
110
|
+
url: string,
|
|
111
|
+
options: {
|
|
112
|
+
method: string;
|
|
113
|
+
headers: Record<string, string>;
|
|
114
|
+
body: string;
|
|
115
|
+
timeout: number;
|
|
116
|
+
},
|
|
117
|
+
): Promise<Response> {
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeout);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(url, {
|
|
123
|
+
method: options.method,
|
|
124
|
+
headers: options.headers,
|
|
125
|
+
body: options.body,
|
|
126
|
+
signal: controller.signal,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return response;
|
|
130
|
+
} finally {
|
|
131
|
+
clearTimeout(timeoutId);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Delay utility for retry backoff
|
|
137
|
+
*/
|
|
138
|
+
private delay(ms: number): Promise<void> {
|
|
139
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { WebhookPlugin } from './webhook-plugin';
|
|
2
|
+
export { WebhookTransformer } from './transformer';
|
|
3
|
+
export { WebhookHttpClient } from './http-client';
|
|
4
|
+
export type {
|
|
5
|
+
TWebhookEventName,
|
|
6
|
+
IWebhookPayload,
|
|
7
|
+
IWebhookEndpoint,
|
|
8
|
+
IWebhookPluginOptions,
|
|
9
|
+
} from './types';
|