@teamvibe/poller 0.1.57 → 0.1.58

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,5 +1,6 @@
1
1
  import { POLLER_VERSION } from './config.js';
2
2
  import { logger } from './logger.js';
3
+ import { emitError } from './event-queue.js';
3
4
  let currentCredentials = null;
4
5
  let currentConfig = null;
5
6
  let currentPollerId = null;
@@ -19,10 +20,12 @@ async function sendHeartbeat(apiUrl, token, version) {
19
20
  });
20
21
  if (!response.ok) {
21
22
  logger.warn(`Heartbeat failed (${response.status})`);
23
+ emitError('auth', `Heartbeat failed (${response.status})`, { severity: 'warn' });
22
24
  }
23
25
  }
24
26
  catch (error) {
25
27
  logger.warn(`Heartbeat error: ${error instanceof Error ? error.message : error}`);
28
+ emitError('auth', `Heartbeat error: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn' });
26
29
  }
27
30
  }
28
31
  async function fetchCredentials(apiUrl, token) {
@@ -62,6 +65,7 @@ export async function initAuth(apiUrl, token) {
62
65
  }
63
66
  catch (error) {
64
67
  logger.error(`Credential refresh failed: ${error instanceof Error ? error.message : error}`);
68
+ emitError('auth', `Credential refresh failed: ${error instanceof Error ? error.message : String(error)}`);
65
69
  }
66
70
  }, REFRESH_INTERVAL_MS);
67
71
  }
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import { config } from './config.js';
6
6
  import { logger } from './logger.js';
7
+ import { emitError } from './event-queue.js';
7
8
  const execAsync = promisify(exec);
8
9
  // Clean env for git commands:
9
10
  // - npm/npx sets GIT_ASKPASS which prevents credential helpers from working
@@ -42,10 +43,12 @@ async function reportBrainSync(brainId, workspaceId, event) {
42
43
  });
43
44
  if (!response.ok) {
44
45
  logger.warn(`Brain sync report failed (${response.status})`);
46
+ emitError('brain', `Brain sync report failed (${response.status})`, { severity: 'warn', context: { brainId, event } });
45
47
  }
46
48
  }
47
49
  catch (error) {
48
50
  logger.warn(`Brain sync report error: ${error instanceof Error ? error.message : error}`);
51
+ emitError('brain', `Brain sync report error: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn', context: { brainId } });
49
52
  }
50
53
  }
