@teamvibe/poller 0.8.1 → 0.8.2-rc.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.
- package/dist/auth-provider.d.ts +6 -0
- package/dist/auth-provider.js +52 -7
- package/dist/base-brain-metadata.js +6 -1
- package/dist/brain-manager.js +4 -3
- package/dist/claude-spawner.js +19 -25
- package/dist/maintenance-reporter.d.ts +8 -0
- package/dist/maintenance-reporter.js +20 -1
- package/dist/poller.js +24 -4
- package/dist/slack-client.d.ts +3 -1
- package/dist/slack-client.js +27 -5
- package/dist/startup-auth.d.ts +15 -0
- package/dist/startup-auth.js +137 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/auth-provider.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ interface AuthConfig {
|
|
|
9
9
|
sessionsTable: string;
|
|
10
10
|
region: string;
|
|
11
11
|
}
|
|
12
|
+
export declare class AuthHttpError extends Error {
|
|
13
|
+
readonly status: number;
|
|
14
|
+
constructor(status: number, _body?: string);
|
|
15
|
+
}
|
|
16
|
+
export declare class StartupConfigurationError extends Error {
|
|
17
|
+
}
|
|
12
18
|
export declare function initAuth(apiUrl: string, token: string): Promise<void>;
|
|
13
19
|
export declare function getCredentials(): AuthCredentials;
|
|
14
20
|
export declare function getAuthConfig(): AuthConfig;
|
package/dist/auth-provider.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import { POLLER_VERSION } from './config.js';
|
|
1
|
+
import { config, POLLER_VERSION } from './config.js';
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
3
|
import { emitError } from './event-queue.js';
|
|
4
|
+
import { getBaseBrainCommitSha } from './base-brain-metadata.js';
|
|
5
|
+
export class AuthHttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, _body) {
|
|
8
|
+
super(`Auth failed (${status})`);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = 'AuthHttpError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class StartupConfigurationError extends Error {
|
|
14
|
+
}
|
|
4
15
|
let currentCredentials = null;
|
|
5
16
|
let currentConfig = null;
|
|
6
17
|
let currentPollerId = null;
|
|
@@ -13,13 +24,16 @@ const REFRESH_INTERVAL_MS = 50 * 60 * 1000; // 50 minutes (before 1hr expiry)
|
|
|
13
24
|
const HEARTBEAT_INTERVAL_MS = 60 * 1000; // 1 minute
|
|
14
25
|
async function sendHeartbeat(apiUrl, token, version) {
|
|
15
26
|
try {
|
|
27
|
+
const baseBrainSha = await getBaseBrainCommitSha(config.BASE_BRAIN_PATH);
|
|
28
|
+
if (!baseBrainSha)
|
|
29
|
+
logger.warn('[Heartbeat] Could not read base-brain HEAD for pin readback');
|
|
16
30
|
const response = await fetch(`${apiUrl}/heartbeat`, {
|
|
17
31
|
method: 'POST',
|
|
18
32
|
headers: {
|
|
19
33
|
Authorization: `Bearer ${token}`,
|
|
20
34
|
'Content-Type': 'application/json',
|
|
21
35
|
},
|
|
22
|
-
body: JSON.stringify({ version }),
|
|
36
|
+
body: JSON.stringify({ version, baseBrainSha, baseBrainBranch: config.BASE_BRAIN_BRANCH }),
|
|
23
37
|
});
|
|
24
38
|
if (!response.ok) {
|
|
25
39
|
logger.warn(`Heartbeat failed (${response.status})`);
|
|
@@ -31,25 +45,56 @@ async function sendHeartbeat(apiUrl, token, version) {
|
|
|
31
45
|
emitError('auth', `Heartbeat error: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn' });
|
|
32
46
|
}
|
|
33
47
|
}
|
|
34
|
-
async function fetchCredentials(apiUrl, token) {
|
|
48
|
+
async function fetchCredentials(apiUrl, token, signal) {
|
|
35
49
|
const response = await fetch(`${apiUrl}/auth`, {
|
|
36
50
|
method: 'POST',
|
|
51
|
+
signal,
|
|
37
52
|
headers: {
|
|
38
53
|
Authorization: `Bearer ${token}`,
|
|
39
54
|
'Content-Type': 'application/json',
|
|
40
55
|
},
|
|
41
56
|
});
|
|
42
57
|
if (!response.ok) {
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
throw new AuthHttpError(response.status);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return await response.json();
|
|
45
62
|
}
|
|
46
|
-
|
|
63
|
+
catch {
|
|
64
|
+
throw new StartupConfigurationError('Auth response is not valid JSON');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function validateStartupAuthResponse(auth) {
|
|
68
|
+
const value = auth;
|
|
69
|
+
const authRecord = value;
|
|
70
|
+
const credentials = authRecord?.credentials;
|
|
71
|
+
const authConfig = authRecord?.config;
|
|
72
|
+
if (typeof authRecord?.pollerId !== 'string' || !authRecord.pollerId ||
|
|
73
|
+
typeof credentials?.accessKeyId !== 'string' || !credentials.accessKeyId ||
|
|
74
|
+
typeof credentials?.secretAccessKey !== 'string' || !credentials.secretAccessKey ||
|
|
75
|
+
typeof credentials?.sessionToken !== 'string' || !credentials.sessionToken ||
|
|
76
|
+
typeof credentials?.expiration !== 'string' || !credentials.expiration ||
|
|
77
|
+
typeof authConfig?.sqsQueueUrl !== 'string' || !authConfig.sqsQueueUrl ||
|
|
78
|
+
typeof authConfig?.sessionsTable !== 'string' || !authConfig.sessionsTable ||
|
|
79
|
+
typeof authConfig?.region !== 'string' || !authConfig.region)
|
|
80
|
+
throw new StartupConfigurationError('Auth response is missing or has malformed credentials/config');
|
|
47
81
|
}
|
|
48
82
|
export async function initAuth(apiUrl, token) {
|
|
83
|
+
if (!token.trim())
|
|
84
|
+
throw new StartupConfigurationError('TEAMVIBE_POLLER_TOKEN is missing or empty');
|
|
85
|
+
try {
|
|
86
|
+
const url = new URL(apiUrl);
|
|
87
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
88
|
+
throw new Error('unsupported protocol');
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new StartupConfigurationError('TEAMVIBE_API_URL is malformed');
|
|
92
|
+
}
|
|
49
93
|
logger.info('Authenticating with TeamVibe API...');
|
|
50
94
|
storedApiUrl = apiUrl;
|
|
51
95
|
storedToken = token;
|
|
52
|
-
const auth = await fetchCredentials(apiUrl, token);
|
|
96
|
+
const auth = await fetchCredentials(apiUrl, token, AbortSignal.timeout(15_000));
|
|
97
|
+
validateStartupAuthResponse(auth);
|
|
53
98
|
currentCredentials = auth.credentials;
|
|
54
99
|
currentConfig = auth.config;
|
|
55
100
|
currentPollerId = auth.pollerId;
|
|
@@ -3,6 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { execFile } from 'child_process';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
|
+
const GIT_HEAD_TIMEOUT_MS = 2_000;
|
|
6
7
|
/**
|
|
7
8
|
* Read the current HEAD commit SHA of a git repo. Returns null on any error
|
|
8
9
|
* (not a git repo, git missing, dir missing, etc.) so callers can attach a
|
|
@@ -10,7 +11,11 @@ const execFileAsync = promisify(execFile);
|
|
|
10
11
|
*/
|
|
11
12
|
export async function getBaseBrainCommitSha(repoDir) {
|
|
12
13
|
try {
|
|
13
|
-
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
|
|
14
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
|
|
15
|
+
cwd: repoDir,
|
|
16
|
+
timeout: GIT_HEAD_TIMEOUT_MS,
|
|
17
|
+
killSignal: 'SIGKILL',
|
|
18
|
+
});
|
|
14
19
|
const sha = stdout.trim();
|
|
15
20
|
return sha || null;
|
|
16
21
|
}
|
package/dist/brain-manager.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join, basename } from 'path';
|
|
|
6
6
|
import { config } from './config.js';
|
|
7
7
|
import { logger } from './logger.js';
|
|
8
8
|
import { emitError } from './event-queue.js';
|
|
9
|
+
import { startupFailureDetail } from './startup-auth.js';
|
|
9
10
|
const execAsync = promisify(exec);
|
|
10
11
|
// Clean env for git commands:
|
|
11
12
|
// - npm/npx sets GIT_ASKPASS which prevents credential helpers from working
|
|
@@ -257,7 +258,7 @@ async function updateBrainLocked(brainDir, brainId, branch) {
|
|
|
257
258
|
catch (error) {
|
|
258
259
|
// Pre-check itself failed (e.g. brainDir not a git repo). Fail-safe to skip;
|
|
259
260
|
// the next iteration will retry.
|
|
260
|
-
const errorMessage = error
|
|
261
|
+
const errorMessage = startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
|
|
261
262
|
logger.warn(`Brain ${brainId} pre-check failed, skipping update: ${errorMessage}`);
|
|
262
263
|
return;
|
|
263
264
|
}
|
|
@@ -271,7 +272,7 @@ async function updateBrainLocked(brainDir, brainId, branch) {
|
|
|
271
272
|
logger.info(`Brain ${brainId} updated successfully`);
|
|
272
273
|
}
|
|
273
274
|
catch (error) {
|
|
274
|
-
const errorMessage = error
|
|
275
|
+
const errorMessage = startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
|
|
275
276
|
logger.error(`Failed to update brain ${brainId}: ${errorMessage}`);
|
|
276
277
|
emitError('brain', `Failed to update brain ${brainId}: ${errorMessage}`, { context: { brainId, brainDir } });
|
|
277
278
|
}
|
|
@@ -283,7 +284,7 @@ async function updateBrainLocked(brainDir, brainId, branch) {
|
|
|
283
284
|
export async function ensureBaseBrain() {
|
|
284
285
|
const brainDir = config.BASE_BRAIN_PATH;
|
|
285
286
|
if (!existsSync(brainDir)) {
|
|
286
|
-
logger.info(`Cloning base brain
|
|
287
|
+
logger.info(`Cloning base brain (branch: ${config.BASE_BRAIN_BRANCH})...`);
|
|
287
288
|
await gitExec(`git clone --recurse-submodules --branch ${config.BASE_BRAIN_BRANCH} ${config.BASE_BRAIN_REPO} ${brainDir}`);
|
|
288
289
|
lastUpdateTimes.set('__base_brain__', Date.now());
|
|
289
290
|
logger.info('Base brain cloned successfully');
|
package/dist/claude-spawner.js
CHANGED
|
@@ -702,22 +702,13 @@ export function assistantCalledTool(event, toolName) {
|
|
|
702
702
|
// Status text is a one-line UI label, not a chat message — cap it well short
|
|
703
703
|
// of a full sentence.
|
|
704
704
|
const TYPING_STATUS_MAX_LEN = 100;
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
// `event.type === 'tool_use'` check — see assistantCalledTool's comment
|
|
713
|
-
// above for why the latter never matches the real stream shape.
|
|
714
|
-
//
|
|
715
|
-
// `assistantText`, when passed, is the caller's already-extracted
|
|
716
|
-
// extractAssistantText(event) result (the stdout handler computes it a few
|
|
717
|
-
// lines above for the end-of-turn guard) — avoids re-parsing the same event
|
|
718
|
-
// twice per line. Falls back to extracting it internally so the function
|
|
719
|
-
// stays a self-contained, directly-testable unit.
|
|
720
|
-
export function resolveTypingStatus(event, assistantText) {
|
|
705
|
+
function oneLineStatus(text) {
|
|
706
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
707
|
+
return oneLine.length > TYPING_STATUS_MAX_LEN
|
|
708
|
+
? oneLine.slice(0, TYPING_STATUS_MAX_LEN - 1) + '…'
|
|
709
|
+
: oneLine;
|
|
710
|
+
}
|
|
711
|
+
function resolveTypingStatusDetail(event, assistantText) {
|
|
721
712
|
if (event?.type !== 'assistant' || !Array.isArray(event.message?.content)) {
|
|
722
713
|
return null;
|
|
723
714
|
}
|
|
@@ -729,19 +720,22 @@ export function resolveTypingStatus(event, assistantText) {
|
|
|
729
720
|
if (text) {
|
|
730
721
|
// Collapse newlines/whitespace runs so a multi-line narration doesn't
|
|
731
722
|
// land a literal `\n` in the one-line status surface.
|
|
732
|
-
|
|
733
|
-
return oneLine.length > TYPING_STATUS_MAX_LEN
|
|
734
|
-
? oneLine.slice(0, TYPING_STATUS_MAX_LEN - 1) + '…'
|
|
735
|
-
: oneLine;
|
|
723
|
+
return { text: oneLineStatus(text), source: 'comment' };
|
|
736
724
|
}
|
|
737
725
|
for (const block of event.message.content) {
|
|
738
726
|
if (block?.type !== 'tool_use' || typeof block.name !== 'string')
|
|
739
727
|
continue;
|
|
740
728
|
const status = getStatusForTool(block.name);
|
|
741
|
-
if (status)
|
|
742
|
-
|
|
729
|
+
if (status) {
|
|
730
|
+
const safeToolName = /^(?:Bash|Read|Edit|Write|WebFetch|WebSearch|Grep|Glob|Agent)$/.test(block.name)
|
|
731
|
+
? block.name : 'other';
|
|
732
|
+
return { text: oneLineStatus(status), source: `tool:${safeToolName}` };
|
|
733
|
+
}
|
|
743
734
|
}
|
|
744
|
-
return 'Thinking...';
|
|
735
|
+
return { text: 'Thinking...', source: 'default' };
|
|
736
|
+
}
|
|
737
|
+
export function resolveTypingStatus(event, assistantText) {
|
|
738
|
+
return resolveTypingStatusDetail(event, assistantText)?.text ?? null;
|
|
745
739
|
}
|
|
746
740
|
async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
|
|
747
741
|
const slackContext = msg.response_context.slack;
|
|
@@ -1050,9 +1044,9 @@ async function runClaudeCode(msg, sessionLog, cwd, brainPath, sessionId, isFirst
|
|
|
1050
1044
|
}
|
|
1051
1045
|
// Auto-update typing indicator status based on tool usage (tv#369, tv#291)
|
|
1052
1046
|
if (typingIndicator) {
|
|
1053
|
-
const status =
|
|
1047
|
+
const status = resolveTypingStatusDetail(event, assistantText);
|
|
1054
1048
|
if (status) {
|
|
1055
|
-
typingIndicator.updateStatus(status);
|
|
1049
|
+
typingIndicator.updateStatus(status.text, status.source);
|
|
1056
1050
|
}
|
|
1057
1051
|
}
|
|
1058
1052
|
}
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** Best-effort runtime identity, sampled when each report is posted. */
|
|
2
|
+
export declare function collectReportProvenance(brainPath: string): Promise<{
|
|
3
|
+
pollerId: string | null;
|
|
4
|
+
pollerVersion: string;
|
|
5
|
+
baseBrainSha: string | null;
|
|
6
|
+
baseBrainBranch: string;
|
|
7
|
+
brainSha: string | null;
|
|
8
|
+
}>;
|
|
1
9
|
/**
|
|
2
10
|
* Scan for JSON maintenance reports in the brain's reports/ directory
|
|
3
11
|
* and POST each to the /events endpoint. Fire-and-forget — never throws.
|
|
@@ -3,10 +3,29 @@ import { join } from 'path';
|
|
|
3
3
|
import { execFile } from 'child_process';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
import { config, POLLER_VERSION } from './config.js';
|
|
6
|
+
import { getPollerId } from './auth-provider.js';
|
|
6
7
|
import { logger } from './logger.js';
|
|
7
8
|
import { emitError } from './event-queue.js';
|
|
8
9
|
import { getBaseBrainCommitSha, getBaseBrainAgeHours, } from './base-brain-metadata.js';
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
11
|
+
/** Best-effort runtime identity, sampled when each report is posted. */
|
|
12
|
+
export async function collectReportProvenance(brainPath) {
|
|
13
|
+
const [baseBrainSha, brainSha] = await Promise.all([
|
|
14
|
+
getBaseBrainCommitSha(config.BASE_BRAIN_PATH),
|
|
15
|
+
getBaseBrainCommitSha(brainPath),
|
|
16
|
+
]);
|
|
17
|
+
if (!baseBrainSha)
|
|
18
|
+
logger.warn('[MaintenanceReporter] Could not read base-brain HEAD for report provenance');
|
|
19
|
+
if (!brainSha)
|
|
20
|
+
logger.warn('[MaintenanceReporter] Could not read channel-brain HEAD for report provenance');
|
|
21
|
+
return {
|
|
22
|
+
pollerId: getPollerId(),
|
|
23
|
+
pollerVersion: POLLER_VERSION,
|
|
24
|
+
baseBrainSha,
|
|
25
|
+
baseBrainBranch: config.BASE_BRAIN_BRANCH,
|
|
26
|
+
brainSha,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
10
29
|
const OPERATION_TO_EVENT_TYPE = {
|
|
11
30
|
consolidation: 'maintenance.consolidation',
|
|
12
31
|
reflection: 'maintenance.reflection',
|
|
@@ -131,7 +150,7 @@ export async function postMaintenanceReports(brainPath, channelId) {
|
|
|
131
150
|
const payload = {
|
|
132
151
|
eventType,
|
|
133
152
|
channelId,
|
|
134
|
-
|
|
153
|
+
...(await collectReportProvenance(brainPath)),
|
|
135
154
|
baseBrainCommitSha,
|
|
136
155
|
baseBrainAgeHours,
|
|
137
156
|
metadata: report,
|
package/dist/poller.js
CHANGED
|
@@ -9,6 +9,7 @@ import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } fr
|
|
|
9
9
|
import { cleanupOrphanWorktreesForAllBrains, isWorktreeIsolationEnabled } from './worktree-manager.js';
|
|
10
10
|
import { postMaintenanceReports } from './maintenance-reporter.js';
|
|
11
11
|
import { initAuth, stopRefresh, getPollerId } from './auth-provider.js';
|
|
12
|
+
import { retryStartupAuth, retryStartupOperation, startupFailureDetail } from './startup-auth.js';
|
|
12
13
|
import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './maintenance-cron.js';
|
|
13
14
|
import { bootstrapTrackedBrains } from './brains-bootstrap.js';
|
|
14
15
|
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
|
|
@@ -870,8 +871,9 @@ export async function startPoller() {
|
|
|
870
871
|
logger.info(`TeamVibe Poller v${POLLER_VERSION} starting...`);
|
|
871
872
|
logger.info(` Max concurrent: ${config.MAX_CONCURRENT_SESSIONS}`);
|
|
872
873
|
logger.info(` Brains path: ${config.BRAINS_PATH}`);
|
|
873
|
-
|
|
874
|
-
process.on('
|
|
874
|
+
const startupAbort = new AbortController();
|
|
875
|
+
process.on('SIGINT', () => { startupAbort.abort(); void shutdown('SIGINT'); });
|
|
876
|
+
process.on('SIGTERM', () => { startupAbort.abort(); void shutdown('SIGTERM'); });
|
|
875
877
|
// Last-resort handlers so a hard crash produces one tagged ERROR line (+ a
|
|
876
878
|
// best-effort event flush) instead of an untagged default Node stack trace —
|
|
877
879
|
// these are exactly the failures that never reach /events on their own.
|
|
@@ -899,7 +901,15 @@ export async function startPoller() {
|
|
|
899
901
|
});
|
|
900
902
|
// Token-based auth: fetch credentials before starting
|
|
901
903
|
if (config.TEAMVIBE_API_URL && config.TEAMVIBE_POLLER_TOKEN) {
|
|
902
|
-
|
|
904
|
+
try {
|
|
905
|
+
const ready = await retryStartupAuth(() => initAuth(config.TEAMVIBE_API_URL, config.TEAMVIBE_POLLER_TOKEN), startupAbort.signal, logger, Math.random, [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
|
|
906
|
+
if (!ready)
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
logger.error(`Startup authentication failed permanently: ${startupFailureDetail(error, 'StartupAuth', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO])}`);
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
903
913
|
const pollerId = getPollerId();
|
|
904
914
|
if (pollerId) {
|
|
905
915
|
setLogContext({ pollerId });
|
|
@@ -910,9 +920,19 @@ export async function startPoller() {
|
|
|
910
920
|
logger.info(` Queue: ${config.SQS_QUEUE_URL}`);
|
|
911
921
|
logger.info(` Sessions table: ${config.SESSIONS_TABLE}`);
|
|
912
922
|
}
|
|
923
|
+
if (startupAbort.signal.aborted)
|
|
924
|
+
return;
|
|
913
925
|
startEventQueue();
|
|
914
926
|
await ensureDirectories();
|
|
915
|
-
|
|
927
|
+
try {
|
|
928
|
+
const ready = await retryStartupOperation('BaseBrain', ensureBaseBrain, startupAbort.signal, logger, Math.random, [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
|
|
929
|
+
if (!ready)
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
catch (error) {
|
|
933
|
+
logger.error(`Startup base brain failed permanently: ${startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO])}`);
|
|
934
|
+
process.exit(1);
|
|
935
|
+
}
|
|
916
936
|
reconcileBgTasksOnBoot();
|
|
917
937
|
if (config.IDLE_RESTART_ENABLED) {
|
|
918
938
|
// Defaults off (tv#317 follow-up, DevGuru review): a silent disabled gate and an
|
package/dist/slack-client.d.ts
CHANGED
|
@@ -7,11 +7,13 @@ export declare function sendSlackMessage(msg: TeamVibeQueueMessage, text: string
|
|
|
7
7
|
export declare function sendSlackError(msg: TeamVibeQueueMessage, error: string): Promise<void>;
|
|
8
8
|
export declare function addReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
|
|
9
9
|
export declare function setThreadStatus(msg: TeamVibeQueueMessage, status: string): Promise<void>;
|
|
10
|
+
type TypingStatusSource = 'comment' | 'default' | `tool:${string}`;
|
|
10
11
|
export interface TypingIndicator {
|
|
11
12
|
/** Update the displayed status text */
|
|
12
|
-
updateStatus: (newStatus: string) => void;
|
|
13
|
+
updateStatus: (newStatus: string, source?: TypingStatusSource) => void;
|
|
13
14
|
/** Stop the typing indicator and clear the status */
|
|
14
15
|
stop: () => void;
|
|
15
16
|
}
|
|
16
17
|
export declare function startTypingIndicator(msg: TeamVibeQueueMessage, statusText?: string): TypingIndicator;
|
|
17
18
|
export declare function removeReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
|
|
19
|
+
export {};
|
package/dist/slack-client.js
CHANGED
|
@@ -94,21 +94,35 @@ export async function setThreadStatus(msg, status) {
|
|
|
94
94
|
return;
|
|
95
95
|
const { channel, thread_ts } = msg.response_context.slack;
|
|
96
96
|
const slack = getSlackClient(msg.teamvibe.botToken);
|
|
97
|
+
const safeStatus = neutralizeSlackMarkup(status);
|
|
97
98
|
try {
|
|
98
99
|
await slack.apiCall('assistant.threads.setStatus', {
|
|
99
100
|
channel_id: channel,
|
|
100
101
|
thread_ts,
|
|
101
|
-
status,
|
|
102
|
+
status: safeStatus,
|
|
103
|
+
...(safeStatus ? { loading_messages: [safeStatus] } : {}),
|
|
102
104
|
});
|
|
103
105
|
}
|
|
104
|
-
catch
|
|
105
|
-
|
|
106
|
+
catch {
|
|
107
|
+
// API errors may echo request fields; status can contain private narration.
|
|
108
|
+
logger.warn('Failed to set thread status');
|
|
106
109
|
emitError('slack', `Failed to set thread status`, { severity: 'warn' });
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
|
-
|
|
112
|
+
function neutralizeSlackMarkup(text) {
|
|
113
|
+
return text.replace(/[<>&]/g, (character) => ({ '<': '<', '>': '>', '&': '&' })[character]);
|
|
114
|
+
}
|
|
115
|
+
export function startTypingIndicator(msg, statusText = 'Thinking...') {
|
|
110
116
|
let currentStatus = statusText;
|
|
117
|
+
let stopped = false;
|
|
118
|
+
const logStatus = (status, source) => {
|
|
119
|
+
if (!status)
|
|
120
|
+
logger.info('[Typing] status cleared');
|
|
121
|
+
else
|
|
122
|
+
logger.info(`[Typing] status -> ${source} (${status.length} chars)`);
|
|
123
|
+
};
|
|
111
124
|
// Set initial status
|
|
125
|
+
logStatus(currentStatus, statusText === 'Thinking...' ? 'default' : 'comment');
|
|
112
126
|
setThreadStatus(msg, currentStatus);
|
|
113
127
|
// Keepalive every 3 seconds (Slack clears after ~2 min timeout)
|
|
114
128
|
let failures = 0;
|
|
@@ -125,14 +139,22 @@ export function startTypingIndicator(msg, statusText = 'is thinking...') {
|
|
|
125
139
|
}
|
|
126
140
|
}, 3000);
|
|
127
141
|
return {
|
|
128
|
-
updateStatus: (newStatus) => {
|
|
142
|
+
updateStatus: (newStatus, source = 'comment') => {
|
|
143
|
+
if (stopped)
|
|
144
|
+
return;
|
|
129
145
|
if (newStatus !== currentStatus) {
|
|
130
146
|
currentStatus = newStatus;
|
|
147
|
+
logStatus(currentStatus, source);
|
|
131
148
|
setThreadStatus(msg, currentStatus);
|
|
132
149
|
}
|
|
133
150
|
},
|
|
134
151
|
stop: () => {
|
|
152
|
+
if (stopped)
|
|
153
|
+
return;
|
|
154
|
+
stopped = true;
|
|
135
155
|
clearInterval(interval);
|
|
156
|
+
if (currentStatus)
|
|
157
|
+
logStatus('', 'default');
|
|
136
158
|
setThreadStatus(msg, '');
|
|
137
159
|
},
|
|
138
160
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface StartupAuthLogger {
|
|
2
|
+
warn(message: string): void;
|
|
3
|
+
error(message: string): void;
|
|
4
|
+
}
|
|
5
|
+
export declare class UnknownStartupFailureError extends Error {
|
|
6
|
+
readonly attempts: number;
|
|
7
|
+
constructor(attempts: number);
|
|
8
|
+
}
|
|
9
|
+
/** The only formatter used for startup retry and terminal failure logs. */
|
|
10
|
+
export declare function redactStartupText(text: string, configuredTokens?: readonly string[]): string;
|
|
11
|
+
export declare function startupFailureDetail(error: unknown, operation: string, configuredTokens?: readonly string[]): string;
|
|
12
|
+
export declare function isPermanentStartupFailure(error: unknown): boolean;
|
|
13
|
+
/** Returns false when shutdown cancels startup. Permanent errors propagate to the caller. */
|
|
14
|
+
export declare function retryStartupOperation(operation: string, run: () => Promise<void>, signal: AbortSignal, log: StartupAuthLogger, random?: () => number, configuredTokens?: readonly string[]): Promise<boolean>;
|
|
15
|
+
export declare function retryStartupAuth(authenticate: () => Promise<void>, signal: AbortSignal, log: StartupAuthLogger, random?: () => number, configuredTokens?: readonly string[]): Promise<boolean>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { AuthHttpError, StartupConfigurationError } from './auth-provider.js';
|
|
2
|
+
export class UnknownStartupFailureError extends Error {
|
|
3
|
+
attempts;
|
|
4
|
+
constructor(attempts) {
|
|
5
|
+
super(`unclassified startup failure after ${attempts} attempts`);
|
|
6
|
+
this.attempts = attempts;
|
|
7
|
+
this.name = 'UnknownStartupFailureError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const NETWORK_CODES = new Set([
|
|
11
|
+
'EAI_AGAIN', 'ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT',
|
|
12
|
+
'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT',
|
|
13
|
+
]);
|
|
14
|
+
function isTransient(error) {
|
|
15
|
+
if (error instanceof AuthHttpError)
|
|
16
|
+
return error.status === 429 || error.status >= 500;
|
|
17
|
+
if (error instanceof StartupConfigurationError)
|
|
18
|
+
return false;
|
|
19
|
+
if (!(error instanceof Error))
|
|
20
|
+
return false;
|
|
21
|
+
if (error.name === 'AbortError' || error.name === 'TimeoutError')
|
|
22
|
+
return true;
|
|
23
|
+
if (error instanceof TypeError && /fetch failed|network|timeout/i.test(error.message))
|
|
24
|
+
return true;
|
|
25
|
+
if (/could not resolve host|could not resolve hostname|temporary failure in name resolution|failed to connect|connection (timed out|refused|reset)|network is unreachable|(?:HTTP|response|returned error:?)\s*(?:429|5\d\d)\b/i.test(error.message))
|
|
26
|
+
return true;
|
|
27
|
+
let cause = error;
|
|
28
|
+
for (let depth = 0; depth < 4 && cause && typeof cause === 'object'; depth++) {
|
|
29
|
+
const code = cause.code;
|
|
30
|
+
if (typeof code === 'string' && NETWORK_CODES.has(code))
|
|
31
|
+
return true;
|
|
32
|
+
cause = cause.cause;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/** The only formatter used for startup retry and terminal failure logs. */
|
|
37
|
+
export function redactStartupText(text, configuredTokens = []) {
|
|
38
|
+
let safe = text
|
|
39
|
+
.replace(/https?:\/\/[^\s/@]+@/gi, 'https://[REDACTED]@')
|
|
40
|
+
.replace(/\b(?:Authorization\s*:\s*)?Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
|
|
41
|
+
.replace(/\bx-access-token\s*:\s*[^\s/@,;]+/gi, 'x-access-token:[REDACTED]');
|
|
42
|
+
for (const token of configuredTokens) {
|
|
43
|
+
if (token)
|
|
44
|
+
safe = safe.split(token).join('[REDACTED]');
|
|
45
|
+
}
|
|
46
|
+
return safe;
|
|
47
|
+
}
|
|
48
|
+
export function startupFailureDetail(error, operation, configuredTokens = []) {
|
|
49
|
+
let detail;
|
|
50
|
+
if (error instanceof AuthHttpError)
|
|
51
|
+
detail = `HTTP ${error.status}`;
|
|
52
|
+
else if (error instanceof StartupConfigurationError)
|
|
53
|
+
detail = 'invalid startup configuration';
|
|
54
|
+
else if (error instanceof UnknownStartupFailureError)
|
|
55
|
+
detail = error.message;
|
|
56
|
+
else if (operation === 'BaseBrain') {
|
|
57
|
+
const code = error && typeof error === 'object' ? error.code : undefined;
|
|
58
|
+
const exit = typeof code === 'number' ? code : 'unknown';
|
|
59
|
+
const message = error instanceof Error ? error.message : '';
|
|
60
|
+
const category = /could not resolve|name resolution|ENOTFOUND|EAI_AGAIN/i.test(message) ? 'DNS failure'
|
|
61
|
+
: /timed out|timeout|ETIMEDOUT/i.test(message) ? 'connection timeout'
|
|
62
|
+
: /failed to connect|connection refused|ECONNREFUSED/i.test(message) ? 'connection failure'
|
|
63
|
+
: /authentication failed|permission denied|403|401/i.test(message) ? 'authentication failure'
|
|
64
|
+
: 'git failure';
|
|
65
|
+
detail = `${category} (exit ${exit})`;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
let code = error;
|
|
69
|
+
for (let depth = 0; depth < 4 && code && typeof code === 'object'; depth++) {
|
|
70
|
+
const value = code.code;
|
|
71
|
+
if (typeof value === 'string' && NETWORK_CODES.has(value)) {
|
|
72
|
+
detail = value;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
code = code.cause;
|
|
76
|
+
}
|
|
77
|
+
detail ??= error instanceof TypeError && /fetch failed/i.test(error.message) ? 'fetch failed'
|
|
78
|
+
: error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name) ? error.name
|
|
79
|
+
: 'unclassified error';
|
|
80
|
+
}
|
|
81
|
+
return redactStartupText(detail ?? 'unclassified error', configuredTokens);
|
|
82
|
+
}
|
|
83
|
+
export function isPermanentStartupFailure(error) {
|
|
84
|
+
return error instanceof StartupConfigurationError ||
|
|
85
|
+
(error instanceof AuthHttpError && error.status !== 429 && error.status < 500);
|
|
86
|
+
}
|
|
87
|
+
function waitForBackoff(ms, signal) {
|
|
88
|
+
if (signal.aborted)
|
|
89
|
+
return Promise.resolve(false);
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const finish = (ready) => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
signal.removeEventListener('abort', onAbort);
|
|
94
|
+
resolve(ready);
|
|
95
|
+
};
|
|
96
|
+
const onAbort = () => finish(false);
|
|
97
|
+
const timer = setTimeout(() => finish(true), ms);
|
|
98
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
99
|
+
if (signal.aborted)
|
|
100
|
+
onAbort();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** Returns false when shutdown cancels startup. Permanent errors propagate to the caller. */
|
|
104
|
+
export async function retryStartupOperation(operation, run, signal, log, random = Math.random, configuredTokens = []) {
|
|
105
|
+
let attempt = 0;
|
|
106
|
+
while (!signal.aborted) {
|
|
107
|
+
attempt++;
|
|
108
|
+
try {
|
|
109
|
+
await run();
|
|
110
|
+
return !signal.aborted;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (signal.aborted)
|
|
114
|
+
return false;
|
|
115
|
+
if (isPermanentStartupFailure(error))
|
|
116
|
+
throw error;
|
|
117
|
+
const transient = isTransient(error);
|
|
118
|
+
if (!transient && attempt >= 5) {
|
|
119
|
+
log.error(`[${operation}] attempt ${attempt} failed: ${startupFailureDetail(error, operation, configuredTokens)}; unclassified failure limit reached`);
|
|
120
|
+
throw new UnknownStartupFailureError(attempt);
|
|
121
|
+
}
|
|
122
|
+
const baseMs = Math.min(1000 * 2 ** Math.min(attempt - 1, 6), 60_000);
|
|
123
|
+
const delayMs = Math.min(60_000, Math.round(baseMs * (1 + 0.2 * random())));
|
|
124
|
+
const message = `[${operation}] attempt ${attempt} failed: ${startupFailureDetail(error, operation, configuredTokens)}; retrying in ${delayMs}ms`;
|
|
125
|
+
if (transient)
|
|
126
|
+
log.warn(message);
|
|
127
|
+
else
|
|
128
|
+
log.error(message);
|
|
129
|
+
if (!await waitForBackoff(delayMs, signal))
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
export function retryStartupAuth(authenticate, signal, log, random = Math.random, configuredTokens = []) {
|
|
136
|
+
return retryStartupOperation('StartupAuth', authenticate, signal, log, random, configuredTokens);
|
|
137
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamvibe/poller",
|
|
3
|
-
"version": "0.8.1",
|
|
3
|
+
"version": "0.8.2-rc.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@teamvibe/poller",
|
|
9
|
-
"version": "0.8.1",
|
|
9
|
+
"version": "0.8.2-rc.1",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@aws-sdk/client-dynamodb": "3.1108.0",
|
|
12
12
|
"@aws-sdk/client-sqs": "3.1108.0",
|