@teamvibe/poller 0.1.68 → 0.1.69
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/auto-update.d.ts +15 -0
- package/dist/auto-update.js +151 -0
- package/dist/cli/commands.js +7 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +195 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +3 -0
- package/dist/control-handler.d.ts +4 -0
- package/dist/control-handler.js +143 -0
- package/dist/control-handler.test.js +74 -0
- package/dist/index.js +1 -1
- package/dist/poller.js +53 -1
- package/dist/types.d.ts +7 -0
- package/package.json +2 -1
- package/dist/kb-manager.d.ts +0 -15
- package/dist/kb-manager.js +0 -61
- package/dist/mailbox-manager.d.ts +0 -26
- package/dist/mailbox-manager.js +0 -95
- package/dist/scripts/add-reaction.d.ts +0 -9
- package/dist/scripts/add-reaction.js +0 -22
- package/dist/scripts/lib/slack-client.d.ts +0 -16
- package/dist/scripts/lib/slack-client.js +0 -48
- package/dist/scripts/read-thread.d.ts +0 -9
- package/dist/scripts/read-thread.js +0 -26
- package/dist/scripts/remove-reaction.d.ts +0 -9
- package/dist/scripts/remove-reaction.js +0 -22
- package/dist/scripts/send-message.d.ts +0 -10
- package/dist/scripts/send-message.js +0 -31
- package/dist/scripts/set-status.d.ts +0 -8
- package/dist/scripts/set-status.js +0 -19
- package/dist/scripts/slack-tool.js +0 -155
- package/dist/scripts/upload-snippet.d.ts +0 -8
- package/dist/scripts/upload-snippet.js +0 -21
- /package/dist/{scripts/slack-tool.d.ts → control-handler.test.d.ts} +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare two semver versions. Returns:
|
|
3
|
+
* - 'patch' if only patch differs (1.2.3 → 1.2.4)
|
|
4
|
+
* - 'minor' if minor differs (1.2.3 → 1.3.0)
|
|
5
|
+
* - 'major' if major differs (1.2.3 → 2.0.0)
|
|
6
|
+
* - null if same or current is newer
|
|
7
|
+
*/
|
|
8
|
+
export declare function classifyUpdate(current: string, latest: string): 'patch' | 'minor' | 'major' | null;
|
|
9
|
+
/**
|
|
10
|
+
* Check for updates and apply if within policy.
|
|
11
|
+
* Called by the cron job defined in poller.ts.
|
|
12
|
+
*
|
|
13
|
+
* Returns true if an update was applied and poller should restart (exit 0).
|
|
14
|
+
*/
|
|
15
|
+
export declare function checkForUpdate(): Promise<boolean>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { config, POLLER_VERSION } from './config.js';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
import { emitEvent } from './event-queue.js';
|
|
5
|
+
import { doctor } from './cli/doctor.js';
|
|
6
|
+
const UPDATE_TIMEOUT_MS = 120_000; // 2 minutes
|
|
7
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
8
|
+
let consecutiveFailures = 0;
|
|
9
|
+
/**
|
|
10
|
+
* Compare two semver versions. Returns:
|
|
11
|
+
* - 'patch' if only patch differs (1.2.3 → 1.2.4)
|
|
12
|
+
* - 'minor' if minor differs (1.2.3 → 1.3.0)
|
|
13
|
+
* - 'major' if major differs (1.2.3 → 2.0.0)
|
|
14
|
+
* - null if same or current is newer
|
|
15
|
+
*/
|
|
16
|
+
export function classifyUpdate(current, latest) {
|
|
17
|
+
const [curMajor, curMinor, curPatch] = current.split('.').map(Number);
|
|
18
|
+
const [latMajor, latMinor, latPatch] = latest.split('.').map(Number);
|
|
19
|
+
if (latMajor > curMajor)
|
|
20
|
+
return 'major';
|
|
21
|
+
if (latMajor < curMajor)
|
|
22
|
+
return null;
|
|
23
|
+
if (latMinor > curMinor)
|
|
24
|
+
return 'minor';
|
|
25
|
+
if (latMinor < curMinor)
|
|
26
|
+
return null;
|
|
27
|
+
if (latPatch > curPatch)
|
|
28
|
+
return 'patch';
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fetch the latest version from npm registry.
|
|
33
|
+
*/
|
|
34
|
+
function getLatestVersion() {
|
|
35
|
+
try {
|
|
36
|
+
const output = execSync('npm view @teamvibe/poller version', {
|
|
37
|
+
encoding: 'utf-8',
|
|
38
|
+
timeout: 15_000,
|
|
39
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
+
});
|
|
41
|
+
return output.trim();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
logger.warn(`[AutoUpdate] Failed to check npm registry: ${error instanceof Error ? error.message : error}`);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Check for updates and apply if within policy.
|
|
50
|
+
* Called by the cron job defined in poller.ts.
|
|
51
|
+
*
|
|
52
|
+
* Returns true if an update was applied and poller should restart (exit 0).
|
|
53
|
+
*/
|
|
54
|
+
export async function checkForUpdate() {
|
|
55
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
56
|
+
logger.warn(`[AutoUpdate] Disabled — ${consecutiveFailures} consecutive failures`);
|
|
57
|
+
emitEvent('poller.autoupdate.disabled', 'system', {
|
|
58
|
+
reason: 'max-failures-reached',
|
|
59
|
+
failures: consecutiveFailures,
|
|
60
|
+
currentVersion: POLLER_VERSION,
|
|
61
|
+
});
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
logger.info(`[AutoUpdate] Checking for updates (current: ${POLLER_VERSION}, policy: ${config.AUTO_UPDATE_POLICY})`);
|
|
65
|
+
// Check npm registry for latest version
|
|
66
|
+
const latestVersion = getLatestVersion();
|
|
67
|
+
if (!latestVersion) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const updateType = classifyUpdate(POLLER_VERSION, latestVersion);
|
|
71
|
+
if (!updateType) {
|
|
72
|
+
logger.info(`[AutoUpdate] Already on latest version (${POLLER_VERSION})`);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
// Check if update type is within policy
|
|
76
|
+
const policyAllows = config.AUTO_UPDATE_POLICY === 'minor'
|
|
77
|
+
? updateType === 'patch' || updateType === 'minor'
|
|
78
|
+
: updateType === 'patch'; // policy === 'patch'
|
|
79
|
+
if (!policyAllows) {
|
|
80
|
+
logger.info(`[AutoUpdate] ${updateType} update available (${POLLER_VERSION} → ${latestVersion}) but policy is "${config.AUTO_UPDATE_POLICY}", skipping`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
logger.info(`[AutoUpdate] ${updateType} update available: ${POLLER_VERSION} → ${latestVersion}`);
|
|
84
|
+
// Step 1: Pre-check
|
|
85
|
+
logger.info('[AutoUpdate] Running pre-update health check...');
|
|
86
|
+
const preCheckOk = await doctor();
|
|
87
|
+
if (!preCheckOk) {
|
|
88
|
+
logger.warn('[AutoUpdate] Pre-check failed, skipping update');
|
|
89
|
+
consecutiveFailures++;
|
|
90
|
+
emitEvent('poller.autoupdate.failed', 'system', {
|
|
91
|
+
reason: 'pre-check-failed',
|
|
92
|
+
currentVersion: POLLER_VERSION,
|
|
93
|
+
targetVersion: latestVersion,
|
|
94
|
+
});
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
// Step 2: Install
|
|
98
|
+
logger.info(`[AutoUpdate] Installing @teamvibe/poller@${latestVersion}...`);
|
|
99
|
+
try {
|
|
100
|
+
execSync(`npm install -g @teamvibe/poller@${latestVersion}`, {
|
|
101
|
+
timeout: UPDATE_TIMEOUT_MS,
|
|
102
|
+
stdio: 'pipe',
|
|
103
|
+
encoding: 'utf-8',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
108
|
+
logger.error(`[AutoUpdate] Install failed: ${errorMsg}`);
|
|
109
|
+
consecutiveFailures++;
|
|
110
|
+
emitEvent('poller.autoupdate.failed', 'system', {
|
|
111
|
+
reason: 'install-failed',
|
|
112
|
+
currentVersion: POLLER_VERSION,
|
|
113
|
+
targetVersion: latestVersion,
|
|
114
|
+
error: errorMsg,
|
|
115
|
+
});
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
// Step 3: Post-check
|
|
119
|
+
logger.info('[AutoUpdate] Running post-update health check...');
|
|
120
|
+
const postCheckOk = await doctor();
|
|
121
|
+
if (!postCheckOk) {
|
|
122
|
+
logger.warn(`[AutoUpdate] Post-check failed, rolling back to ${POLLER_VERSION}...`);
|
|
123
|
+
try {
|
|
124
|
+
execSync(`npm install -g @teamvibe/poller@${POLLER_VERSION}`, {
|
|
125
|
+
timeout: UPDATE_TIMEOUT_MS,
|
|
126
|
+
stdio: 'pipe',
|
|
127
|
+
encoding: 'utf-8',
|
|
128
|
+
});
|
|
129
|
+
logger.info(`[AutoUpdate] Rolled back to ${POLLER_VERSION}`);
|
|
130
|
+
}
|
|
131
|
+
catch (rollbackError) {
|
|
132
|
+
logger.error(`[AutoUpdate] CRITICAL: Rollback failed: ${rollbackError instanceof Error ? rollbackError.message : rollbackError}`);
|
|
133
|
+
}
|
|
134
|
+
consecutiveFailures++;
|
|
135
|
+
emitEvent('poller.autoupdate.rolledback', 'system', {
|
|
136
|
+
currentVersion: POLLER_VERSION,
|
|
137
|
+
targetVersion: latestVersion,
|
|
138
|
+
reason: 'post-check-failed',
|
|
139
|
+
});
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
// Success
|
|
143
|
+
consecutiveFailures = 0;
|
|
144
|
+
logger.info(`[AutoUpdate] Successfully updated ${POLLER_VERSION} → ${latestVersion}. Restart required.`);
|
|
145
|
+
emitEvent('poller.autoupdate.success', 'system', {
|
|
146
|
+
previousVersion: POLLER_VERSION,
|
|
147
|
+
newVersion: latestVersion,
|
|
148
|
+
updateType,
|
|
149
|
+
});
|
|
150
|
+
return true;
|
|
151
|
+
}
|
package/dist/cli/commands.js
CHANGED
|
@@ -5,6 +5,7 @@ import { update } from './update.js';
|
|
|
5
5
|
import { logs } from './logs.js';
|
|
6
6
|
import { start, stop, restart, status } from './service.js';
|
|
7
7
|
import { reset } from './reset.js';
|
|
8
|
+
import { doctor } from './doctor.js';
|
|
8
9
|
function showHelp() {
|
|
9
10
|
console.log(`TeamVibe Poller
|
|
10
11
|
|
|
@@ -16,6 +17,7 @@ Commands:
|
|
|
16
17
|
Flags: --token <token> --claude-token <token> [--api-url <url>] [--max-concurrent <n>]
|
|
17
18
|
uninstall Remove the launchd service
|
|
18
19
|
update Update to the latest version and restart
|
|
20
|
+
doctor Run health checks (verify installation & configuration)
|
|
19
21
|
start Start the installed service
|
|
20
22
|
stop Stop the installed service
|
|
21
23
|
restart Restart the installed service
|
|
@@ -57,6 +59,11 @@ export async function handleCommand(command) {
|
|
|
57
59
|
case 'restart':
|
|
58
60
|
restart();
|
|
59
61
|
break;
|
|
62
|
+
case 'doctor': {
|
|
63
|
+
const healthy = await doctor();
|
|
64
|
+
process.exit(healthy ? 0 : 1);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
60
67
|
case 'reset':
|
|
61
68
|
await reset();
|
|
62
69
|
break;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function doctor(): Promise<boolean>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { PLIST_PATH, SERVICE_LABEL, TEAMVIBE_DIR, LOGS_DIR } from './plist.js';
|
|
5
|
+
function check(name, fn) {
|
|
6
|
+
try {
|
|
7
|
+
const result = fn();
|
|
8
|
+
if (result === true) {
|
|
9
|
+
return { name, ok: true, message: 'OK' };
|
|
10
|
+
}
|
|
11
|
+
return { name, ok: true, message: result };
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
return { name, ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function checkNodeVersion() {
|
|
18
|
+
return check('Node.js version', () => {
|
|
19
|
+
const version = process.version;
|
|
20
|
+
const major = parseInt(version.slice(1).split('.')[0], 10);
|
|
21
|
+
if (major < 18) {
|
|
22
|
+
throw new Error(`Node.js ${version} is below minimum (v18). Please upgrade.`);
|
|
23
|
+
}
|
|
24
|
+
return `${version} (>= v18)`;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function checkTeamVibeDir() {
|
|
28
|
+
return check('TeamVibe directory', () => {
|
|
29
|
+
if (!fs.existsSync(TEAMVIBE_DIR)) {
|
|
30
|
+
throw new Error(`${TEAMVIBE_DIR} does not exist. Run 'poller install' first.`);
|
|
31
|
+
}
|
|
32
|
+
return TEAMVIBE_DIR;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function checkEnvFile() {
|
|
36
|
+
return check('Environment config', () => {
|
|
37
|
+
const envPath = path.join(TEAMVIBE_DIR, '.env');
|
|
38
|
+
if (!fs.existsSync(envPath)) {
|
|
39
|
+
throw new Error(`${envPath} not found. Run 'poller install' to configure.`);
|
|
40
|
+
}
|
|
41
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
42
|
+
const hasToken = content.includes('TEAMVIBE_POLLER_TOKEN');
|
|
43
|
+
const hasDirect = content.includes('SQS_QUEUE_URL') && content.includes('SESSIONS_TABLE');
|
|
44
|
+
if (!hasToken && !hasDirect) {
|
|
45
|
+
throw new Error('Missing auth configuration (TEAMVIBE_POLLER_TOKEN or SQS_QUEUE_URL + SESSIONS_TABLE)');
|
|
46
|
+
}
|
|
47
|
+
return hasToken ? 'Token mode configured' : 'Direct mode configured';
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function checkLaunchdPlist() {
|
|
51
|
+
return check('Launchd plist', () => {
|
|
52
|
+
if (process.platform !== 'darwin') {
|
|
53
|
+
return 'Skipped (not macOS)';
|
|
54
|
+
}
|
|
55
|
+
if (!fs.existsSync(PLIST_PATH)) {
|
|
56
|
+
throw new Error(`Plist not found at ${PLIST_PATH}. Run 'poller install'.`);
|
|
57
|
+
}
|
|
58
|
+
const content = fs.readFileSync(PLIST_PATH, 'utf-8');
|
|
59
|
+
if (!content.includes('<key>KeepAlive</key>')) {
|
|
60
|
+
throw new Error('KeepAlive not set in plist — poller will not auto-restart on exit');
|
|
61
|
+
}
|
|
62
|
+
if (content.includes('/_npx/')) {
|
|
63
|
+
throw new Error('Plist references stale npx cache path. Run \'poller update\' to fix.');
|
|
64
|
+
}
|
|
65
|
+
return 'Installed with KeepAlive';
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function checkServiceRunning() {
|
|
69
|
+
return check('Service status', () => {
|
|
70
|
+
if (process.platform !== 'darwin') {
|
|
71
|
+
return 'Skipped (not macOS)';
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const output = execSync(`launchctl list | grep ${SERVICE_LABEL}`, { encoding: 'utf-8' });
|
|
75
|
+
if (output.trim()) {
|
|
76
|
+
const parts = output.trim().split('\t');
|
|
77
|
+
const pid = parts[0];
|
|
78
|
+
const lastExitStatus = parts[1];
|
|
79
|
+
if (pid === '-') {
|
|
80
|
+
throw new Error(`Service loaded but not running (last exit: ${lastExitStatus})`);
|
|
81
|
+
}
|
|
82
|
+
return `Running (PID: ${pid})`;
|
|
83
|
+
}
|
|
84
|
+
throw new Error('Service not loaded');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error instanceof Error && error.message.includes('Service')) {
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
throw new Error('Service not loaded in launchd');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function checkLogsDir() {
|
|
95
|
+
return check('Logs directory', () => {
|
|
96
|
+
if (!fs.existsSync(LOGS_DIR)) {
|
|
97
|
+
throw new Error(`${LOGS_DIR} does not exist`);
|
|
98
|
+
}
|
|
99
|
+
const stdoutLog = path.join(LOGS_DIR, 'poller.stdout.log');
|
|
100
|
+
const stderrLog = path.join(LOGS_DIR, 'poller.stderr.log');
|
|
101
|
+
const hasStdout = fs.existsSync(stdoutLog);
|
|
102
|
+
const hasStderr = fs.existsSync(stderrLog);
|
|
103
|
+
if (!hasStdout && !hasStderr) {
|
|
104
|
+
return 'Directory exists but no log files yet';
|
|
105
|
+
}
|
|
106
|
+
return 'OK';
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function checkClaudeCli() {
|
|
110
|
+
return check('Claude CLI', () => {
|
|
111
|
+
try {
|
|
112
|
+
const output = execSync('which claude', { encoding: 'utf-8' }).trim();
|
|
113
|
+
if (!output) {
|
|
114
|
+
throw new Error('Claude CLI not found in PATH');
|
|
115
|
+
}
|
|
116
|
+
// Try to get version
|
|
117
|
+
try {
|
|
118
|
+
const version = execSync('claude --version 2>/dev/null', { encoding: 'utf-8' }).trim();
|
|
119
|
+
return `${output} (${version})`;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return output;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new Error('Claude CLI not found in PATH. Install Claude Code first.');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function checkBrainsDir() {
|
|
131
|
+
return check('Brains directory', () => {
|
|
132
|
+
const brainsPath = path.join(TEAMVIBE_DIR, 'brains');
|
|
133
|
+
if (!fs.existsSync(brainsPath)) {
|
|
134
|
+
return 'Not yet created (will be created on first message)';
|
|
135
|
+
}
|
|
136
|
+
const brains = fs.readdirSync(brainsPath).filter(f => {
|
|
137
|
+
const stat = fs.statSync(path.join(brainsPath, f));
|
|
138
|
+
return stat.isDirectory();
|
|
139
|
+
});
|
|
140
|
+
return `${brains.length} brain(s) cloned`;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function checkDiskSpace() {
|
|
144
|
+
return check('Disk space', () => {
|
|
145
|
+
try {
|
|
146
|
+
const output = execSync(`df -h "${TEAMVIBE_DIR}" | tail -1`, { encoding: 'utf-8' }).trim();
|
|
147
|
+
const parts = output.split(/\s+/);
|
|
148
|
+
const available = parts[3];
|
|
149
|
+
const capacityStr = parts[4]?.replace('%', '');
|
|
150
|
+
const capacity = parseInt(capacityStr || '0', 10);
|
|
151
|
+
if (capacity > 95) {
|
|
152
|
+
throw new Error(`Disk ${capacity}% full — only ${available} available`);
|
|
153
|
+
}
|
|
154
|
+
if (capacity > 85) {
|
|
155
|
+
return `Warning: ${capacity}% used (${available} available)`;
|
|
156
|
+
}
|
|
157
|
+
return `${available} available (${capacity}% used)`;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error instanceof Error && error.message.includes('Disk'))
|
|
161
|
+
throw error;
|
|
162
|
+
throw new Error('Could not check disk space');
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
export async function doctor() {
|
|
167
|
+
console.log('TeamVibe Poller - Health Check\n');
|
|
168
|
+
const results = [
|
|
169
|
+
checkNodeVersion(),
|
|
170
|
+
checkTeamVibeDir(),
|
|
171
|
+
checkEnvFile(),
|
|
172
|
+
checkLaunchdPlist(),
|
|
173
|
+
checkServiceRunning(),
|
|
174
|
+
checkLogsDir(),
|
|
175
|
+
checkClaudeCli(),
|
|
176
|
+
checkBrainsDir(),
|
|
177
|
+
checkDiskSpace(),
|
|
178
|
+
];
|
|
179
|
+
let hasFailure = false;
|
|
180
|
+
for (const result of results) {
|
|
181
|
+
const icon = result.ok ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
|
182
|
+
console.log(` ${icon} ${result.name}: ${result.message}`);
|
|
183
|
+
if (!result.ok)
|
|
184
|
+
hasFailure = true;
|
|
185
|
+
}
|
|
186
|
+
console.log('');
|
|
187
|
+
if (hasFailure) {
|
|
188
|
+
console.log('\x1b[31mSome checks failed. Fix the issues above before proceeding.\x1b[0m');
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
console.log('\x1b[32mAll checks passed. Poller is healthy.\x1b[0m');
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ declare const configSchema: z.ZodObject<{
|
|
|
30
30
|
MAINTENANCE_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
|
|
31
31
|
MAINTENANCE_CHECK_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
|
|
32
32
|
MAINTENANCE_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
|
|
33
|
+
AUTO_UPDATE_POLICY: z.ZodDefault<z.ZodEnum<["patch", "minor", "off"]>>;
|
|
34
|
+
AUTO_UPDATE_SCHEDULE: z.ZodDefault<z.ZodString>;
|
|
33
35
|
}, "strip", z.ZodTypeAny, {
|
|
34
36
|
AWS_REGION: string;
|
|
35
37
|
TEAMVIBE_API_URL: string;
|
|
@@ -57,6 +59,8 @@ declare const configSchema: z.ZodObject<{
|
|
|
57
59
|
MAINTENANCE_INTERVAL_MS: number;
|
|
58
60
|
MAINTENANCE_CHECK_INTERVAL_MS: number;
|
|
59
61
|
MAINTENANCE_ENABLED: boolean;
|
|
62
|
+
AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
|
|
63
|
+
AUTO_UPDATE_SCHEDULE: string;
|
|
60
64
|
SQS_QUEUE_URL?: string | undefined;
|
|
61
65
|
SESSIONS_TABLE?: string | undefined;
|
|
62
66
|
TEAMVIBE_POLLER_TOKEN?: string | undefined;
|
|
@@ -90,6 +94,8 @@ declare const configSchema: z.ZodObject<{
|
|
|
90
94
|
MAINTENANCE_INTERVAL_MS?: number | undefined;
|
|
91
95
|
MAINTENANCE_CHECK_INTERVAL_MS?: number | undefined;
|
|
92
96
|
MAINTENANCE_ENABLED?: string | undefined;
|
|
97
|
+
AUTO_UPDATE_POLICY?: "patch" | "minor" | "off" | undefined;
|
|
98
|
+
AUTO_UPDATE_SCHEDULE?: string | undefined;
|
|
93
99
|
}>;
|
|
94
100
|
export type Config = z.infer<typeof configSchema>;
|
|
95
101
|
export declare function loadConfig(): Config;
|
|
@@ -120,6 +126,8 @@ export declare const config: {
|
|
|
120
126
|
MAINTENANCE_INTERVAL_MS: number;
|
|
121
127
|
MAINTENANCE_CHECK_INTERVAL_MS: number;
|
|
122
128
|
MAINTENANCE_ENABLED: boolean;
|
|
129
|
+
AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
|
|
130
|
+
AUTO_UPDATE_SCHEDULE: string;
|
|
123
131
|
SQS_QUEUE_URL?: string | undefined;
|
|
124
132
|
SESSIONS_TABLE?: string | undefined;
|
|
125
133
|
TEAMVIBE_POLLER_TOKEN?: string | undefined;
|
package/dist/config.js
CHANGED
|
@@ -46,6 +46,9 @@ const configSchema = z.object({
|
|
|
46
46
|
MAINTENANCE_INTERVAL_MS: z.coerce.number().default(21600000), // 6 hours between maintenance per brain
|
|
47
47
|
MAINTENANCE_CHECK_INTERVAL_MS: z.coerce.number().default(300000), // 5 min check loop
|
|
48
48
|
MAINTENANCE_ENABLED: z.string().default('true').transform((v) => v.toLowerCase() === 'true'),
|
|
49
|
+
// Auto-update settings
|
|
50
|
+
AUTO_UPDATE_POLICY: z.enum(['patch', 'minor', 'off']).default('patch'),
|
|
51
|
+
AUTO_UPDATE_SCHEDULE: z.string().default('03:00'), // HH:MM in UTC — daily check time
|
|
49
52
|
});
|
|
50
53
|
export function loadConfig() {
|
|
51
54
|
const result = configSchema.safeParse(process.env);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
import { emitEvent, flush as flushEventQueue } from './event-queue.js';
|
|
5
|
+
import { doctor } from './cli/doctor.js';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const pkg = require('../package.json');
|
|
8
|
+
const UPDATE_TIMEOUT_MS = 120_000; // 2 minutes
|
|
9
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
10
|
+
export function isControlMessage(message) {
|
|
11
|
+
if (typeof message !== 'object' || message === null)
|
|
12
|
+
return false;
|
|
13
|
+
const msg = message;
|
|
14
|
+
return msg['source'] === 'control' && (msg['action'] === 'restart' || msg['action'] === 'update');
|
|
15
|
+
}
|
|
16
|
+
export async function handleControlMessage(control) {
|
|
17
|
+
logger.info(`[Control] Received action: ${control.action}`);
|
|
18
|
+
if (control.action === 'restart') {
|
|
19
|
+
await handleRestart();
|
|
20
|
+
}
|
|
21
|
+
else if (control.action === 'update') {
|
|
22
|
+
await handleUpdate(control.targetVersion);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function handleRestart() {
|
|
26
|
+
logger.info('[Control] Initiating graceful restart...');
|
|
27
|
+
emitEvent('poller.restart', 'system', { version: pkg.version });
|
|
28
|
+
await flushEventQueue();
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
async function handleUpdate(targetVersion) {
|
|
32
|
+
const currentVersion = pkg.version;
|
|
33
|
+
logger.info(`[Control] Starting safe update. Current version: ${currentVersion}`);
|
|
34
|
+
// Defense-in-depth: sanitize targetVersion even though resolver validates too
|
|
35
|
+
if (targetVersion && !SEMVER_RE.test(targetVersion)) {
|
|
36
|
+
logger.error(`[Control] Invalid targetVersion "${targetVersion}" — must be semver (e.g. 1.2.3)`);
|
|
37
|
+
emitEvent('poller.update.failed', 'system', { reason: 'invalid-version', currentVersion, targetVersion });
|
|
38
|
+
await flushEventQueue();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// Step 1: Pre-check — verify current state is healthy
|
|
42
|
+
logger.info('[Control] Running pre-update health check...');
|
|
43
|
+
const preCheckOk = await doctor();
|
|
44
|
+
if (!preCheckOk) {
|
|
45
|
+
logger.error('[Control] Pre-update doctor check failed. Aborting update.');
|
|
46
|
+
emitEvent('poller.update.failed', 'system', {
|
|
47
|
+
reason: 'pre-check-failed',
|
|
48
|
+
currentVersion,
|
|
49
|
+
});
|
|
50
|
+
await flushEventQueue();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Step 2: Determine target version
|
|
54
|
+
// Without explicit targetVersion, find latest and only allow patch updates
|
|
55
|
+
let resolvedVersion = targetVersion;
|
|
56
|
+
if (!resolvedVersion) {
|
|
57
|
+
try {
|
|
58
|
+
resolvedVersion = execSync('npm view @teamvibe/poller version', {
|
|
59
|
+
encoding: 'utf-8',
|
|
60
|
+
timeout: 15_000,
|
|
61
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
62
|
+
}).trim();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
logger.error('[Control] Failed to check npm registry for latest version');
|
|
66
|
+
emitEvent('poller.update.failed', 'system', { reason: 'registry-check-failed', currentVersion });
|
|
67
|
+
await flushEventQueue();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!SEMVER_RE.test(resolvedVersion)) {
|
|
71
|
+
logger.error(`[Control] npm returned invalid version: "${resolvedVersion}"`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (resolvedVersion === currentVersion) {
|
|
75
|
+
logger.info(`[Control] Already on latest version (${currentVersion})`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const packageSpec = `@teamvibe/poller@${resolvedVersion}`;
|
|
80
|
+
logger.info(`[Control] Installing ${packageSpec}...`);
|
|
81
|
+
try {
|
|
82
|
+
execSync(`npm install -g ${packageSpec}`, {
|
|
83
|
+
timeout: UPDATE_TIMEOUT_MS,
|
|
84
|
+
stdio: 'pipe',
|
|
85
|
+
encoding: 'utf-8',
|
|
86
|
+
});
|
|
87
|
+
logger.info('[Control] npm install completed');
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
91
|
+
logger.error(`[Control] Update failed: ${errorMsg}`);
|
|
92
|
+
// Update failed — no rollback needed (original version still in place)
|
|
93
|
+
emitEvent('poller.update.failed', 'system', {
|
|
94
|
+
reason: 'install-failed',
|
|
95
|
+
currentVersion,
|
|
96
|
+
targetVersion: resolvedVersion,
|
|
97
|
+
error: errorMsg,
|
|
98
|
+
});
|
|
99
|
+
await flushEventQueue();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Step 3: Post-update doctor check
|
|
103
|
+
logger.info('[Control] Running post-update health check...');
|
|
104
|
+
const postCheckOk = await doctor();
|
|
105
|
+
if (!postCheckOk) {
|
|
106
|
+
logger.warn(`[Control] Post-update doctor failed. Rolling back to ${currentVersion}...`);
|
|
107
|
+
// Step 4: Rollback
|
|
108
|
+
try {
|
|
109
|
+
execSync(`npm install -g @teamvibe/poller@${currentVersion}`, {
|
|
110
|
+
timeout: UPDATE_TIMEOUT_MS,
|
|
111
|
+
stdio: 'pipe',
|
|
112
|
+
encoding: 'utf-8',
|
|
113
|
+
});
|
|
114
|
+
logger.info(`[Control] Rolled back to ${currentVersion}`);
|
|
115
|
+
emitEvent('poller.update.rolledback', 'system', {
|
|
116
|
+
currentVersion,
|
|
117
|
+
targetVersion: resolvedVersion,
|
|
118
|
+
reason: 'post-check-failed',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (rollbackError) {
|
|
122
|
+
const errMsg = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
|
|
123
|
+
logger.error(`[Control] CRITICAL: Rollback failed: ${errMsg}`);
|
|
124
|
+
emitEvent('poller.update.rollback-failed', 'system', {
|
|
125
|
+
currentVersion,
|
|
126
|
+
targetVersion: resolvedVersion,
|
|
127
|
+
error: errMsg,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
await flushEventQueue();
|
|
131
|
+
// Restart anyway — launchd will bring us back (exit 1 to signal failure)
|
|
132
|
+
process.exit(1);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Step 5: Success — restart with new version
|
|
136
|
+
logger.info('[Control] Update successful. Restarting with new version...');
|
|
137
|
+
emitEvent('poller.update.success', 'system', {
|
|
138
|
+
previousVersion: currentVersion,
|
|
139
|
+
targetVersion: resolvedVersion,
|
|
140
|
+
});
|
|
141
|
+
await flushEventQueue();
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { isControlMessage } from './control-handler.js';
|
|
3
|
+
import { classifyUpdate } from './auto-update.js';
|
|
4
|
+
describe('isControlMessage', () => {
|
|
5
|
+
test('valid restart message', () => {
|
|
6
|
+
expect(isControlMessage({ source: 'control', action: 'restart' })).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
test('valid update message', () => {
|
|
9
|
+
expect(isControlMessage({ source: 'control', action: 'update' })).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
test('valid update with targetVersion', () => {
|
|
12
|
+
expect(isControlMessage({ source: 'control', action: 'update', targetVersion: '1.2.3' })).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
test('rejects non-control source', () => {
|
|
15
|
+
expect(isControlMessage({ source: 'slack', action: 'restart' })).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
test('rejects unknown action', () => {
|
|
18
|
+
expect(isControlMessage({ source: 'control', action: 'delete' })).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
test('rejects null', () => {
|
|
21
|
+
expect(isControlMessage(null)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
test('rejects undefined', () => {
|
|
24
|
+
expect(isControlMessage(undefined)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
test('rejects string', () => {
|
|
27
|
+
expect(isControlMessage('control')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
test('rejects empty object', () => {
|
|
30
|
+
expect(isControlMessage({})).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
test('rejects regular queue message', () => {
|
|
33
|
+
expect(isControlMessage({
|
|
34
|
+
id: '123',
|
|
35
|
+
source: 'cron',
|
|
36
|
+
thread_id: 'thread1',
|
|
37
|
+
sender: { id: 'user1', name: 'Test' },
|
|
38
|
+
text: 'hello',
|
|
39
|
+
})).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('classifyUpdate', () => {
|
|
43
|
+
test('detects patch update', () => {
|
|
44
|
+
expect(classifyUpdate('1.2.3', '1.2.4')).toBe('patch');
|
|
45
|
+
});
|
|
46
|
+
test('detects minor update', () => {
|
|
47
|
+
expect(classifyUpdate('1.2.3', '1.3.0')).toBe('minor');
|
|
48
|
+
});
|
|
49
|
+
test('detects major update', () => {
|
|
50
|
+
expect(classifyUpdate('1.2.3', '2.0.0')).toBe('major');
|
|
51
|
+
});
|
|
52
|
+
test('returns null for same version', () => {
|
|
53
|
+
expect(classifyUpdate('1.2.3', '1.2.3')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
test('returns null for downgrade (patch)', () => {
|
|
56
|
+
expect(classifyUpdate('1.2.4', '1.2.3')).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
test('returns null for downgrade (minor)', () => {
|
|
59
|
+
expect(classifyUpdate('1.3.0', '1.2.5')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
test('returns null for downgrade (major)', () => {
|
|
62
|
+
expect(classifyUpdate('2.0.0', '1.9.9')).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
test('handles zero versions', () => {
|
|
65
|
+
expect(classifyUpdate('0.0.1', '0.0.2')).toBe('patch');
|
|
66
|
+
expect(classifyUpdate('0.1.0', '0.2.0')).toBe('minor');
|
|
67
|
+
});
|
|
68
|
+
test('minor update with different patch', () => {
|
|
69
|
+
expect(classifyUpdate('1.2.3', '1.3.5')).toBe('minor');
|
|
70
|
+
});
|
|
71
|
+
test('major update with different minor/patch', () => {
|
|
72
|
+
expect(classifyUpdate('1.2.3', '2.1.0')).toBe('major');
|
|
73
|
+
});
|
|
74
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const command = process.argv[2];
|
|
3
|
-
if (command && ['install', 'uninstall', 'update', 'logs', 'status', 'start', 'stop', 'restart', 'reset', '--help', '-h', '--version', '-v'].includes(command)) {
|
|
3
|
+
if (command && ['install', 'uninstall', 'update', 'doctor', 'logs', 'status', 'start', 'stop', 'restart', 'reset', '--help', '-h', '--version', '-v'].includes(command)) {
|
|
4
4
|
const { handleCommand } = await import('./cli/commands.js');
|
|
5
5
|
await handleCommand(command);
|
|
6
6
|
}
|