coder-config 0.50.4-beta → 0.50.6-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 +215 -93
- package/package.json +1 -1
package/lib/constants.js
CHANGED
package/lib/heartbeat.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { loadLoops, loadLoopState } = require('./loops.js');
|
|
7
|
+
const { loadLoops, loadLoopState, getLoopsPath } = require('./loops.js');
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Get default heartbeat configuration
|
|
@@ -21,6 +21,122 @@ function getDefaultHeartbeatConfig() {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
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
|
+
|
|
24
140
|
/**
|
|
25
141
|
* Evaluate heartbeat for all active loops
|
|
26
142
|
* @param {string} installDir - path to coder-config install dir
|
|
@@ -36,6 +152,7 @@ function heartbeat(installDir, config) {
|
|
|
36
152
|
const alerts = [];
|
|
37
153
|
const healthy = [];
|
|
38
154
|
const terminalStates = new Set(['completed', 'cancelled']);
|
|
155
|
+
const now = new Date();
|
|
39
156
|
|
|
40
157
|
for (const entry of loops) {
|
|
41
158
|
const state = loadLoopState(installDir, entry.id);
|
|
@@ -45,84 +162,16 @@ function heartbeat(installDir, config) {
|
|
|
45
162
|
if (terminalStates.has(status)) continue;
|
|
46
163
|
|
|
47
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 };
|
|
48
167
|
|
|
49
|
-
|
|
50
|
-
if (status === 'failed') {
|
|
51
|
-
alerts.push({
|
|
52
|
-
loopId: entry.id,
|
|
53
|
-
name,
|
|
54
|
-
type: 'failed',
|
|
55
|
-
message: `Loop "${name}" has failed`,
|
|
56
|
-
severity: 'critical'
|
|
57
|
-
});
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
168
|
+
const loopAlerts = evaluateLoop(stateWithMeta, hbConfig, now);
|
|
60
169
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
?
|
|
66
|
-
: hbConfig.staleThresholdMinutes;
|
|
67
|
-
|
|
68
|
-
if (state.updatedAt) {
|
|
69
|
-
const updatedAt = new Date(state.updatedAt);
|
|
70
|
-
const ageMinutes = (Date.now() - updatedAt.getTime()) / 60000;
|
|
71
|
-
if (ageMinutes >= perLoopThreshold) {
|
|
72
|
-
alerts.push({
|
|
73
|
-
loopId: entry.id,
|
|
74
|
-
name,
|
|
75
|
-
type: 'stale',
|
|
76
|
-
message: `Loop "${name}" has not updated in ${Math.floor(ageMinutes)} minutes`,
|
|
77
|
-
severity: 'warning'
|
|
78
|
-
});
|
|
79
|
-
hasAlert = true;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Check: iteration_limit
|
|
84
|
-
const iterations = state.iterations || {};
|
|
85
|
-
const current = iterations.current != null ? iterations.current : 0;
|
|
86
|
-
const max = iterations.max != null ? iterations.max : 0;
|
|
87
|
-
if (max > 0) {
|
|
88
|
-
const pct = (current / max) * 100;
|
|
89
|
-
if (pct >= hbConfig.iterationLimitPercent) {
|
|
90
|
-
alerts.push({
|
|
91
|
-
loopId: entry.id,
|
|
92
|
-
name,
|
|
93
|
-
type: 'iteration_limit',
|
|
94
|
-
message: `Loop "${name}" is at ${Math.round(pct)}% of iteration limit (${current}/${max})`,
|
|
95
|
-
severity: 'warning'
|
|
96
|
-
});
|
|
97
|
-
hasAlert = true;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Check: phase_gate (plan phase + running)
|
|
102
|
-
if (state.phase === 'plan' && status === 'running') {
|
|
103
|
-
alerts.push({
|
|
104
|
-
loopId: entry.id,
|
|
105
|
-
name,
|
|
106
|
-
type: 'phase_gate',
|
|
107
|
-
message: `Loop "${name}" is waiting at plan phase gate`,
|
|
108
|
-
severity: 'info'
|
|
109
|
-
});
|
|
110
|
-
hasAlert = true;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Check: blocked (paused with pauseReason)
|
|
114
|
-
if (status === 'paused' && state.pauseReason) {
|
|
115
|
-
alerts.push({
|
|
116
|
-
loopId: entry.id,
|
|
117
|
-
name,
|
|
118
|
-
type: 'blocked',
|
|
119
|
-
message: `Loop "${name}" is blocked: ${state.pauseReason}`,
|
|
120
|
-
severity: 'info'
|
|
121
|
-
});
|
|
122
|
-
hasAlert = true;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (!hasAlert) {
|
|
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;
|
|
126
175
|
healthy.push({
|
|
127
176
|
loopId: entry.id,
|
|
128
177
|
name,
|
|
@@ -139,23 +188,10 @@ function heartbeat(installDir, config) {
|
|
|
139
188
|
return !terminalStates.has(status);
|
|
140
189
|
}).length;
|
|
141
190
|
|
|
142
|
-
const
|
|
143
|
-
const warningCount = alerts.filter(a => a.severity === 'warning').length;
|
|
144
|
-
|
|
145
|
-
let summary;
|
|
146
|
-
if (alerts.length === 0) {
|
|
147
|
-
summary = `${activeLoops} active loop${activeLoops !== 1 ? 's' : ''}, all healthy`;
|
|
148
|
-
} else {
|
|
149
|
-
const parts = [];
|
|
150
|
-
if (criticalCount > 0) parts.push(`${criticalCount} critical`);
|
|
151
|
-
if (warningCount > 0) parts.push(`${warningCount} warning${warningCount !== 1 ? 's' : ''}`);
|
|
152
|
-
const infoCount = alerts.filter(a => a.severity === 'info').length;
|
|
153
|
-
if (infoCount > 0) parts.push(`${infoCount} info`);
|
|
154
|
-
summary = `${activeLoops} active loop${activeLoops !== 1 ? 's' : ''}, ${parts.join(', ')}`;
|
|
155
|
-
}
|
|
191
|
+
const summary = buildSummary(activeLoops, alerts, healthy);
|
|
156
192
|
|
|
157
193
|
return {
|
|
158
|
-
timestamp:
|
|
194
|
+
timestamp: now.toISOString(),
|
|
159
195
|
activeLoops,
|
|
160
196
|
alerts,
|
|
161
197
|
healthy,
|
|
@@ -163,7 +199,93 @@ function heartbeat(installDir, config) {
|
|
|
163
199
|
};
|
|
164
200
|
}
|
|
165
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Save the last heartbeat report to disk for deduplication
|
|
204
|
+
* @param {string} installDir - path to coder-config install dir
|
|
205
|
+
* @param {object} report - heartbeat report with alerts array
|
|
206
|
+
*/
|
|
207
|
+
function saveLastHeartbeat(installDir, report) {
|
|
208
|
+
const loopsDir = path.join(installDir, 'loops');
|
|
209
|
+
fs.mkdirSync(loopsDir, { recursive: true });
|
|
210
|
+
const alertHashes = {};
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
for (const alert of (report.alerts || [])) {
|
|
213
|
+
const key = `${alert.loopId}:${alert.type}`;
|
|
214
|
+
alertHashes[key] = now;
|
|
215
|
+
}
|
|
216
|
+
const data = { alertHashes, timestamp: now };
|
|
217
|
+
fs.writeFileSync(path.join(loopsDir, 'last-heartbeat.json'), JSON.stringify(data, null, 2));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Load the last saved heartbeat from disk
|
|
222
|
+
* @param {string} installDir - path to coder-config install dir
|
|
223
|
+
* @returns {object|null} saved heartbeat data or null if not found
|
|
224
|
+
*/
|
|
225
|
+
function loadLastHeartbeat(installDir) {
|
|
226
|
+
const filePath = path.join(installDir, 'loops', 'last-heartbeat.json');
|
|
227
|
+
try {
|
|
228
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
229
|
+
return JSON.parse(raw);
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Determine whether an alert should fire (not within cooldown)
|
|
237
|
+
* @param {string} installDir - path to coder-config install dir
|
|
238
|
+
* @param {object} alert - alert object with loopId and type
|
|
239
|
+
* @param {number} cooldownMinutes - cooldown in minutes
|
|
240
|
+
* @returns {boolean} true if should notify, false if within cooldown
|
|
241
|
+
*/
|
|
242
|
+
function shouldNotify(installDir, alert, cooldownMinutes) {
|
|
243
|
+
const last = loadLastHeartbeat(installDir);
|
|
244
|
+
if (!last || !last.alertHashes) return true;
|
|
245
|
+
const key = `${alert.loopId}:${alert.type}`;
|
|
246
|
+
const lastTime = last.alertHashes[key];
|
|
247
|
+
if (lastTime == null) return true;
|
|
248
|
+
const ageMinutes = (Date.now() - lastTime) / 60000;
|
|
249
|
+
return ageMinutes >= cooldownMinutes;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Build a macOS osascript notification command for a heartbeat report
|
|
254
|
+
* @param {object} report - heartbeat report
|
|
255
|
+
* @returns {string} shell command string
|
|
256
|
+
*/
|
|
257
|
+
function buildMacosNotification(report) {
|
|
258
|
+
const alerts = report.alerts || [];
|
|
259
|
+
const criticalCount = alerts.filter(a => a.severity === 'critical').length;
|
|
260
|
+
const warningCount = alerts.filter(a => a.severity === 'warning').length;
|
|
261
|
+
const parts = [];
|
|
262
|
+
if (criticalCount > 0) parts.push(`${criticalCount} critical`);
|
|
263
|
+
if (warningCount > 0) parts.push(`${warningCount} warning${warningCount !== 1 ? 's' : ''}`);
|
|
264
|
+
const subtitle = parts.length > 0 ? parts.join(', ') : 'all healthy';
|
|
265
|
+
const body = (report.summary || subtitle).replace(/'/g, "\\'");
|
|
266
|
+
return `osascript -e 'display notification "${body}" with title "Ralph Heartbeat" subtitle "${subtitle}"'`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get exit code for a heartbeat report
|
|
271
|
+
* @param {object} report - heartbeat report with alerts array
|
|
272
|
+
* @returns {number} 0 if healthy or only info, 1 if any warning or critical
|
|
273
|
+
*/
|
|
274
|
+
function getExitCode(report) {
|
|
275
|
+
const alerts = report.alerts || [];
|
|
276
|
+
const hasActionable = alerts.some(a => a.severity === 'critical' || a.severity === 'warning');
|
|
277
|
+
return hasActionable ? 1 : 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
166
280
|
module.exports = {
|
|
281
|
+
heartbeat,
|
|
167
282
|
getDefaultHeartbeatConfig,
|
|
168
|
-
|
|
283
|
+
loadHeartbeatConfig,
|
|
284
|
+
evaluateLoop,
|
|
285
|
+
buildSummary,
|
|
286
|
+
saveLastHeartbeat,
|
|
287
|
+
loadLastHeartbeat,
|
|
288
|
+
shouldNotify,
|
|
289
|
+
buildMacosNotification,
|
|
290
|
+
getExitCode
|
|
169
291
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coder-config",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.6-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",
|