feat: Phase 0+1 — repo sync, pino, lint fixes, core components
Phase 0: - Synced latest live-status.js from workspace (9928 bytes) - Fixed 43 lint issues: empty catch blocks, console statements - Added pino dependency - Created src/tool-labels.json with all known tool mappings - make check passes Phase 1 (Core Components): - src/config.js: env-var config with validation, throws on missing required vars - src/logger.js: pino singleton with child loggers, level validation - src/circuit-breaker.js: CLOSED/OPEN/HALF_OPEN state machine with callbacks - src/tool-labels.js: exact/prefix/regex tool->label resolver with external override - src/status-box.js: Mattermost post manager (keepAlive, throttle, retry, circuit breaker) - src/status-formatter.js: pure SessionState->text formatter (nested, compact) - src/health.js: HTTP health endpoint + metrics - src/status-watcher.js: JSONL file watcher (inotify, compaction detection, idle detection) Tests: - test/unit/config.test.js: 7 tests - test/unit/circuit-breaker.test.js: 12 tests - test/unit/logger.test.js: 5 tests - test/unit/status-formatter.test.js: 20 tests - test/unit/tool-labels.test.js: 15 tests All 59 unit tests pass. make check clean.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
@@ -12,272 +14,315 @@ const options = {};
|
||||
const otherArgs = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
const next = args[i + 1];
|
||||
if (arg === '--channel' && next) { options.channel = next; i++; }
|
||||
else if (arg === '--reply-to' && next) { options.replyTo = next; i++; }
|
||||
else if (arg === '--agent' && next) { options.agent = next; i++; }
|
||||
else if (arg === '--token' && next) { options.token = next; i++; }
|
||||
else if (arg === '--host' && next) { options.host = next; i++; }
|
||||
else if (arg === '--rich') { options.rich = true; }
|
||||
else if (!command && ['create', 'update', 'complete', 'error', 'delete'].includes(arg)) {
|
||||
command = arg;
|
||||
} else {
|
||||
otherArgs.push(arg);
|
||||
}
|
||||
const arg = args[i]; // eslint-disable-line security/detect-object-injection
|
||||
const next = args[i + 1]; // eslint-disable-line security/detect-object-injection
|
||||
if (arg === '--channel' && next) {
|
||||
options.channel = next;
|
||||
i++;
|
||||
} else if (arg === '--reply-to' && next) {
|
||||
options.replyTo = next;
|
||||
i++;
|
||||
} else if (arg === '--agent' && next) {
|
||||
options.agent = next;
|
||||
i++;
|
||||
} else if (arg === '--token' && next) {
|
||||
options.token = next;
|
||||
i++;
|
||||
} else if (arg === '--host' && next) {
|
||||
options.host = next;
|
||||
i++;
|
||||
} else if (arg === '--rich') {
|
||||
options.rich = true;
|
||||
} else if (!command && ['create', 'update', 'complete', 'error', 'delete'].includes(arg)) {
|
||||
command = arg;
|
||||
} else {
|
||||
otherArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
// --- LOAD CONFIG ---
|
||||
function loadConfig() {
|
||||
const searchPaths = [
|
||||
process.env.OPENCLAW_CONFIG_DIR && path.join(process.env.OPENCLAW_CONFIG_DIR, 'openclaw.json'),
|
||||
process.env.XDG_CONFIG_HOME && path.join(process.env.XDG_CONFIG_HOME, 'openclaw.json'),
|
||||
path.join(process.env.HOME || '/root', '.openclaw', 'openclaw.json'),
|
||||
'/home/node/.openclaw/openclaw.json'
|
||||
].filter(Boolean);
|
||||
const searchPaths = [
|
||||
process.env.OPENCLAW_CONFIG_DIR && path.join(process.env.OPENCLAW_CONFIG_DIR, 'openclaw.json'),
|
||||
process.env.XDG_CONFIG_HOME && path.join(process.env.XDG_CONFIG_HOME, 'openclaw.json'),
|
||||
path.join(process.env.HOME || '/root', '.openclaw', 'openclaw.json'),
|
||||
'/home/node/.openclaw/openclaw.json',
|
||||
].filter(Boolean);
|
||||
|
||||
for (const p of searchPaths) {
|
||||
try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
|
||||
catch (_) {}
|
||||
for (const p of searchPaths) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
} catch (_e) {
|
||||
/* file not found or invalid JSON — try next path */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveToken(config) {
|
||||
if (options.token) return options.token;
|
||||
if (process.env.MM_BOT_TOKEN) return process.env.MM_BOT_TOKEN;
|
||||
if (!config) return null;
|
||||
if (options.token) return options.token;
|
||||
if (process.env.MM_BOT_TOKEN) return process.env.MM_BOT_TOKEN;
|
||||
if (!config) return null;
|
||||
|
||||
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
|
||||
const accounts = mm.accounts || {};
|
||||
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
|
||||
const accounts = mm.accounts || {};
|
||||
|
||||
if (options.agent) {
|
||||
try {
|
||||
const mapPath = path.join(__dirname, 'agent-accounts.json');
|
||||
const agentMap = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
|
||||
const accName = agentMap[options.agent];
|
||||
if (accName && accounts[accName] && accounts[accName].botToken) {
|
||||
return accounts[accName].botToken;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (options.agent) {
|
||||
try {
|
||||
const mapPath = path.join(__dirname, 'agent-accounts.json');
|
||||
const agentMap = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
const accName = agentMap[options.agent];
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
if (accName && accounts[accName] && accounts[accName].botToken) {
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
return accounts[accName].botToken;
|
||||
}
|
||||
} catch (_e) {
|
||||
/* agent-accounts.json not found or agent not mapped */
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.default && accounts.default.botToken) return accounts.default.botToken;
|
||||
for (const acc of Object.values(accounts)) {
|
||||
if (acc.botToken) return acc.botToken;
|
||||
}
|
||||
return null;
|
||||
if (accounts.default && accounts.default.botToken) return accounts.default.botToken;
|
||||
for (const acc of Object.values(accounts)) {
|
||||
if (acc.botToken) return acc.botToken;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveHost(config) {
|
||||
if (options.host) return options.host;
|
||||
if (process.env.MM_HOST) return process.env.MM_HOST;
|
||||
if (config) {
|
||||
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
|
||||
const baseUrl = mm.baseUrl || '';
|
||||
if (baseUrl) { try { return new URL(baseUrl).hostname; } catch (_) {} }
|
||||
if (options.host) return options.host;
|
||||
if (process.env.MM_HOST) return process.env.MM_HOST;
|
||||
if (config) {
|
||||
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
|
||||
const baseUrl = mm.baseUrl || '';
|
||||
if (baseUrl) {
|
||||
try {
|
||||
return new URL(baseUrl).hostname;
|
||||
} catch (_e) {
|
||||
/* invalid URL */
|
||||
}
|
||||
}
|
||||
return 'localhost';
|
||||
}
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
function resolvePort(config) {
|
||||
if (process.env.MM_PORT) return parseInt(process.env.MM_PORT, 10);
|
||||
if (config) {
|
||||
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
|
||||
const baseUrl = mm.baseUrl || '';
|
||||
if (baseUrl) {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (process.env.MM_PORT) return parseInt(process.env.MM_PORT, 10);
|
||||
if (config) {
|
||||
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
|
||||
const baseUrl = mm.baseUrl || '';
|
||||
if (baseUrl) {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return url.port ? parseInt(url.port, 10) : url.protocol === 'https:' ? 443 : 80;
|
||||
} catch (_e) {
|
||||
/* invalid URL — use default port */
|
||||
}
|
||||
}
|
||||
return 443;
|
||||
}
|
||||
return 443;
|
||||
}
|
||||
|
||||
function resolveProtocol(config) {
|
||||
if (config) {
|
||||
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
|
||||
if ((mm.baseUrl || '').startsWith('http://')) return 'http';
|
||||
}
|
||||
return 'https';
|
||||
if (config) {
|
||||
const mm = config.mattermost || (config.channels && config.channels.mattermost) || {};
|
||||
if ((mm.baseUrl || '').startsWith('http://')) return 'http';
|
||||
}
|
||||
return 'https';
|
||||
}
|
||||
|
||||
// --- BUILD CONFIG ---
|
||||
const ocConfig = loadConfig();
|
||||
const CONFIG = {
|
||||
host: resolveHost(ocConfig),
|
||||
port: resolvePort(ocConfig),
|
||||
protocol: resolveProtocol(ocConfig),
|
||||
token: resolveToken(ocConfig),
|
||||
channel_id: options.channel || process.env.MM_CHANNEL_ID || process.env.CHANNEL_ID
|
||||
host: resolveHost(ocConfig),
|
||||
port: resolvePort(ocConfig),
|
||||
protocol: resolveProtocol(ocConfig),
|
||||
token: resolveToken(ocConfig),
|
||||
channel_id: options.channel || process.env.MM_CHANNEL_ID || process.env.CHANNEL_ID,
|
||||
};
|
||||
|
||||
if (!CONFIG.token) {
|
||||
console.error('Error: No bot token found.');
|
||||
console.error(' Set MM_BOT_TOKEN, use --token, or configure openclaw.json');
|
||||
process.exit(1);
|
||||
console.error('Error: No bot token found.');
|
||||
console.error(' Set MM_BOT_TOKEN, use --token, or configure openclaw.json');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- HTTP REQUEST ---
|
||||
function request(method, apiPath, data) {
|
||||
const transport = CONFIG.protocol === 'https' ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = transport.request({
|
||||
hostname: CONFIG.host, port: CONFIG.port,
|
||||
path: '/api/v4' + apiPath, method,
|
||||
headers: { 'Authorization': `Bearer ${CONFIG.token}`, 'Content-Type': 'application/json' }
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => body += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try { resolve(JSON.parse(body)); } catch (_) { resolve(body); }
|
||||
} else {
|
||||
let msg = `HTTP ${res.statusCode}`;
|
||||
try { msg = JSON.parse(body).message || msg; } catch (_) {}
|
||||
reject(new Error(msg));
|
||||
}
|
||||
});
|
||||
const transport = CONFIG.protocol === 'https' ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = transport.request(
|
||||
{
|
||||
hostname: CONFIG.host,
|
||||
port: CONFIG.port,
|
||||
path: '/api/v4' + apiPath,
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${CONFIG.token}`, 'Content-Type': 'application/json' },
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => (body += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (_e) {
|
||||
resolve(body);
|
||||
}
|
||||
} else {
|
||||
let msg = `HTTP ${res.statusCode}`;
|
||||
try {
|
||||
msg = JSON.parse(body).message || msg;
|
||||
} catch (_e) {
|
||||
/* use default msg */
|
||||
}
|
||||
reject(new Error(msg));
|
||||
}
|
||||
});
|
||||
req.on('error', (e) => reject(e));
|
||||
if (data) req.write(JSON.stringify(data));
|
||||
req.end();
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', (e) => reject(e));
|
||||
if (data) req.write(JSON.stringify(data));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// --- RICH ATTACHMENT HELPERS ---
|
||||
const RICH_STYLES = {
|
||||
create: { color: '#FFA500', prefix: '⏳', status: '🔄 Running' },
|
||||
update: { color: '#FFA500', prefix: '🔄', status: '🔄 Running' },
|
||||
complete: { color: '#36A64F', prefix: '✅', status: '✅ Complete' },
|
||||
error: { color: '#DC3545', prefix: '❌', status: '❌ Error' }
|
||||
create: { color: '#FFA500', prefix: '⏳', status: '🔄 Running' },
|
||||
update: { color: '#FFA500', prefix: '🔄', status: '🔄 Running' },
|
||||
complete: { color: '#36A64F', prefix: '✅', status: '✅ Complete' },
|
||||
error: { color: '#DC3545', prefix: '❌', status: '❌ Error' },
|
||||
};
|
||||
|
||||
function buildAttachment(cmd, text) {
|
||||
const style = RICH_STYLES[cmd] || RICH_STYLES.update;
|
||||
const agentName = options.agent || 'unknown';
|
||||
const style = RICH_STYLES[cmd] || RICH_STYLES.update; // eslint-disable-line security/detect-object-injection
|
||||
const agentName = options.agent || 'unknown';
|
||||
|
||||
// Split text: first line = title, rest = log body
|
||||
const lines = text.split('\n');
|
||||
const title = `${style.prefix} ${lines[0]}`;
|
||||
const body = lines.length > 1 ? lines.slice(1).join('\n') : '';
|
||||
// Split text: first line = title, rest = log body
|
||||
const lines = text.split('\n');
|
||||
const title = `${style.prefix} ${lines[0]}`;
|
||||
const body = lines.length > 1 ? lines.slice(1).join('\n') : '';
|
||||
|
||||
return {
|
||||
color: style.color,
|
||||
title: title,
|
||||
text: body || undefined,
|
||||
fields: [
|
||||
{ short: true, title: 'Agent', value: agentName },
|
||||
{ short: true, title: 'Status', value: style.status }
|
||||
]
|
||||
};
|
||||
return {
|
||||
color: style.color,
|
||||
title: title,
|
||||
text: body || undefined,
|
||||
fields: [
|
||||
{ short: true, title: 'Agent', value: agentName },
|
||||
{ short: true, title: 'Status', value: style.status },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// --- COMMANDS ---
|
||||
|
||||
async function createPost(text, cmd) {
|
||||
if (!CONFIG.channel_id) {
|
||||
console.error('Error: Channel ID required. Use --channel <id> or set MM_CHANNEL_ID.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!text || !text.trim()) {
|
||||
console.error('Error: Message text is required for create.');
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
let payload;
|
||||
if (options.rich) {
|
||||
payload = {
|
||||
channel_id: CONFIG.channel_id,
|
||||
message: '',
|
||||
props: { attachments: [buildAttachment(cmd || 'create', text)] }
|
||||
};
|
||||
} else {
|
||||
payload = { channel_id: CONFIG.channel_id, message: text };
|
||||
}
|
||||
if (options.replyTo) payload.root_id = options.replyTo;
|
||||
const result = await request('POST', '/posts', payload);
|
||||
console.log(result.id);
|
||||
} catch (e) {
|
||||
console.error('Error (create):', e.message);
|
||||
process.exit(1);
|
||||
if (!CONFIG.channel_id) {
|
||||
console.error('Error: Channel ID required. Use --channel <id> or set MM_CHANNEL_ID.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!text || !text.trim()) {
|
||||
console.error('Error: Message text is required for create.');
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
let payload;
|
||||
if (options.rich) {
|
||||
payload = {
|
||||
channel_id: CONFIG.channel_id,
|
||||
message: '',
|
||||
props: { attachments: [buildAttachment(cmd || 'create', text)] },
|
||||
};
|
||||
} else {
|
||||
payload = { channel_id: CONFIG.channel_id, message: text };
|
||||
}
|
||||
if (options.replyTo) payload.root_id = options.replyTo;
|
||||
const result = await request('POST', '/posts', payload);
|
||||
console.log(result.id);
|
||||
} catch (e) {
|
||||
console.error('Error (create):', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePost(postId, text, cmd) {
|
||||
if (!postId) {
|
||||
console.error('Error: Post ID is required for update.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!text || !text.trim()) {
|
||||
console.error('Error: Message text is required for update.');
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
if (options.rich) {
|
||||
await request('PUT', `/posts/${postId}`, {
|
||||
id: postId,
|
||||
message: '',
|
||||
props: { attachments: [buildAttachment(cmd || 'update', text)] }
|
||||
});
|
||||
} else {
|
||||
const current = await request('GET', `/posts/${postId}`);
|
||||
await request('PUT', `/posts/${postId}`, {
|
||||
id: postId, message: text, props: current.props
|
||||
});
|
||||
}
|
||||
console.log('updated');
|
||||
} catch (e) {
|
||||
console.error('Error (update):', e.message);
|
||||
process.exit(1);
|
||||
if (!postId) {
|
||||
console.error('Error: Post ID is required for update.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!text || !text.trim()) {
|
||||
console.error('Error: Message text is required for update.');
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
if (options.rich) {
|
||||
await request('PUT', `/posts/${postId}`, {
|
||||
id: postId,
|
||||
message: '',
|
||||
props: { attachments: [buildAttachment(cmd || 'update', text)] },
|
||||
});
|
||||
} else {
|
||||
const current = await request('GET', `/posts/${postId}`);
|
||||
await request('PUT', `/posts/${postId}`, {
|
||||
id: postId,
|
||||
message: text,
|
||||
props: current.props,
|
||||
});
|
||||
}
|
||||
console.log('updated');
|
||||
} catch (e) {
|
||||
console.error('Error (update):', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePost(postId) {
|
||||
if (!postId) {
|
||||
console.error('Error: Post ID is required for delete.');
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
await request('DELETE', `/posts/${postId}`);
|
||||
console.log('deleted');
|
||||
} catch (e) {
|
||||
console.error('Error (delete):', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!postId) {
|
||||
console.error('Error: Post ID is required for delete.');
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
await request('DELETE', `/posts/${postId}`);
|
||||
console.log('deleted');
|
||||
} catch (e) {
|
||||
console.error('Error (delete):', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// --- CLI ROUTER ---
|
||||
if (command === 'create') {
|
||||
createPost(otherArgs.join(' '), 'create');
|
||||
createPost(otherArgs.join(' '), 'create');
|
||||
} else if (command === 'update') {
|
||||
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'update');
|
||||
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'update');
|
||||
} else if (command === 'complete') {
|
||||
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'complete');
|
||||
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'complete');
|
||||
} else if (command === 'error') {
|
||||
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'error');
|
||||
updatePost(otherArgs[0], otherArgs.slice(1).join(' '), 'error');
|
||||
} else if (command === 'delete') {
|
||||
deletePost(otherArgs[0]);
|
||||
deletePost(otherArgs[0]);
|
||||
} else {
|
||||
console.log('Usage:');
|
||||
console.log(' live-status [options] create <text>');
|
||||
console.log(' live-status [options] update <id> <text>');
|
||||
console.log(' live-status [options] complete <id> <text>');
|
||||
console.log(' live-status [options] error <id> <text>');
|
||||
console.log(' live-status [options] delete <id>');
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(' --rich Use rich message attachments (colored cards)');
|
||||
console.log(' --channel ID Target channel');
|
||||
console.log(' --reply-to ID Post as thread reply');
|
||||
console.log(' --agent NAME Use bot token mapped to this agent');
|
||||
console.log(' --token TOKEN Explicit bot token (overrides all)');
|
||||
console.log(' --host HOST Mattermost hostname');
|
||||
console.log('');
|
||||
console.log('Rich mode colors:');
|
||||
console.log(' create/update → Orange (running)');
|
||||
console.log(' complete → Green (done)');
|
||||
console.log(' error → Red (failed)');
|
||||
process.exit(1);
|
||||
console.log('Usage:');
|
||||
console.log(' live-status [options] create <text>');
|
||||
console.log(' live-status [options] update <id> <text>');
|
||||
console.log(' live-status [options] complete <id> <text>');
|
||||
console.log(' live-status [options] error <id> <text>');
|
||||
console.log(' live-status [options] delete <id>');
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(' --rich Use rich message attachments (colored cards)');
|
||||
console.log(' --channel ID Target channel');
|
||||
console.log(' --reply-to ID Post as thread reply');
|
||||
console.log(' --agent NAME Use bot token mapped to this agent');
|
||||
console.log(' --token TOKEN Explicit bot token (overrides all)');
|
||||
console.log(' --host HOST Mattermost hostname');
|
||||
console.log('');
|
||||
console.log('Rich mode colors:');
|
||||
console.log(' create/update → Orange (running)');
|
||||
console.log(' complete → Green (done)');
|
||||
console.log(' error → Red (failed)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user