@teamvibe/poller 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
+ import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
3
+ import { randomUUID } from 'crypto';
4
+ import { config } from './config.js';
5
+ import { logger } from './logger.js';
6
+ import { isTokenMode, getCredentials, getAuthConfig } from './auth-provider.js';
7
+ function createDocClient() {
8
+ if (isTokenMode()) {
9
+ const creds = getCredentials();
10
+ const authConfig = getAuthConfig();
11
+ const client = new DynamoDBClient({
12
+ region: authConfig.region,
13
+ credentials: {
14
+ accessKeyId: creds.accessKeyId,
15
+ secretAccessKey: creds.secretAccessKey,
16
+ sessionToken: creds.sessionToken,
17
+ },
18
+ });
19
+ return DynamoDBDocumentClient.from(client);
20
+ }
21
+ return DynamoDBDocumentClient.from(new DynamoDBClient({ region: config.AWS_REGION }));
22
+ }
23
+ function getTableName() {
24
+ if (isTokenMode()) {
25
+ return getAuthConfig().sessionsTable;
26
+ }
27
+ return config.SESSIONS_TABLE;
28
+ }
29
+ const TTL_DAYS = 7;
30
+ function buildKey(threadId) {
31
+ return {
32
+ PK: `thread#${threadId}`,
33
+ SK: 'SESSION',
34
+ };
35
+ }
36
+ function calculateTTL() {
37
+ return Math.floor(Date.now() / 1000) + TTL_DAYS * 24 * 60 * 60;
38
+ }
39
+ function generateLockToken() {
40
+ return randomUUID();
41
+ }
42
+ export async function getSession(threadId) {
43
+ const key = buildKey(threadId);
44
+ const result = await createDocClient().send(new GetCommand({
45
+ TableName: getTableName(),
46
+ Key: key,
47
+ }));
48
+ return result.Item || null;
49
+ }
50
+ export async function createSession(threadId, workspacePath) {
51
+ const key = buildKey(threadId);
52
+ const now = Date.now();
53
+ const lockToken = generateLockToken();
54
+ const session = {
55
+ ...key,
56
+ session_id: randomUUID(),
57
+ workspace_path: workspacePath,
58
+ status: 'processing',
59
+ lock_token: lockToken,
60
+ created_at: now,
61
+ last_activity: now,
62
+ message_count: 1,
63
+ ttl: calculateTTL(),
64
+ };
65
+ await createDocClient().send(new PutCommand({
66
+ TableName: getTableName(),
67
+ Item: session,
68
+ ConditionExpression: 'attribute_not_exists(PK)',
69
+ }));
70
+ logger.info(`Created new session for thread ${threadId}`);
71
+ return session;
72
+ }
73
+ export async function acquireSessionLock(threadId, workspacePath) {
74
+ const existingSession = await getSession(threadId);
75
+ // No existing session - create new one
76
+ if (!existingSession) {
77
+ try {
78
+ const session = await createSession(threadId, workspacePath);
79
+ return { success: true, session, lockToken: session.lock_token };
80
+ }
81
+ catch (error) {
82
+ if (error.name === 'ConditionalCheckFailedException') {
83
+ const session = await getSession(threadId);
84
+ if (session) {
85
+ return { success: false, session, lockToken: null };
86
+ }
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+ // Session is processing - check for stale lock
92
+ if (existingSession.status === 'processing') {
93
+ const lockAge = Date.now() - existingSession.last_activity;
94
+ const staleLockTimeout = config.STALE_LOCK_TIMEOUT_MS;
95
+ if (lockAge > staleLockTimeout) {
96
+ logger.warn(`Session ${threadId} has stale lock (age: ${Math.round(lockAge / 1000)}s), attempting recovery...`);
97
+ const released = await forceReleaseStalelock(threadId, staleLockTimeout);
98
+ if (released) {
99
+ logger.info(`Stale lock released for ${threadId}, retrying acquisition`);
100
+ return acquireSessionLock(threadId, workspacePath);
101
+ }
102
+ }
103
+ logger.debug(`Session ${threadId} is already processing (age: ${Math.round(lockAge / 1000)}s)`);
104
+ return { success: false, session: existingSession, lockToken: null };
105
+ }
106
+ // Session is idle/waiting - try to acquire lock
107
+ const newLockToken = generateLockToken();
108
+ const now = Date.now();
109
+ try {
110
+ await createDocClient().send(new UpdateCommand({
111
+ TableName: getTableName(),
112
+ Key: buildKey(threadId),
113
+ UpdateExpression: 'SET #status = :processing, lock_token = :newToken, last_activity = :now, message_count = message_count + :one, #ttl = :ttl',
114
+ ConditionExpression: '#status IN (:idle, :waiting) AND (lock_token = :oldToken OR attribute_not_exists(lock_token))',
115
+ ExpressionAttributeNames: {
116
+ '#status': 'status',
117
+ '#ttl': 'ttl',
118
+ },
119
+ ExpressionAttributeValues: {
120
+ ':processing': 'processing',
121
+ ':idle': 'idle',
122
+ ':waiting': 'waiting_for_input',
123
+ ':newToken': newLockToken,
124
+ ':oldToken': existingSession.lock_token,
125
+ ':now': now,
126
+ ':one': 1,
127
+ ':ttl': calculateTTL(),
128
+ },
129
+ }));
130
+ const updatedSession = {
131
+ ...existingSession,
132
+ status: 'processing',
133
+ lock_token: newLockToken,
134
+ last_activity: now,
135
+ message_count: existingSession.message_count + 1,
136
+ ttl: calculateTTL(),
137
+ };
138
+ logger.info(`Acquired lock on session ${threadId}`);
139
+ return { success: true, session: updatedSession, lockToken: newLockToken };
140
+ }
141
+ catch (error) {
142
+ if (error.name === 'ConditionalCheckFailedException') {
143
+ const session = await getSession(threadId);
144
+ logger.debug(`Failed to acquire lock on session ${threadId} - already locked`);
145
+ return { success: false, session: session || existingSession, lockToken: null };
146
+ }
147
+ throw error;
148
+ }
149
+ }
150
+ export async function releaseSessionLock(threadId, lockToken, newStatus = 'idle', lastMessageTs) {
151
+ const now = Date.now();
152
+ const updateExpression = lastMessageTs
153
+ ? 'SET #status = :newStatus, lock_token = :null, last_activity = :now, #ttl = :ttl, last_message_ts = :lastMessageTs'
154
+ : 'SET #status = :newStatus, lock_token = :null, last_activity = :now, #ttl = :ttl';
155
+ const expressionAttributeValues = {
156
+ ':newStatus': newStatus,
157
+ ':null': null,
158
+ ':now': now,
159
+ ':lockToken': lockToken,
160
+ ':ttl': calculateTTL(),
161
+ };
162
+ if (lastMessageTs) {
163
+ expressionAttributeValues[':lastMessageTs'] = lastMessageTs;
164
+ }
165
+ try {
166
+ await createDocClient().send(new UpdateCommand({
167
+ TableName: getTableName(),
168
+ Key: buildKey(threadId),
169
+ UpdateExpression: updateExpression,
170
+ ConditionExpression: 'lock_token = :lockToken',
171
+ ExpressionAttributeNames: {
172
+ '#status': 'status',
173
+ '#ttl': 'ttl',
174
+ },
175
+ ExpressionAttributeValues: expressionAttributeValues,
176
+ }));
177
+ logger.info(`Released lock on session ${threadId}, status: ${newStatus}`);
178
+ }
179
+ catch (error) {
180
+ if (error.name === 'ConditionalCheckFailedException') {
181
+ logger.warn(`Lock token mismatch when releasing session ${threadId}`);
182
+ return;
183
+ }
184
+ throw error;
185
+ }
186
+ }
187
+ export async function forceReleaseStalelock(threadId, maxAgeMs) {
188
+ const session = await getSession(threadId);
189
+ if (!session || session.status !== 'processing') {
190
+ return false;
191
+ }
192
+ const lockAge = Date.now() - session.last_activity;
193
+ if (lockAge < maxAgeMs) {
194
+ return false;
195
+ }
196
+ try {
197
+ await createDocClient().send(new UpdateCommand({
198
+ TableName: getTableName(),
199
+ Key: buildKey(threadId),
200
+ UpdateExpression: 'SET #status = :idle, lock_token = :null',
201
+ ConditionExpression: '#status = :processing AND last_activity = :lastActivity',
202
+ ExpressionAttributeNames: {
203
+ '#status': 'status',
204
+ },
205
+ ExpressionAttributeValues: {
206
+ ':idle': 'idle',
207
+ ':null': null,
208
+ ':processing': 'processing',
209
+ ':lastActivity': session.last_activity,
210
+ },
211
+ }));
212
+ logger.warn(`Force released stale lock on session ${threadId} (age: ${lockAge}ms)`);
213
+ return true;
214
+ }
215
+ catch (error) {
216
+ if (error.name === 'ConditionalCheckFailedException') {
217
+ return false;
218
+ }
219
+ throw error;
220
+ }
221
+ }
@@ -0,0 +1,11 @@
1
+ import type { TeamVibeQueueMessage } from './types.js';
2
+ export declare function getUserInfo(botToken: string, userId: string): Promise<{
3
+ name: string;
4
+ realName: string;
5
+ }>;
6
+ export declare function sendSlackMessage(msg: TeamVibeQueueMessage, text: string): Promise<void>;
7
+ export declare function sendSlackError(msg: TeamVibeQueueMessage, error: string): Promise<void>;
8
+ export declare function addReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
9
+ export declare function setThreadStatus(msg: TeamVibeQueueMessage, status: string): Promise<void>;
10
+ export declare function startTypingIndicator(msg: TeamVibeQueueMessage, statusText?: string): () => void;
11
+ export declare function removeReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
@@ -0,0 +1,139 @@
1
+ import { WebClient } from '@slack/web-api';
2
+ import { logger } from './logger.js';
3
+ // Cache user info to avoid repeated API calls
4
+ const userCache = new Map();
5
+ // Per-token Slack client cache
6
+ const slackClients = new Map();
7
+ function getSlackClient(botToken) {
8
+ let client = slackClients.get(botToken);
9
+ if (!client) {
10
+ client = new WebClient(botToken);
11
+ slackClients.set(botToken, client);
12
+ }
13
+ return client;
14
+ }
15
+ export async function getUserInfo(botToken, userId) {
16
+ const cached = userCache.get(userId);
17
+ if (cached)
18
+ return cached;
19
+ try {
20
+ const slack = getSlackClient(botToken);
21
+ const result = await slack.users.info({ user: userId });
22
+ if (result.user) {
23
+ const info = {
24
+ name: result.user.name || userId,
25
+ realName: result.user.real_name || result.user.name || userId,
26
+ };
27
+ userCache.set(userId, info);
28
+ return info;
29
+ }
30
+ }
31
+ catch (error) {
32
+ if (error?.data?.error === 'missing_scope') {
33
+ logger.debug('Missing users:read scope - using user ID as name');
34
+ }
35
+ else {
36
+ logger.warn(`Failed to fetch user info for ${userId}:`, error?.data?.error || error);
37
+ }
38
+ }
39
+ const fallback = { name: userId, realName: userId };
40
+ userCache.set(userId, fallback);
41
+ return fallback;
42
+ }
43
+ export async function sendSlackMessage(msg, text) {
44
+ if (!msg.response_context.slack) {
45
+ logger.error('No Slack context in message, cannot respond');
46
+ return;
47
+ }
48
+ const { channel, thread_ts } = msg.response_context.slack;
49
+ const slack = getSlackClient(msg.teamvibe.botToken);
50
+ await slack.chat.postMessage({
51
+ channel,
52
+ thread_ts,
53
+ text,
54
+ unfurl_links: false,
55
+ unfurl_media: false,
56
+ });
57
+ }
58
+ export async function sendSlackError(msg, error) {
59
+ if (!msg.response_context.slack)
60
+ return;
61
+ const { channel, thread_ts } = msg.response_context.slack;
62
+ const slack = getSlackClient(msg.teamvibe.botToken);
63
+ await slack.chat.postMessage({
64
+ channel,
65
+ thread_ts,
66
+ text: `Error processing your request:\n\`\`\`\n${error}\n\`\`\``,
67
+ });
68
+ }
69
+ export async function addReaction(msg, emoji) {
70
+ if (!msg.response_context.slack)
71
+ return;
72
+ const { channel, message_ts } = msg.response_context.slack;
73
+ const slack = getSlackClient(msg.teamvibe.botToken);
74
+ try {
75
+ await slack.reactions.add({
76
+ channel,
77
+ timestamp: message_ts,
78
+ name: emoji,
79
+ });
80
+ }
81
+ catch (error) {
82
+ logger.warn(`Failed to add reaction ${emoji}:`, error);
83
+ }
84
+ }
85
+ export async function setThreadStatus(msg, status) {
86
+ if (!msg.response_context.slack)
87
+ return;
88
+ const { channel, thread_ts } = msg.response_context.slack;
89
+ const slack = getSlackClient(msg.teamvibe.botToken);
90
+ try {
91
+ await slack.apiCall('assistant.threads.setStatus', {
92
+ channel_id: channel,
93
+ thread_ts,
94
+ status,
95
+ });
96
+ }
97
+ catch (error) {
98
+ logger.warn(`Failed to set thread status: ${error}`);
99
+ }
100
+ }
101
+ export function startTypingIndicator(msg, statusText = 'is thinking...') {
102
+ // Set initial status
103
+ setThreadStatus(msg, statusText);
104
+ // Keepalive every 3 seconds (Slack clears after ~2 min timeout)
105
+ let failures = 0;
106
+ const interval = setInterval(async () => {
107
+ try {
108
+ await setThreadStatus(msg, statusText);
109
+ failures = 0;
110
+ }
111
+ catch {
112
+ failures++;
113
+ if (failures >= 2) {
114
+ clearInterval(interval);
115
+ }
116
+ }
117
+ }, 3000);
118
+ // Return cleanup function
119
+ return () => {
120
+ clearInterval(interval);
121
+ setThreadStatus(msg, '');
122
+ };
123
+ }
124
+ export async function removeReaction(msg, emoji) {
125
+ if (!msg.response_context.slack)
126
+ return;
127
+ const { channel, message_ts } = msg.response_context.slack;
128
+ const slack = getSlackClient(msg.teamvibe.botToken);
129
+ try {
130
+ await slack.reactions.remove({
131
+ channel,
132
+ timestamp: message_ts,
133
+ name: emoji,
134
+ });
135
+ }
136
+ catch {
137
+ // Ignore errors
138
+ }
139
+ }
@@ -0,0 +1,10 @@
1
+ import type { TeamVibeQueueMessage } from './types.js';
2
+ export interface ReceivedMessage {
3
+ queueMessage: TeamVibeQueueMessage;
4
+ receiptHandle: string;
5
+ messageId: string;
6
+ }
7
+ export declare function pollMessages(maxMessages?: number): Promise<ReceivedMessage[]>;
8
+ export declare function deleteMessage(receiptHandle: string): Promise<void>;
9
+ export declare function extendVisibility(receiptHandle: string, timeoutSeconds: number): Promise<void>;
10
+ export declare function sendMessage(queueMessage: TeamVibeQueueMessage, messageGroupId: string, deduplicationId: string): Promise<void>;
@@ -0,0 +1,86 @@
1
+ import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand, ChangeMessageVisibilityCommand, SendMessageCommand, } from '@aws-sdk/client-sqs';
2
+ import { config } from './config.js';
3
+ import { logger } from './logger.js';
4
+ import { isTokenMode, getCredentials, getAuthConfig } from './auth-provider.js';
5
+ function createSqsClient() {
6
+ if (isTokenMode()) {
7
+ const creds = getCredentials();
8
+ const authConfig = getAuthConfig();
9
+ return new SQSClient({
10
+ region: authConfig.region,
11
+ credentials: {
12
+ accessKeyId: creds.accessKeyId,
13
+ secretAccessKey: creds.secretAccessKey,
14
+ sessionToken: creds.sessionToken,
15
+ },
16
+ });
17
+ }
18
+ return new SQSClient({ region: config.AWS_REGION });
19
+ }
20
+ function getQueueUrl() {
21
+ if (isTokenMode()) {
22
+ return getAuthConfig().sqsQueueUrl;
23
+ }
24
+ return config.SQS_QUEUE_URL;
25
+ }
26
+ export async function pollMessages(maxMessages = 10) {
27
+ logger.debug(`Polling SQS (max: ${maxMessages}, wait: ${config.POLL_WAIT_TIME_SECONDS}s)...`);
28
+ try {
29
+ const response = await createSqsClient().send(new ReceiveMessageCommand({
30
+ QueueUrl: getQueueUrl(),
31
+ MaxNumberOfMessages: Math.min(maxMessages, 10),
32
+ WaitTimeSeconds: config.POLL_WAIT_TIME_SECONDS,
33
+ VisibilityTimeout: config.VISIBILITY_TIMEOUT_SECONDS,
34
+ MessageAttributeNames: ['All'],
35
+ }));
36
+ if (!response.Messages || response.Messages.length === 0) {
37
+ logger.debug('No messages received from SQS');
38
+ return [];
39
+ }
40
+ logger.info(`Received ${response.Messages.length} raw message(s) from SQS`);
41
+ return response.Messages.map((msg) => parseMessage(msg)).filter((m) => m !== null);
42
+ }
43
+ catch (error) {
44
+ logger.error('Error polling SQS:', error);
45
+ throw error;
46
+ }
47
+ }
48
+ function parseMessage(msg) {
49
+ if (!msg.Body || !msg.ReceiptHandle || !msg.MessageId) {
50
+ logger.error('Invalid SQS message - missing required fields');
51
+ return null;
52
+ }
53
+ try {
54
+ const queueMessage = JSON.parse(msg.Body);
55
+ return {
56
+ queueMessage,
57
+ receiptHandle: msg.ReceiptHandle,
58
+ messageId: msg.MessageId,
59
+ };
60
+ }
61
+ catch (error) {
62
+ logger.error('Failed to parse message body:', error);
63
+ return null;
64
+ }
65
+ }
66
+ export async function deleteMessage(receiptHandle) {
67
+ await createSqsClient().send(new DeleteMessageCommand({
68
+ QueueUrl: getQueueUrl(),
69
+ ReceiptHandle: receiptHandle,
70
+ }));
71
+ }
72
+ export async function extendVisibility(receiptHandle, timeoutSeconds) {
73
+ await createSqsClient().send(new ChangeMessageVisibilityCommand({
74
+ QueueUrl: getQueueUrl(),
75
+ ReceiptHandle: receiptHandle,
76
+ VisibilityTimeout: timeoutSeconds,
77
+ }));
78
+ }
79
+ export async function sendMessage(queueMessage, messageGroupId, deduplicationId) {
80
+ await createSqsClient().send(new SendMessageCommand({
81
+ QueueUrl: getQueueUrl(),
82
+ MessageBody: JSON.stringify(queueMessage),
83
+ MessageGroupId: messageGroupId,
84
+ MessageDeduplicationId: deduplicationId,
85
+ }));
86
+ }
@@ -0,0 +1,58 @@
1
+ export interface TeamVibeQueueMessage {
2
+ id: string;
3
+ source: 'slack' | 'cron';
4
+ thread_id: string;
5
+ sender: {
6
+ id: string;
7
+ name: string;
8
+ };
9
+ text: string;
10
+ attachments: Array<{
11
+ url: string;
12
+ filename: string;
13
+ mimetype: string;
14
+ }>;
15
+ response_context: {
16
+ slack?: {
17
+ channel: string;
18
+ thread_ts: string;
19
+ message_ts: string;
20
+ };
21
+ };
22
+ type: 'message' | 'approval_response' | 'button_click';
23
+ approval?: {
24
+ action_id: string;
25
+ approved: boolean;
26
+ };
27
+ teamvibe: {
28
+ workspaceId: string;
29
+ botId: string;
30
+ botToken: string;
31
+ channelId: string;
32
+ persona: {
33
+ personaId: string;
34
+ name: string;
35
+ systemPrompt?: string;
36
+ knowledgeBase?: {
37
+ kbId: string;
38
+ gitRepoUrl: string;
39
+ branch: string;
40
+ claudePath: string;
41
+ };
42
+ };
43
+ };
44
+ }
45
+ export interface SessionRecord {
46
+ PK: string;
47
+ SK: string;
48
+ session_id: string;
49
+ workspace_path: string;
50
+ status: SessionStatus;
51
+ lock_token: string;
52
+ created_at: number;
53
+ last_activity: number;
54
+ message_count: number;
55
+ last_message_ts?: string;
56
+ ttl: number;
57
+ }
58
+ export type SessionStatus = 'processing' | 'idle' | 'waiting_for_input';
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@teamvibe/poller",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "bin": {
7
+ "teamvibe-poller": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "dev": "tsx watch src/index.ts",
14
+ "build": "tsc -p tsconfig.build.json",
15
+ "start": "node dist/index.js",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@aws-sdk/client-dynamodb": "^3.470.0",
23
+ "@aws-sdk/client-sqs": "^3.470.0",
24
+ "@aws-sdk/lib-dynamodb": "^3.470.0",
25
+ "@slack/web-api": "^7.0.0",
26
+ "dotenv": "^17.2.3",
27
+ "zod": "^3.22.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^20.10.5",
31
+ "tsx": "^4.7.0",
32
+ "typescript": "^5.3.3"
33
+ }
34
+ }