@agent-analytics/paperclip-live-analytics-plugin 0.1.0 → 0.1.1

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.
@@ -1,535 +0,0 @@
1
- import {
2
- ACTION_KEYS,
3
- DATA_KEYS,
4
- DEFAULT_BASE_URL,
5
- DEFAULT_SNOOZE_MINUTES,
6
- MAX_ENABLED_ASSET_STREAMS,
7
- } from '../shared/constants.js';
8
- import {
9
- buildCompanyLiveState,
10
- clampLiveWindowSeconds,
11
- clampPollIntervalSeconds,
12
- createEmptyAssetState,
13
- createSnoozeExpiry,
14
- deriveWidgetSummary,
15
- mappingSignature,
16
- normalizeAssetMapping,
17
- applySnapshotToAssetState,
18
- applyTrackEventToAssetState,
19
- validateEnabledMappings,
20
- } from '../shared/live-state.js';
21
- import { AgentAnalyticsClient } from '../shared/agent-analytics-client.js';
22
- import { closeLiveChannel, emitLiveState, openLiveChannel, registerActionHandler, registerDataHandler } from './paperclip.js';
23
- import { loadAuthState, loadSettings, loadSnoozes, saveAuthState, saveSettings, saveSnoozes } from './state.js';
24
-
25
- function delay(ms) {
26
- return new Promise((resolve) => setTimeout(resolve, ms));
27
- }
28
-
29
- function serializeAccount(account) {
30
- if (!account) return null;
31
- return {
32
- id: account.id,
33
- email: account.email,
34
- githubLogin: account.github_login || null,
35
- googleName: account.google_name || null,
36
- };
37
- }
38
-
39
- function toPublicAuthState(auth) {
40
- return {
41
- status: auth.status,
42
- mode: auth.mode,
43
- tier: auth.tier,
44
- accountSummary: auth.accountSummary,
45
- accessExpiresAt: auth.accessExpiresAt,
46
- refreshExpiresAt: auth.refreshExpiresAt,
47
- pendingAuthRequest: auth.pendingAuthRequest,
48
- lastValidatedAt: auth.lastValidatedAt,
49
- lastError: auth.lastError,
50
- connected: Boolean(auth.accessToken),
51
- };
52
- }
53
-
54
- export class PaperclipLiveAnalyticsService {
55
- constructor(ctx, { fetchImpl = globalThis.fetch } = {}) {
56
- this.ctx = ctx;
57
- this.fetchImpl = fetchImpl;
58
- this.runtimes = new Map();
59
- }
60
-
61
- async register() {
62
- await registerDataHandler(this.ctx, DATA_KEYS.livePageLoad, (input) => this.loadLivePage(input));
63
- await registerDataHandler(this.ctx, DATA_KEYS.liveWidgetLoad, (input) => this.loadLiveWidget(input));
64
- await registerDataHandler(this.ctx, DATA_KEYS.settingsLoad, (input) => this.loadSettingsData(input));
65
-
66
- await registerActionHandler(this.ctx, ACTION_KEYS.authStart, (input) => this.startAuth(input));
67
- await registerActionHandler(this.ctx, ACTION_KEYS.authComplete, (input) => this.completeAuth(input));
68
- await registerActionHandler(this.ctx, ACTION_KEYS.authReconnect, (input) => this.reconnectAuth(input));
69
- await registerActionHandler(this.ctx, ACTION_KEYS.authDisconnect, (input) => this.disconnectAuth(input));
70
- await registerActionHandler(this.ctx, ACTION_KEYS.settingsSave, (input) => this.savePluginSettings(input));
71
- await registerActionHandler(this.ctx, ACTION_KEYS.mappingUpsert, (input) => this.upsertMapping(input));
72
- await registerActionHandler(this.ctx, ACTION_KEYS.mappingRemove, (input) => this.removeMapping(input));
73
- await registerActionHandler(this.ctx, ACTION_KEYS.assetSnooze, (input) => this.snoozeAsset(input));
74
- await registerActionHandler(this.ctx, ACTION_KEYS.assetUnsnooze, (input) => this.unsnoozeAsset(input));
75
- }
76
-
77
- async shutdown() {
78
- for (const [companyId] of this.runtimes.entries()) {
79
- await this.stopRuntime(companyId);
80
- }
81
- }
82
-
83
- async loadLivePage({ companyId }) {
84
- const liveState = await this.ensureLiveState(companyId);
85
- return liveState;
86
- }
87
-
88
- async loadLiveWidget({ companyId }) {
89
- const liveState = await this.ensureLiveState(companyId);
90
- return deriveWidgetSummary(liveState);
91
- }
92
-
93
- async loadSettingsData({ companyId }) {
94
- const settings = await loadSettings(this.ctx, companyId);
95
- const auth = await loadAuthState(this.ctx, companyId);
96
- const validation = validateEnabledMappings(settings.monitoredAssets);
97
- const projects = await this.listProjectsForCompany(companyId).catch((error) => ({
98
- projects: [],
99
- tier: auth.tier,
100
- error: error.message,
101
- }));
102
- return {
103
- settings,
104
- auth: toPublicAuthState(auth),
105
- discoveredProjects: projects.projects || [],
106
- validation,
107
- projectListError: projects.error || null,
108
- };
109
- }
110
-
111
- async startAuth({ companyId, label }) {
112
- const settings = await loadSettings(this.ctx, companyId);
113
- const auth = await loadAuthState(this.ctx, companyId);
114
- const client = this.createClient(companyId, settings, auth);
115
- const started = await client.startPaperclipAuth({ companyId, label });
116
-
117
- const nextAuth = {
118
- ...auth,
119
- status: 'pending',
120
- lastError: null,
121
- pendingAuthRequest: {
122
- authRequestId: started.auth_request_id,
123
- authorizeUrl: started.authorize_url,
124
- approvalCode: started.approval_code,
125
- pollToken: started.poll_token,
126
- expiresAt: started.expires_at,
127
- },
128
- };
129
- await saveAuthState(this.ctx, companyId, nextAuth);
130
- return {
131
- auth: toPublicAuthState(nextAuth),
132
- };
133
- }
134
-
135
- async completeAuth({ companyId, authRequestId, exchangeCode }) {
136
- const settings = await loadSettings(this.ctx, companyId);
137
- const auth = await loadAuthState(this.ctx, companyId);
138
- const requestId = authRequestId || auth.pendingAuthRequest?.authRequestId;
139
- if (!requestId || !exchangeCode) {
140
- throw new Error('authRequestId and exchangeCode are required');
141
- }
142
-
143
- const client = this.createClient(companyId, settings, auth);
144
- const exchanged = await client.exchangeAgentSession(requestId, exchangeCode);
145
- const nextAuth = {
146
- mode: 'agent_session',
147
- accessToken: exchanged.agent_session.access_token,
148
- refreshToken: exchanged.agent_session.refresh_token,
149
- accessExpiresAt: exchanged.agent_session.access_expires_at,
150
- refreshExpiresAt: exchanged.agent_session.refresh_expires_at,
151
- accountSummary: serializeAccount(exchanged.account),
152
- tier: exchanged.account?.tier || null,
153
- status: 'connected',
154
- pendingAuthRequest: null,
155
- lastValidatedAt: Date.now(),
156
- lastError: null,
157
- };
158
-
159
- await saveAuthState(this.ctx, companyId, nextAuth);
160
- await this.ensureLiveState(companyId, { forceSync: true });
161
- return this.loadSettingsData({ companyId });
162
- }
163
-
164
- async reconnectAuth({ companyId }) {
165
- const settings = await loadSettings(this.ctx, companyId);
166
- const auth = await loadAuthState(this.ctx, companyId);
167
- if (!auth.refreshToken) {
168
- return this.startAuth({ companyId });
169
- }
170
-
171
- const client = this.createClient(companyId, settings, auth);
172
- const refreshed = await client.refreshAgentSession();
173
- const nextAuth = {
174
- ...auth,
175
- accessToken: refreshed.access_token,
176
- refreshToken: refreshed.refresh_token || auth.refreshToken,
177
- accessExpiresAt: refreshed.access_expires_at,
178
- refreshExpiresAt: refreshed.refresh_expires_at || auth.refreshExpiresAt,
179
- status: 'connected',
180
- lastValidatedAt: Date.now(),
181
- lastError: null,
182
- };
183
- await saveAuthState(this.ctx, companyId, nextAuth);
184
- await this.ensureLiveState(companyId, { forceSync: true });
185
- return this.loadSettingsData({ companyId });
186
- }
187
-
188
- async disconnectAuth({ companyId }) {
189
- const auth = await loadAuthState(this.ctx, companyId);
190
- const nextAuth = {
191
- ...auth,
192
- accessToken: null,
193
- refreshToken: null,
194
- accessExpiresAt: null,
195
- refreshExpiresAt: null,
196
- status: 'disconnected',
197
- pendingAuthRequest: null,
198
- lastError: null,
199
- };
200
- await saveAuthState(this.ctx, companyId, nextAuth);
201
- await this.stopRuntime(companyId);
202
- return this.loadSettingsData({ companyId });
203
- }
204
-
205
- async savePluginSettings({ companyId, settings: partialSettings = {} }) {
206
- const currentSettings = await loadSettings(this.ctx, companyId);
207
- const nextSettings = {
208
- ...currentSettings,
209
- agentAnalyticsBaseUrl: partialSettings.agentAnalyticsBaseUrl || currentSettings.agentAnalyticsBaseUrl || DEFAULT_BASE_URL,
210
- liveWindowSeconds: clampLiveWindowSeconds(partialSettings.liveWindowSeconds ?? currentSettings.liveWindowSeconds),
211
- pollIntervalSeconds: clampPollIntervalSeconds(partialSettings.pollIntervalSeconds ?? currentSettings.pollIntervalSeconds),
212
- pluginEnabled: partialSettings.pluginEnabled ?? currentSettings.pluginEnabled,
213
- };
214
- const validation = validateEnabledMappings(nextSettings.monitoredAssets);
215
- if (validation.errors.length > 0) {
216
- throw new Error(validation.errors.join(' '));
217
- }
218
- await saveSettings(this.ctx, companyId, nextSettings);
219
- await this.ensureLiveState(companyId, { forceSync: true });
220
- return this.loadSettingsData({ companyId });
221
- }
222
-
223
- async upsertMapping({ companyId, mapping }) {
224
- const settings = await loadSettings(this.ctx, companyId);
225
- const normalized = normalizeAssetMapping(mapping);
226
- const monitoredAssets = [...settings.monitoredAssets];
227
- const existingIndex = monitoredAssets.findIndex((entry) => entry.assetKey === normalized.assetKey);
228
-
229
- if (existingIndex === -1) monitoredAssets.push(normalized);
230
- else monitoredAssets.splice(existingIndex, 1, normalized);
231
-
232
- const validation = validateEnabledMappings(monitoredAssets);
233
- if (validation.errors.length > 0) {
234
- throw new Error(validation.errors.join(' '));
235
- }
236
-
237
- const nextSettings = {
238
- ...settings,
239
- monitoredAssets,
240
- };
241
- await saveSettings(this.ctx, companyId, nextSettings);
242
- await this.ensureLiveState(companyId, { forceSync: true });
243
- return this.loadSettingsData({ companyId });
244
- }
245
-
246
- async removeMapping({ companyId, assetKey }) {
247
- const settings = await loadSettings(this.ctx, companyId);
248
- const nextSettings = {
249
- ...settings,
250
- monitoredAssets: settings.monitoredAssets.filter((mapping) => mapping.assetKey !== assetKey),
251
- };
252
- await saveSettings(this.ctx, companyId, nextSettings);
253
- await this.ensureLiveState(companyId, { forceSync: true });
254
- return this.loadSettingsData({ companyId });
255
- }
256
-
257
- async snoozeAsset({ companyId, assetKey, minutes = DEFAULT_SNOOZE_MINUTES }) {
258
- const snoozes = await loadSnoozes(this.ctx, companyId);
259
- const nextSnoozes = {
260
- ...snoozes,
261
- [assetKey]: createSnoozeExpiry(minutes),
262
- };
263
- await saveSnoozes(this.ctx, companyId, nextSnoozes);
264
- const liveState = await this.ensureLiveState(companyId);
265
- return {
266
- snoozes: nextSnoozes,
267
- liveState,
268
- };
269
- }
270
-
271
- async unsnoozeAsset({ companyId, assetKey }) {
272
- const snoozes = await loadSnoozes(this.ctx, companyId);
273
- const nextSnoozes = { ...snoozes };
274
- delete nextSnoozes[assetKey];
275
- await saveSnoozes(this.ctx, companyId, nextSnoozes);
276
- const liveState = await this.ensureLiveState(companyId);
277
- return {
278
- snoozes: nextSnoozes,
279
- liveState,
280
- };
281
- }
282
-
283
- async ensureLiveState(companyId, { forceSync = false } = {}) {
284
- const settings = await loadSettings(this.ctx, companyId);
285
- const auth = await loadAuthState(this.ctx, companyId);
286
- const snoozes = await loadSnoozes(this.ctx, companyId);
287
-
288
- const runtime = this.getRuntime(companyId);
289
- if (forceSync) {
290
- await this.syncRuntime(companyId, settings, auth, runtime);
291
- } else if (!runtime.lastState) {
292
- await this.syncRuntime(companyId, settings, auth, runtime);
293
- }
294
-
295
- const assetStates = settings.monitoredAssets.map((mapping) => {
296
- const normalized = normalizeAssetMapping(mapping);
297
- return runtime.assetStates.get(normalized.assetKey) || createEmptyAssetState(normalized);
298
- });
299
-
300
- const liveState = buildCompanyLiveState({
301
- settings,
302
- auth,
303
- assets: assetStates,
304
- snoozes,
305
- });
306
- runtime.lastState = liveState;
307
- return liveState;
308
- }
309
-
310
- async syncRuntime(companyId, settings, auth, runtime) {
311
- const mappings = settings.monitoredAssets.map(normalizeAssetMapping);
312
- const validation = validateEnabledMappings(mappings);
313
- if (!settings.pluginEnabled || !auth.accessToken || validation.errors.length > 0) {
314
- auth.status = validation.errors.length > 0 ? 'error' : auth.status;
315
- if (validation.errors.length > 0) auth.lastError = validation.errors.join(' ');
316
- await saveAuthState(this.ctx, companyId, auth);
317
- await this.stopRuntime(companyId, { keepState: true });
318
- return;
319
- }
320
-
321
- await openLiveChannel(this.ctx, companyId);
322
-
323
- for (const mapping of mappings) {
324
- const current = runtime.assetStates.get(mapping.assetKey) || createEmptyAssetState(mapping);
325
- runtime.assetStates.set(mapping.assetKey, {
326
- ...current,
327
- ...mapping,
328
- });
329
- }
330
-
331
- for (const assetKey of Array.from(runtime.assetStates.keys())) {
332
- if (!mappings.find((mapping) => mapping.assetKey === assetKey)) {
333
- runtime.assetStates.delete(assetKey);
334
- }
335
- }
336
-
337
- const enabledMappings = mappings.filter((mapping) => mapping.enabled !== false).slice(0, MAX_ENABLED_ASSET_STREAMS);
338
- const groupedMappings = new Map();
339
- for (const mapping of enabledMappings) {
340
- const signature = mappingSignature(mapping);
341
- const group = groupedMappings.get(signature) || [];
342
- group.push(mapping);
343
- groupedMappings.set(signature, group);
344
- }
345
-
346
- for (const [signature, streamRuntime] of runtime.streams.entries()) {
347
- if (!groupedMappings.has(signature)) {
348
- streamRuntime.controller.abort();
349
- runtime.streams.delete(signature);
350
- }
351
- }
352
-
353
- for (const [signature, group] of groupedMappings.entries()) {
354
- const existing = runtime.streams.get(signature);
355
- if (existing) {
356
- existing.mappings = group;
357
- } else {
358
- runtime.streams.set(signature, this.startStreamLoop(companyId, settings, auth, group));
359
- }
360
- }
361
-
362
- for (const mapping of enabledMappings) {
363
- const poller = runtime.pollers.get(mapping.assetKey);
364
- if (poller) {
365
- clearInterval(poller);
366
- }
367
-
368
- const intervalId = setInterval(() => {
369
- this.refreshSnapshot(companyId, mapping).catch((error) => this.recordAssetError(companyId, mapping.assetKey, error));
370
- }, settings.pollIntervalSeconds * 1000);
371
- runtime.pollers.set(mapping.assetKey, intervalId);
372
- await this.refreshSnapshot(companyId, mapping);
373
- }
374
-
375
- for (const [assetKey, intervalId] of runtime.pollers.entries()) {
376
- if (!enabledMappings.find((mapping) => mapping.assetKey === assetKey)) {
377
- clearInterval(intervalId);
378
- runtime.pollers.delete(assetKey);
379
- }
380
- }
381
- }
382
-
383
- createClient(companyId, settings, auth) {
384
- return new AgentAnalyticsClient({
385
- auth: {
386
- access_token: auth.accessToken,
387
- refresh_token: auth.refreshToken,
388
- },
389
- baseUrl: settings.agentAnalyticsBaseUrl || DEFAULT_BASE_URL,
390
- fetchImpl: this.fetchImpl,
391
- onAuthUpdate: async (nextAuth) => {
392
- const current = await loadAuthState(this.ctx, companyId);
393
- await saveAuthState(this.ctx, companyId, {
394
- ...current,
395
- accessToken: nextAuth.access_token,
396
- refreshToken: nextAuth.refresh_token || current.refreshToken,
397
- accessExpiresAt: nextAuth.access_expires_at,
398
- refreshExpiresAt: nextAuth.refresh_expires_at || current.refreshExpiresAt,
399
- status: 'connected',
400
- lastValidatedAt: Date.now(),
401
- lastError: null,
402
- });
403
- },
404
- });
405
- }
406
-
407
- getRuntime(companyId) {
408
- let runtime = this.runtimes.get(companyId);
409
- if (!runtime) {
410
- runtime = {
411
- pollers: new Map(),
412
- streams: new Map(),
413
- assetStates: new Map(),
414
- lastState: null,
415
- };
416
- this.runtimes.set(companyId, runtime);
417
- }
418
- return runtime;
419
- }
420
-
421
- startStreamLoop(companyId, settings, auth, mappings) {
422
- const controller = new AbortController();
423
- const runtime = {
424
- controller,
425
- mappings,
426
- run: (async () => {
427
- while (!controller.signal.aborted) {
428
- try {
429
- const currentSettings = await loadSettings(this.ctx, companyId);
430
- const currentAuth = await loadAuthState(this.ctx, companyId);
431
- const client = this.createClient(companyId, currentSettings, currentAuth);
432
- const primaryMapping = mappings[0];
433
- await client.subscribeToStream({
434
- project: primaryMapping.agentAnalyticsProject,
435
- filter: primaryMapping.primaryHostname ? `hostname:${primaryMapping.primaryHostname}` : null,
436
- signal: controller.signal,
437
- onTrack: async (track) => {
438
- await this.applyTrackEvent(companyId, mappings, track);
439
- },
440
- });
441
- } catch (error) {
442
- if (!controller.signal.aborted) {
443
- for (const mapping of mappings) {
444
- await this.recordAssetError(companyId, mapping.assetKey, error);
445
- }
446
- await delay(2_000);
447
- }
448
- }
449
- }
450
- })(),
451
- };
452
- return runtime;
453
- }
454
-
455
- async refreshSnapshot(companyId, mapping) {
456
- const settings = await loadSettings(this.ctx, companyId);
457
- const auth = await loadAuthState(this.ctx, companyId);
458
- const client = this.createClient(companyId, settings, auth);
459
- const snapshot = await client.getLive(mapping.agentAnalyticsProject, {
460
- window: settings.liveWindowSeconds,
461
- });
462
- const runtime = this.getRuntime(companyId);
463
- const currentState = runtime.assetStates.get(mapping.assetKey) || createEmptyAssetState(mapping);
464
- runtime.assetStates.set(mapping.assetKey, applySnapshotToAssetState(currentState, snapshot, mapping));
465
- await this.publish(companyId);
466
- }
467
-
468
- async applyTrackEvent(companyId, mappings, track) {
469
- const runtime = this.getRuntime(companyId);
470
- for (const mapping of mappings) {
471
- const currentState = runtime.assetStates.get(mapping.assetKey) || createEmptyAssetState(mapping);
472
- runtime.assetStates.set(mapping.assetKey, applyTrackEventToAssetState(currentState, track, mapping));
473
- }
474
- await this.publish(companyId);
475
- }
476
-
477
- async recordAssetError(companyId, assetKey, error) {
478
- const runtime = this.getRuntime(companyId);
479
- const current = runtime.assetStates.get(assetKey);
480
- if (!current) return;
481
- runtime.assetStates.set(assetKey, {
482
- ...current,
483
- status: 'error',
484
- errors: [error.message || String(error)],
485
- lastUpdatedAt: Date.now(),
486
- });
487
-
488
- const auth = await loadAuthState(this.ctx, companyId);
489
- await saveAuthState(this.ctx, companyId, {
490
- ...auth,
491
- status: 'error',
492
- lastError: error.message || String(error),
493
- });
494
- await this.publish(companyId);
495
- }
496
-
497
- async publish(companyId) {
498
- const liveState = await this.ensureLiveState(companyId);
499
- await emitLiveState(this.ctx, companyId, liveState);
500
- }
501
-
502
- async listProjectsForCompany(companyId) {
503
- const settings = await loadSettings(this.ctx, companyId);
504
- const auth = await loadAuthState(this.ctx, companyId);
505
- if (!auth.accessToken) {
506
- return { projects: [], tier: auth.tier, error: null };
507
- }
508
- const client = this.createClient(companyId, settings, auth);
509
- return client.listProjects();
510
- }
511
-
512
- async stopRuntime(companyId, { keepState = false } = {}) {
513
- const runtime = this.runtimes.get(companyId);
514
- if (!runtime) return;
515
-
516
- for (const intervalId of runtime.pollers.values()) {
517
- clearInterval(intervalId);
518
- }
519
- runtime.pollers.clear();
520
-
521
- for (const streamRuntime of runtime.streams.values()) {
522
- streamRuntime.controller.abort();
523
- }
524
- runtime.streams.clear();
525
-
526
- if (!keepState) {
527
- runtime.assetStates.clear();
528
- runtime.lastState = null;
529
- this.runtimes.delete(companyId);
530
- }
531
-
532
- await closeLiveChannel(this.ctx, companyId);
533
- }
534
- }
535
-
@@ -1,58 +0,0 @@
1
- import { STATE_NAMESPACE } from '../shared/constants.js';
2
- import {
3
- createDefaultAuthState,
4
- createDefaultSettings,
5
- createDefaultSnoozeState,
6
- } from '../shared/defaults.js';
7
-
8
- function createScope(companyId) {
9
- return {
10
- scopeKind: 'company',
11
- scopeId: companyId,
12
- namespace: STATE_NAMESPACE,
13
- };
14
- }
15
-
16
- async function getValue(ctx, companyId, key, fallbackValue) {
17
- if (!ctx?.state?.get) return fallbackValue;
18
- const value = await ctx.state.get({
19
- ...createScope(companyId),
20
- key,
21
- });
22
- return value ?? fallbackValue;
23
- }
24
-
25
- async function setValue(ctx, companyId, key, value) {
26
- if (!ctx?.state?.set) return value;
27
- await ctx.state.set({
28
- ...createScope(companyId),
29
- key,
30
- value,
31
- });
32
- return value;
33
- }
34
-
35
- export async function loadSettings(ctx, companyId) {
36
- return getValue(ctx, companyId, 'config', createDefaultSettings());
37
- }
38
-
39
- export async function saveSettings(ctx, companyId, settings) {
40
- return setValue(ctx, companyId, 'config', settings);
41
- }
42
-
43
- export async function loadAuthState(ctx, companyId) {
44
- return getValue(ctx, companyId, 'auth', createDefaultAuthState());
45
- }
46
-
47
- export async function saveAuthState(ctx, companyId, authState) {
48
- return setValue(ctx, companyId, 'auth', authState);
49
- }
50
-
51
- export async function loadSnoozes(ctx, companyId) {
52
- return getValue(ctx, companyId, 'snoozes', createDefaultSnoozeState());
53
- }
54
-
55
- export async function saveSnoozes(ctx, companyId, snoozes) {
56
- return setValue(ctx, companyId, 'snoozes', snoozes);
57
- }
58
-