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:
sol
2026-03-07 17:26:53 +00:00
parent b3ec2c61db
commit 43cfebee96
21 changed files with 2691 additions and 287 deletions

112
src/config.js Normal file
View File

@@ -0,0 +1,112 @@
'use strict';
/**
* config.js — Centralized env-var config with validation.
* All config is read from environment variables.
* Throws on missing required variables at startup.
*/
function getEnv(name, defaultValue, required = false) {
const val = process.env[name];
if (val === undefined || val === '') {
if (required) {
throw new Error(`Required environment variable ${name} is not set`);
}
return defaultValue;
}
return val;
}
function getEnvInt(name, defaultValue, required = false) {
const val = getEnv(name, undefined, required);
if (val === undefined) return defaultValue;
const n = parseInt(val, 10);
if (isNaN(n)) throw new Error(`Environment variable ${name} must be an integer, got: ${val}`);
return n;
}
function getEnvBool(name, defaultValue) {
const val = process.env[name];
if (val === undefined || val === '') return defaultValue;
return val === '1' || val.toLowerCase() === 'true' || val.toLowerCase() === 'yes';
}
/**
* Build and validate the config object.
* Called once at startup; throws on invalid config.
*/
function buildConfig() {
const config = {
// Mattermost API
mm: {
token: getEnv('MM_BOT_TOKEN', null, true),
baseUrl: getEnv('MM_BASE_URL', 'https://slack.solio.tech'),
maxSockets: getEnvInt('MM_MAX_SOCKETS', 4),
},
// Transcript directory (OpenClaw agents)
transcriptDir: getEnv('TRANSCRIPT_DIR', '/home/node/.openclaw/agents'),
// Timing
throttleMs: getEnvInt('THROTTLE_MS', 500),
idleTimeoutS: getEnvInt('IDLE_TIMEOUT_S', 60),
sessionPollMs: getEnvInt('SESSION_POLL_MS', 2000),
// Limits
maxActiveSessions: getEnvInt('MAX_ACTIVE_SESSIONS', 20),
maxMessageChars: getEnvInt('MAX_MESSAGE_CHARS', 15000),
maxStatusLines: getEnvInt('MAX_STATUS_LINES', 20),
maxRetries: getEnvInt('MAX_RETRIES', 3),
// Circuit breaker
circuitBreakerThreshold: getEnvInt('CIRCUIT_BREAKER_THRESHOLD', 5),
circuitBreakerCooldownS: getEnvInt('CIRCUIT_BREAKER_COOLDOWN_S', 30),
// Health check
healthPort: getEnvInt('HEALTH_PORT', 9090),
// Logging
logLevel: getEnv('LOG_LEVEL', 'info'),
// PID file
pidFile: getEnv('PID_FILE', '/tmp/status-watcher.pid'),
// Offset persistence
offsetFile: getEnv('OFFSET_FILE', '/tmp/status-watcher-offsets.json'),
// Optional external tool labels override
toolLabelsFile: getEnv('TOOL_LABELS_FILE', null),
// Fallback channel for non-MM sessions (null = skip)
defaultChannel: getEnv('DEFAULT_CHANNEL', null),
// Feature flags
enableFsWatch: getEnvBool('ENABLE_FS_WATCH', true),
};
// Validate MM base URL
try {
new URL(config.mm.baseUrl);
} catch (_e) {
throw new Error(`MM_BASE_URL is not a valid URL: ${config.mm.baseUrl}`);
}
return config;
}
// Singleton — built once, exported
let _config = null;
function getConfig() {
if (!_config) {
_config = buildConfig();
}
return _config;
}
// Allow resetting config in tests
function resetConfig() {
_config = null;
}
module.exports = { getConfig, resetConfig, buildConfig };