51
54
  export async function getBrainPath(brain, channelId, workspaceId) {
@@ -107,6 +110,7 @@ async function updateBrain(brainDir, brainId, branch) {
107
110
  catch (error) {
108
111
  const errorMessage = error instanceof Error ? error.message : String(error);
109
112
  logger.error(`Failed to update brain ${brainId}: ${errorMessage}`);
113
+ emitError('brain', `Failed to update brain ${brainId}: ${errorMessage}`, { context: { brainId, brainDir } });
110
114
  }
111
115
  }
112
116
  /**
@@ -170,6 +174,7 @@ export async function pushBrainChanges(brainDir, brainId, workspaceId) {
170
174
  catch (error) {
171
175
  const errorMessage = error instanceof Error ? error.message : String(error);
172
176
  logger.warn(`Failed to push brain ${brainId} changes: ${errorMessage}`);
177
+ emitError('brain', `Failed to push brain ${brainId} changes: ${errorMessage}`, { severity: 'warn', context: { brainId } });
173
178
  }
174
179
  }
175
180
  /**
@@ -6,6 +6,7 @@ import { config } from './config.js';
6
6
  import { logger } from './logger.js';
7
7
  import { getBaseBrainPath } from './brain-manager.js';
8
8
  import { getPollerId } from './auth-provider.js';
9
+ import { emitError } from './event-queue.js';
9
10
  /**
10
11
  * Maps tool names (or prefixes) to human-readable status messages.
11
12
  * More specific patterns are checked first.
@@ -80,6 +81,7 @@ async function countNewThreadMessages(botToken, channel, threadTs, sinceTs) {
80
81
  }
81
82
  catch (error) {
82
83
  logger.warn(`Failed to count new thread messages: ${error}`);
84
+ emitError('slack', `Failed to count new thread messages: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn' });
83
85
  return 0;
84
86
  }
85
87
  }
@@ -97,6 +99,7 @@ function readBrainSettings(cwd) {
97
99
  }
98
100
  catch (error) {
99
101
  logger.warn(`Failed to read brain settings at ${settingsPath}: ${error}`);
102
+ emitError('brain', `Failed to read brain settings at ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn' });
100
103
  return {};
101
104
  }
102
105
  }
@@ -373,6 +376,10 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
373
376
  const timeout = setTimeout(() => {
374
377
  timedOut = true;
375
378
  logger.error(`Claude Code process timed out after ${timeoutMinutes} minutes`);
379
+ emitError('session', `Claude Code process timed out after ${timeoutMinutes} minutes`, {
380
+ context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
381
+ channelId: msg.teamvibe.channelId,
382
+ });
376
383
  proc.kill('SIGTERM');
377
384
  setTimeout(() => proc.kill('SIGKILL'), 5000);
378
385
  }, config.CLAUDE_TIMEOUT_MS);
@@ -395,6 +402,10 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
395
402
  proc.on('error', (error) => {
396
403
  clearTimeout(timeout);
397
404
  activeProcesses.delete(msg.id);
405
+ emitError('session', `Claude Code process spawn error: ${error.message}`, {
406
+ channelId: msg.teamvibe.channelId,
407
+ context: { threadId: msg.thread_id },
408
+ });
398
409
  resolve({
399
410
  success: false,
400
411
  output: stdout,
@@ -440,6 +451,7 @@ export function killAllActiveProcesses() {
440
451
  }
441
452
  const forceKillTimer = setTimeout(() => {
442
453
  logger.warn(`Process ${id} did not exit after SIGTERM, sending SIGKILL`);
454
+ emitError('session', `Process ${id} did not exit after SIGTERM, sending SIGKILL`, { severity: 'warn' });
443
455
  proc.kill('SIGKILL');
444
456
  }, 5000);
445
457
  proc.once('close', () => {
@@ -0,0 +1,14 @@
1
+ export type ErrorCategory = 'sqs' | 'brain' | 'auth' | 'session' | 'slack' | 'maintenance';
2
+ export type ErrorSeverity = 'warn' | 'error';
3
+ export declare function flush(): Promise<void>;
4
+ export declare function startEventQueue(): void;
5
+ export declare function stopEventQueue(): void;
6
+ export declare function emitError(category: ErrorCategory, message: string, options?: {
7
+ severity?: ErrorSeverity;
8
+ channelId?: string;
9
+ context?: Record<string, unknown>;
10
+ }): void;
11
+ /** For testing: get current queue length */
12
+ export declare function getQueueLength(): number;
13
+ /** For testing: clear the queue */
14
+ export declare function clearQueue(): void;
@@ -0,0 +1,113 @@
1
+ // packages/poller/src/event-queue.ts
2
+ import { config } from './config.js';
3
+ import { logger } from './logger.js';
4
+ const MAX_QUEUE_SIZE = 200;
5
+ const FLUSH_INTERVAL_MS = 30_000;
6
+ const DEDUP_WINDOW_MS = 60_000;
7
+ let queue = [];
8
+ let flushTimer = null;
9
+ let isFlushing = false;
10
+ function dedupKey(channelId, category, message) {
11
+ return `${channelId}:${category}:${message}`;
12
+ }
13
+ function enqueue(event) {
14
+ // Dedup: collapse identical category+message within window
15
+ const key = dedupKey(event.channelId, event.metadata.category, event.metadata.message);
16
+ const now = Date.now();
17
+ const windowStart = new Date(now - DEDUP_WINDOW_MS).toISOString();
18
+ const existing = queue.find((e) => dedupKey(e.channelId, e.metadata.category, e.metadata.message) === key &&
19
+ e.timestamp >= windowStart);
20
+ if (existing) {
21
+ existing.metadata.count += 1;
22
+ return;
23
+ }
24
+ if (queue.length >= MAX_QUEUE_SIZE) {
25
+ queue.shift(); // Drop oldest
26
+ }
27
+ queue.push(event);
28
+ }
29
+ async function flushOne(event) {
30
+ try {
31
+ const response = await fetch(`${config.TEAMVIBE_API_URL}/events`, {
32
+ method: 'POST',
33
+ headers: {
34
+ 'Content-Type': 'application/json',
35
+ Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
36
+ },
37
+ body: JSON.stringify({
38
+ eventType: event.eventType,
39
+ channelId: event.channelId,
40
+ metadata: event.metadata,
41
+ error: event.error,
42
+ }),
43
+ });
44
+ return response.ok;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ export async function flush() {
51
+ if (queue.length === 0 || isFlushing)
52
+ return;
53
+ isFlushing = true;
54
+ try {
55
+ const batch = [...queue];
56
+ const failed = [];
57
+ for (const event of batch) {
58
+ const ok = await flushOne(event);
59
+ if (!ok) {
60
+ failed.push(event);
61
+ }
62
+ }
63
+ queue = failed;
64
+ if (failed.length > 0) {
65
+ logger.debug(`[EventQueue] ${failed.length} event(s) failed to flush, will retry`);
66
+ }
67
+ }
68
+ finally {
69
+ isFlushing = false;
70
+ }
71
+ }
72
+ export function startEventQueue() {
73
+ if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
74
+ return; // No-op in direct mode
75
+ }
76
+ flushTimer = setInterval(() => {
77
+ flush().catch(() => { });
78
+ }, FLUSH_INTERVAL_MS);
79
+ }
80
+ export function stopEventQueue() {
81
+ if (flushTimer) {
82
+ clearInterval(flushTimer);
83
+ flushTimer = null;
84
+ }
85
+ }
86
+ export function emitError(category, message, options) {
87
+ if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
88
+ return; // No-op in direct mode
89
+ }
90
+ const severity = options?.severity ?? 'error';
91
+ const channelId = options?.channelId ?? '_system';
92
+ enqueue({
93
+ eventType: 'poller.error',
94
+ channelId,
95
+ metadata: {
96
+ category,
97
+ severity,
98
+ message,
99
+ count: 1,
100
+ context: options?.context,
101
+ },
102
+ error: { code: category, message },
103
+ timestamp: new Date().toISOString(),
104
+ });
105
+ }
106
+ /** For testing: get current queue length */
107
+ export function getQueueLength() {
108
+ return queue.length;
109
+ }
110
+ /** For testing: clear the queue */
111
+ export function clearQueue() {
112
+ queue = [];
113
+ }
@@ -1,6 +1,7 @@
1
1
  import { config } from './config.js';
2
2
  import { logger } from './logger.js';
3
3
  import { isAtCapacity } from './claude-spawner.js';
4
+ import { emitError } from './event-queue.js';
4
5
  const trackedBrains = new Map();
5
6
  let heartbeatInterval = null;
6
7
  /**
@@ -85,6 +86,10 @@ export function startHeartbeatLoop(processMessageFn) {
85
86
  }
86
87
  catch (error) {
87
88
  logger.error(`[Heartbeat] Failed maintenance for ${brainId}: ${error instanceof Error ? error.message : error}`);
89
+ emitError('session', `Heartbeat maintenance failed for ${brainId}: ${error instanceof Error ? error.message : String(error)}`, {
90
+ channelId: info.channelId,
91
+ context: { brainId },
92
+ });
88
93
  }
89
94
  }
90
95
  }, config.MAINTENANCE_CHECK_INTERVAL_MS);
@@ -4,6 +4,7 @@ import { execFile } from 'child_process';
4
4
  import { promisify } from 'util';
5
5
  import { config } from './config.js';
6
6
  import { logger } from './logger.js';
7
+ import { emitError } from './event-queue.js';
7
8
  const execFileAsync = promisify(execFile);
8
9
  const OPERATION_TO_EVENT_TYPE = {
9
10
  consolidation: 'maintenance.consolidation',
@@ -32,11 +33,13 @@ export async function postMaintenanceReports(brainPath, channelId) {
32
33
  const report = JSON.parse(content);
33
34
  if (!report.operationType) {
34
35
  logger.warn(`[MaintenanceReporter] Skipping ${file}: missing operationType`);
36
+ emitError('maintenance', `Skipping report ${file}: missing operationType`, { severity: 'warn' });
35
37
  continue;
36
38
  }
37
39
  const eventType = OPERATION_TO_EVENT_TYPE[report.operationType];
38
40
  if (!eventType) {
39
41
  logger.warn(`[MaintenanceReporter] Skipping ${file}: unknown operationType "${report.operationType}"`);
42
+ emitError('maintenance', `Skipping report ${file}: unknown operationType "${report.operationType}"`, { severity: 'warn' });
40
43
  continue;
41
44
  }
42
45
  const payload = {
@@ -60,10 +63,12 @@ export async function postMaintenanceReports(brainPath, channelId) {
60
63
  else {
61
64
  const errorBody = await response.text().catch(() => 'unknown');
62
65
  logger.warn(`[MaintenanceReporter] Failed to POST ${file}: ${response.status} ${errorBody}`);
66
+ emitError('maintenance', `Failed to POST report ${file}: ${response.status}`, { severity: 'warn', channelId });
63
67
  }
64
68
  }
65
69
  catch (err) {
66
70
  logger.warn(`[MaintenanceReporter] Error processing ${file}: ${err instanceof Error ? err.message : err}`);
71
+ emitError('maintenance', `Error processing report ${file}: ${err instanceof Error ? err.message : String(err)}`, { severity: 'warn', channelId });
67
72
  }
68
73
  }
69
74
  // Remove posted JSON reports from git so they don't resurrect on next pull
@@ -80,5 +85,6 @@ export async function postMaintenanceReports(brainPath, channelId) {
80
85
  }
81
86
  catch (err) {
82
87
  logger.warn(`[MaintenanceReporter] Error scanning reports: ${err instanceof Error ? err.message : err}`);
88
+ emitError('maintenance', `Error scanning reports: ${err instanceof Error ? err.message : String(err)}`, { severity: 'warn' });
83
89
  }
84
90
  }
package/dist/poller.js CHANGED
@@ -8,6 +8,7 @@ import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } fr
8
8
  import { postMaintenanceReports } from './maintenance-reporter.js';
9
9
  import { initAuth, stopRefresh } from './auth-provider.js';
10
10
  import { registerBrain, startHeartbeatLoop, stopHeartbeatLoop } from './heartbeat-manager.js';
11
+ import { startEventQueue, stopEventQueue, flush as flushEventQueue } from './event-queue.js';
11
12
  // Track active message processing
12
13
  const processingMessages = new Set();
13
14
  // Per-thread completion signals
@@ -312,6 +313,7 @@ async function shutdown(signal) {
312
313
  logger.info(`${signal} received, shutting down gracefully...`);
313
314
  stopRefresh();
314
315
  stopHeartbeatLoop();
316
+ stopEventQueue();
315
317
  // Unblock all messages waiting on thread completion so they can exit
316
318
  for (const [threadId, signals] of threadCompletionSignals.entries()) {
317
319
  logger.info(`Unblocking ${signals.length} waiting message(s) on thread ${threadId}`);
@@ -332,6 +334,8 @@ async function shutdown(signal) {
332
334
  logger.info(`Waiting for ${processingMessages.size} message(s) to complete...`);
333
335
  await sleep(1000);
334
336
  }
337
+ // Final flush after all processes stopped — captures errors emitted during shutdown
338
+ await flushEventQueue();
335
339
  logger.info('Shutdown complete');
336
340
  process.exit(0);
337
341
  }
@@ -349,6 +353,7 @@ export async function startPoller() {
349
353
  logger.info(` Queue: ${config.SQS_QUEUE_URL}`);
350
354
  logger.info(` Sessions table: ${config.SESSIONS_TABLE}`);
351
355
  }
356
+ startEventQueue();
352
357
  await ensureDirectories();
353
358
  await ensureBaseBrain();
354
359
  startHeartbeatLoop(processMessage);
@@ -4,6 +4,7 @@ import { randomUUID } from 'crypto';
4
4
  import { config } from './config.js';
5
5
  import { logger } from './logger.js';
6
6
  import { isTokenMode, getCredentials, getAuthConfig } from './auth-provider.js';
7
+ import { emitError } from './event-queue.js';
7
8
  function createDocClient() {
8
9
  if (isTokenMode()) {
9
10
  const creds = getCredentials();
@@ -94,6 +95,7 @@ export async function acquireSessionLock(threadId, workspacePath) {
94
95
  const staleLockTimeout = config.STALE_LOCK_TIMEOUT_MS;
95
96
  if (lockAge > staleLockTimeout) {
96
97
  logger.warn(`Session ${threadId} has stale lock (age: ${Math.round(lockAge / 1000)}s), attempting recovery...`);
98
+ emitError('session', `Stale lock detected on session ${threadId} (age: ${Math.round(lockAge / 1000)}s)`, { severity: 'warn' });
97
99
  const released = await forceReleaseStalelock(threadId, staleLockTimeout);
98
100
  if (released) {
99
101
  logger.info(`Stale lock released for ${threadId}, retrying acquisition`);
@@ -164,6 +166,7 @@ export async function updateSessionId(threadId, lockToken, newSessionId) {
164
166
  catch (error) {
165
167
  if (error.name === 'ConditionalCheckFailedException') {
166
168
  logger.warn(`Lock token mismatch when updating session_id for ${threadId}`);
169
+ emitError('session', `Lock token mismatch when updating session_id for ${threadId}`, { severity: 'warn' });
167
170
  return;
168
171
  }
169
172
  throw error;
@@ -201,6 +204,7 @@ export async function releaseSessionLock(threadId, lockToken, newStatus = 'idle'
201
204
  catch (error) {
202
205
  if (error.name === 'ConditionalCheckFailedException') {
203
206
  logger.warn(`Lock token mismatch when releasing session ${threadId}`);
207
+ emitError('session', `Lock token mismatch when releasing session ${threadId}`, { severity: 'warn' });
204
208
  return;
205
209
  }
206
210
  throw error;
@@ -232,6 +236,7 @@ export async function forceReleaseStalelock(threadId, maxAgeMs) {
232
236
  },
233
237
  }));
234
238
  logger.warn(`Force released stale lock on session ${threadId} (age: ${lockAge}ms)`);
239
+ emitError('session', `Force released stale lock on session ${threadId}`, { severity: 'warn' });
235
240
  return true;
236
241
  }
237
242
  catch (error) {
@@ -1,5 +1,6 @@
1
1
  import { WebClient } from '@slack/web-api';
2
2
  import { logger } from './logger.js';
3
+ import { emitError } from './event-queue.js';
3
4
  // Cache user info to avoid repeated API calls
4
5
  const userCache = new Map();
5
6
  // Per-token Slack client cache
@@ -34,6 +35,7 @@ export async function getUserInfo(botToken, userId) {
34
35
  }
35
36
  else {
36
37
  logger.warn(`Failed to fetch user info for ${userId}:`, error?.data?.error || error);
38
+ emitError('slack', `Failed to fetch user info for ${userId}`, { severity: 'warn' });
37
39
  }
38
40
  }
39
41
  const fallback = { name: userId, realName: userId };
@@ -84,6 +86,7 @@ export async function addReaction(msg, emoji) {
84
86
  }
85
87
  catch (error) {
86
88
  logger.warn(`Failed to add reaction ${emoji}:`, error);
89
+ emitError('slack', `Failed to add reaction ${emoji}`, { severity: 'warn' });
87
90
  }
88
91
  }
89
92
  export async function setThreadStatus(msg, status) {
@@ -100,6 +103,7 @@ export async function setThreadStatus(msg, status) {
100
103
  }
101
104
  catch (error) {
102
105
  logger.warn(`Failed to set thread status: ${error}`);
106
+ emitError('slack', `Failed to set thread status`, { severity: 'warn' });
103
107
  }
104
108
  }
105
109
  export function startTypingIndicator(msg, statusText = 'is thinking...') {
@@ -2,6 +2,7 @@ import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand, ChangeMessageVi
2
2
  import { config } from './config.js';
3
3
  import { logger } from './logger.js';
4
4
  import { isTokenMode, getCredentials, getAuthConfig } from './auth-provider.js';
5
+ import { emitError } from './event-queue.js';
5
6
  function createSqsClient() {
6
7
  if (isTokenMode()) {
7
8
  const creds = getCredentials();
@@ -42,12 +43,14 @@ export async function pollMessages(maxMessages = 10) {
42
43
  }
43
44
  catch (error) {
44
45
  logger.error('Error polling SQS:', error);
46
+ emitError('sqs', `Error polling SQS: ${error instanceof Error ? error.message : String(error)}`);
45
47
  throw error;
46
48
  }
47
49
  }
48
50
  function parseMessage(msg) {
49
51
  if (!msg.Body || !msg.ReceiptHandle || !msg.MessageId) {
50
52
  logger.error('Invalid SQS message - missing required fields');
53
+ emitError('sqs', 'Invalid SQS message - missing required fields');
51
54
  return null;
52
55
  }
53
56
  try {
@@ -60,6 +63,7 @@ function parseMessage(msg) {
60
63
  }
61
64
  catch (error) {
62
65
  logger.error('Failed to parse message body:', error);
66
+ emitError('sqs', `Failed to parse message body: ${error instanceof Error ? error.message : String(error)}`);
63
67
  return null;
64
68
  }
65
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.1.57",
3
+ "version": "0.1.58",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {