coder-config 0.50.3 → 0.50.5-beta
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/lib/constants.js +1 -1
- package/lib/heartbeat.js +208 -0
- package/package.json +1 -1
package/lib/constants.js
CHANGED
package/lib/heartbeat.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat module - monitors Ralph Loop health
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { loadLoops, loadLoopState, getLoopsPath } = require('./loops.js');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get default heartbeat configuration
|
|
11
|
+
*/
|
|
12
|
+
function getDefaultHeartbeatConfig() {
|
|
13
|
+
return {
|
|
14
|
+
staleThresholdMinutes: 30,
|
|
15
|
+
iterationLimitPercent: 80,
|
|
16
|
+
cooldownMinutes: 15,
|
|
17
|
+
notifications: {
|
|
18
|
+
macos: { enabled: true },
|
|
19
|
+
slack: { enabled: false }
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load heartbeat config from loops.json, merged with defaults
|
|
26
|
+
* @param {string} installDir - path to coder-config install dir
|
|
27
|
+
* @returns {object} merged heartbeat config
|
|
28
|
+
*/
|
|
29
|
+
function loadHeartbeatConfig(installDir) {
|
|
30
|
+
const data = loadLoops(installDir);
|
|
31
|
+
return { ...getDefaultHeartbeatConfig(), ...data.config?.heartbeat };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Evaluate a single loop state and return any alerts
|
|
36
|
+
* @param {object} state - loop state object
|
|
37
|
+
* @param {object} globalConfig - heartbeat config (merged defaults)
|
|
38
|
+
* @param {Date} now - current date/time
|
|
39
|
+
* @returns {Array} array of alert objects for this loop
|
|
40
|
+
*/
|
|
41
|
+
function evaluateLoop(state, globalConfig, now) {
|
|
42
|
+
const alerts = [];
|
|
43
|
+
const status = state.status || 'unknown';
|
|
44
|
+
const name = state.name || state.id;
|
|
45
|
+
|
|
46
|
+
// Check: failed
|
|
47
|
+
if (status === 'failed') {
|
|
48
|
+
alerts.push({
|
|
49
|
+
loopId: state.id,
|
|
50
|
+
name,
|
|
51
|
+
type: 'failed',
|
|
52
|
+
message: `Loop "${name}" has failed`,
|
|
53
|
+
severity: 'critical'
|
|
54
|
+
});
|
|
55
|
+
return alerts;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check: stale
|
|
59
|
+
const perLoopThreshold = state.heartbeat && state.heartbeat.staleThresholdMinutes != null
|
|
60
|
+
? state.heartbeat.staleThresholdMinutes
|
|
61
|
+
: globalConfig.staleThresholdMinutes;
|
|
62
|
+
|
|
63
|
+
if (state.updatedAt) {
|
|
64
|
+
const updatedAt = new Date(state.updatedAt);
|
|
65
|
+
const ageMinutes = ((now || new Date()).getTime() - updatedAt.getTime()) / 60000;
|
|
66
|
+
if (ageMinutes >= perLoopThreshold) {
|
|
67
|
+
alerts.push({
|
|
68
|
+
loopId: state.id,
|
|
69
|
+
name,
|
|
70
|
+
type: 'stale',
|
|
71
|
+
message: `Loop "${name}" has not updated in ${Math.floor(ageMinutes)} minutes`,
|
|
72
|
+
severity: 'warning'
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check: iteration_limit
|
|
78
|
+
const iterations = state.iterations || {};
|
|
79
|
+
const current = iterations.current != null ? iterations.current : 0;
|
|
80
|
+
const max = iterations.max != null ? iterations.max : 0;
|
|
81
|
+
if (max > 0) {
|
|
82
|
+
const pct = (current / max) * 100;
|
|
83
|
+
if (pct >= globalConfig.iterationLimitPercent) {
|
|
84
|
+
alerts.push({
|
|
85
|
+
loopId: state.id,
|
|
86
|
+
name,
|
|
87
|
+
type: 'iteration_limit',
|
|
88
|
+
message: `Loop "${name}" is at ${Math.round(pct)}% of iteration limit (${current}/${max})`,
|
|
89
|
+
severity: 'warning'
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check: phase_gate (plan phase + running)
|
|
95
|
+
if (state.phase === 'plan' && status === 'running') {
|
|
96
|
+
alerts.push({
|
|
97
|
+
loopId: state.id,
|
|
98
|
+
name,
|
|
99
|
+
type: 'phase_gate',
|
|
100
|
+
message: `Loop "${name}" is waiting at plan phase gate`,
|
|
101
|
+
severity: 'info'
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check: blocked (paused with pauseReason)
|
|
106
|
+
if (status === 'paused' && state.pauseReason) {
|
|
107
|
+
alerts.push({
|
|
108
|
+
loopId: state.id,
|
|
109
|
+
name,
|
|
110
|
+
type: 'blocked',
|
|
111
|
+
message: `Loop "${name}" is blocked: ${state.pauseReason}`,
|
|
112
|
+
severity: 'info'
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return alerts;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a summary string from heartbeat results
|
|
121
|
+
* @param {number} activeLoops - count of active (non-terminal) loops
|
|
122
|
+
* @param {Array} alerts - array of alert objects
|
|
123
|
+
* @param {Array} healthy - array of healthy loop objects
|
|
124
|
+
* @returns {string} summary string
|
|
125
|
+
*/
|
|
126
|
+
function buildSummary(activeLoops, alerts, healthy) {
|
|
127
|
+
if (alerts.length === 0) {
|
|
128
|
+
return `${activeLoops} active loop${activeLoops !== 1 ? 's' : ''}, all healthy`;
|
|
129
|
+
}
|
|
130
|
+
const criticalCount = alerts.filter(a => a.severity === 'critical').length;
|
|
131
|
+
const warningCount = alerts.filter(a => a.severity === 'warning').length;
|
|
132
|
+
const infoCount = alerts.filter(a => a.severity === 'info').length;
|
|
133
|
+
const parts = [];
|
|
134
|
+
if (criticalCount > 0) parts.push(`${criticalCount} critical`);
|
|
135
|
+
if (warningCount > 0) parts.push(`${warningCount} warning${warningCount !== 1 ? 's' : ''}`);
|
|
136
|
+
if (infoCount > 0) parts.push(`${infoCount} info`);
|
|
137
|
+
return `${activeLoops} active loop${activeLoops !== 1 ? 's' : ''}, ${parts.join(', ')}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Evaluate heartbeat for all active loops
|
|
142
|
+
* @param {string} installDir - path to coder-config install dir
|
|
143
|
+
* @param {object} [config] - optional heartbeat config overrides
|
|
144
|
+
* @returns {object} report with alerts and healthy loops
|
|
145
|
+
*/
|
|
146
|
+
function heartbeat(installDir, config) {
|
|
147
|
+
const hbConfig = Object.assign({}, getDefaultHeartbeatConfig(), config || {});
|
|
148
|
+
|
|
149
|
+
const registry = loadLoops(installDir);
|
|
150
|
+
const loops = registry.loops || [];
|
|
151
|
+
|
|
152
|
+
const alerts = [];
|
|
153
|
+
const healthy = [];
|
|
154
|
+
const terminalStates = new Set(['completed', 'cancelled']);
|
|
155
|
+
const now = new Date();
|
|
156
|
+
|
|
157
|
+
for (const entry of loops) {
|
|
158
|
+
const state = loadLoopState(installDir, entry.id);
|
|
159
|
+
if (!state) continue;
|
|
160
|
+
|
|
161
|
+
const status = state.status || 'unknown';
|
|
162
|
+
if (terminalStates.has(status)) continue;
|
|
163
|
+
|
|
164
|
+
const name = state.name || entry.name || entry.id;
|
|
165
|
+
// Ensure state has id and name for evaluateLoop
|
|
166
|
+
const stateWithMeta = { ...state, id: entry.id, name };
|
|
167
|
+
|
|
168
|
+
const loopAlerts = evaluateLoop(stateWithMeta, hbConfig, now);
|
|
169
|
+
|
|
170
|
+
if (loopAlerts.length > 0) {
|
|
171
|
+
alerts.push(...loopAlerts);
|
|
172
|
+
} else {
|
|
173
|
+
const iterations = state.iterations || {};
|
|
174
|
+
const current = iterations.current != null ? iterations.current : 0;
|
|
175
|
+
healthy.push({
|
|
176
|
+
loopId: entry.id,
|
|
177
|
+
name,
|
|
178
|
+
phase: state.phase || null,
|
|
179
|
+
iteration: current
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const activeLoops = loops.filter(e => {
|
|
185
|
+
const state = loadLoopState(installDir, e.id);
|
|
186
|
+
if (!state) return false;
|
|
187
|
+
const status = state.status || 'unknown';
|
|
188
|
+
return !terminalStates.has(status);
|
|
189
|
+
}).length;
|
|
190
|
+
|
|
191
|
+
const summary = buildSummary(activeLoops, alerts, healthy);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
timestamp: now.toISOString(),
|
|
195
|
+
activeLoops,
|
|
196
|
+
alerts,
|
|
197
|
+
healthy,
|
|
198
|
+
summary
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
heartbeat,
|
|
204
|
+
getDefaultHeartbeatConfig,
|
|
205
|
+
loadHeartbeatConfig,
|
|
206
|
+
evaluateLoop,
|
|
207
|
+
buildSummary
|
|
208
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coder-config",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.5-beta",
|
|
4
4
|
"description": "Configuration manager for AI coding tools - Claude Code, Gemini CLI, Codex CLI, Antigravity. Manage MCPs, rules, permissions, memory, and workstreams.",
|
|
5
5
|
"author": "regression.io",
|
|
6
6
|
"main": "config-loader.js",
|