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

143
src/circuit-breaker.js Normal file
View File

@@ -0,0 +1,143 @@
'use strict';
/**
* circuit-breaker.js — Circuit breaker for API resilience.
*
* States:
* CLOSED — Normal operation. Failures tracked.
* OPEN — Too many failures. Calls rejected immediately.
* HALF_OPEN — Cooldown expired. One probe call allowed.
*
* Transition rules:
* CLOSED -> OPEN: failures >= threshold
* OPEN -> HALF_OPEN: cooldown expired
* HALF_OPEN -> CLOSED: probe succeeds
* HALF_OPEN -> OPEN: probe fails
*/
const STATE = {
CLOSED: 'closed',
OPEN: 'open',
HALF_OPEN: 'half_open',
};
class CircuitBreaker {
/**
* @param {object} opts
* @param {number} opts.threshold - Consecutive failures to open (default 5)
* @param {number} opts.cooldownMs - Milliseconds before half-open probe (default 30000)
* @param {Function} [opts.onStateChange] - Called with (newState, oldState)
* @param {object} [opts.logger] - Optional logger
*/
constructor(opts = {}) {
this.threshold = opts.threshold || 5;
this.cooldownMs = opts.cooldownMs || 30000;
this.onStateChange = opts.onStateChange || null;
this.logger = opts.logger || null;
this.state = STATE.CLOSED;
this.failures = 0;
this.openedAt = null;
this.lastError = null;
}
/**
* Execute a function through the circuit breaker.
* Throws CircuitOpenError if the circuit is open.
* @param {Function} fn - Async function to execute
* @returns {Promise<*>}
*/
async execute(fn) {
if (this.state === STATE.OPEN) {
const elapsed = Date.now() - this.openedAt;
if (elapsed >= this.cooldownMs) {
this._transition(STATE.HALF_OPEN);
} else {
throw new CircuitOpenError(
`Circuit open (${Math.ceil((this.cooldownMs - elapsed) / 1000)}s remaining)`,
this.lastError,
);
}
}
try {
const result = await fn();
this._onSuccess();
return result;
} catch (err) {
this._onFailure(err);
throw err;
}
}
_onSuccess() {
if (this.state === STATE.HALF_OPEN) {
this._transition(STATE.CLOSED);
}
this.failures = 0;
this.lastError = null;
}
_onFailure(err) {
this.lastError = err;
this.failures++;
if (this.state === STATE.HALF_OPEN) {
// Probe failed — reopen
this.openedAt = Date.now();
this._transition(STATE.OPEN);
} else if (this.state === STATE.CLOSED && this.failures >= this.threshold) {
this.openedAt = Date.now();
this._transition(STATE.OPEN);
}
}
_transition(newState) {
const oldState = this.state;
this.state = newState;
if (newState === STATE.CLOSED) {
this.failures = 0;
this.openedAt = null;
}
if (this.logger) {
this.logger.warn({ from: oldState, to: newState }, 'Circuit breaker state change');
}
if (this.onStateChange) {
this.onStateChange(newState, oldState);
}
}
getState() {
return this.state;
}
getMetrics() {
return {
state: this.state,
failures: this.failures,
threshold: this.threshold,
openedAt: this.openedAt,
lastError: this.lastError ? this.lastError.message : null,
};
}
reset() {
this.state = STATE.CLOSED;
this.failures = 0;
this.openedAt = null;
this.lastError = null;
}
}
class CircuitOpenError extends Error {
constructor(message, cause) {
super(message);
this.name = 'CircuitOpenError';
this.cause = cause;
}
}
module.exports = { CircuitBreaker, CircuitOpenError, STATE };