@yemi33/minions 0.1.1965 → 0.1.1967
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/bin/minions.js +6 -6
- package/dashboard/js/refresh.js +5 -0
- package/dashboard/js/render-managed.js +261 -0
- package/dashboard/js/render-other.js +5 -2
- package/dashboard/pages/engine.html +6 -0
- package/dashboard/styles.css +21 -4
- package/dashboard-build.js +1 -1
- package/dashboard.js +250 -1
- package/docs/README.md +10 -13
- package/docs/managed-spawn.md +259 -0
- package/docs/watches.md +47 -20
- package/engine/cli.js +39 -0
- package/engine/managed-spawn.js +1325 -0
- package/engine/playbook.js +34 -0
- package/engine/projects.js +13 -0
- package/engine/shared.js +118 -0
- package/engine.js +264 -14
- package/package.json +2 -1
package/bin/minions.js
CHANGED
|
@@ -169,13 +169,13 @@ function killMinionsProcesses(patterns) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/** Open append-mode FDs under engine/ for detached-process stdio. If the file
|
|
172
|
-
* can't be opened we fall back to 'ignore' rather than blocking the restart.
|
|
172
|
+
* can't be opened we fall back to 'ignore' rather than blocking the restart.
|
|
173
|
+
* Delegates to shared.openAppendLogFd (P-8a4d6f29) so the rotation behavior
|
|
174
|
+
* (rename to .1 when current file exceeds the managed-spawn log cap) is
|
|
175
|
+
* uniform across every long-running log Minions writes. */
|
|
173
176
|
function _openStdioLog(name) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
177
|
-
return fs.openSync(path.join(dir, name), 'a');
|
|
178
|
-
} catch { return 'ignore'; }
|
|
177
|
+
const dir = path.join(MINIONS_HOME, 'engine');
|
|
178
|
+
return shared.openAppendLogFd(name, dir, { fallback: 'ignore' }).fd;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/** Spawn a detached dashboard with self-open suppressed — the CLI decides
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -107,6 +107,11 @@ function _processStatusUpdate(data) {
|
|
|
107
107
|
if (typeof renderKeepProcesses === 'function') {
|
|
108
108
|
try { renderKeepProcesses(); } catch {}
|
|
109
109
|
}
|
|
110
|
+
// managed-processes panel — same engine-page-only pattern, ETag-gated so
|
|
111
|
+
// unchanged ticks return 304 with no body (P-6e2a8b13).
|
|
112
|
+
if (typeof renderManagedProcesses === 'function') {
|
|
113
|
+
try { renderManagedProcesses(); } catch {}
|
|
114
|
+
}
|
|
110
115
|
if (_changed('workItems', data.workItems)) renderWorkItems(data.workItems || []);
|
|
111
116
|
if (_changed('skills', data.skills)) renderSkills(data.skills || []);
|
|
112
117
|
if (_changed('mcpServers', data.mcpServers)) renderMcpServers(data.mcpServers || []);
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// dashboard/js/render-managed.js — Live managed-processes panel + log viewer modal.
|
|
2
|
+
// P-6e2a8b13 (plan W-mp7k1r760003b5dd item 5).
|
|
3
|
+
//
|
|
4
|
+
// Polls /api/managed-processes with If-None-Match every refresh tick on the
|
|
5
|
+
// engine page (cheap: server returns 304 when nothing changed). Renders one
|
|
6
|
+
// project-grouped table per owner_project with name/pid/healthy/ports/attrs/
|
|
7
|
+
// uptime/TTL + per-row kill, restart, log buttons.
|
|
8
|
+
//
|
|
9
|
+
// Log button opens a self-contained modal overlay that streams the spec's
|
|
10
|
+
// log_path via SSE EventSource (`/api/managed-processes/log-stream/<name>`).
|
|
11
|
+
// One stream at a time — closing the modal aborts the EventSource so we
|
|
12
|
+
// don't blow through HTTP/1.1 connection slots (cf. live-stream.js note
|
|
13
|
+
// about why the agent live tab moved off SSE).
|
|
14
|
+
|
|
15
|
+
let _managedProcessesEtag = null;
|
|
16
|
+
let _managedLogES = null;
|
|
17
|
+
|
|
18
|
+
function _fmtAgo(ms) {
|
|
19
|
+
if (!ms || ms < 0) return '0s';
|
|
20
|
+
const s = Math.floor(ms / 1000);
|
|
21
|
+
if (s < 60) return s + 's';
|
|
22
|
+
const m = Math.floor(s / 60);
|
|
23
|
+
if (m < 60) return m + 'm';
|
|
24
|
+
const h = Math.floor(m / 60);
|
|
25
|
+
if (h < 24) return h + 'h ' + (m % 60) + 'm';
|
|
26
|
+
const d = Math.floor(h / 24);
|
|
27
|
+
return d + 'd ' + (h % 24) + 'h';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _fmtAttrs(attrs) {
|
|
31
|
+
if (!attrs || typeof attrs !== 'object') return '';
|
|
32
|
+
const parts = [];
|
|
33
|
+
for (const k of Object.keys(attrs)) {
|
|
34
|
+
const v = attrs[k];
|
|
35
|
+
if (v == null) continue;
|
|
36
|
+
const s = (typeof v === 'object') ? JSON.stringify(v) : String(v);
|
|
37
|
+
// Compact: clip individual values, keep the panel scannable.
|
|
38
|
+
parts.push(escHtml(k) + '=' + escHtml(s.length > 40 ? s.slice(0, 37) + '...' : s));
|
|
39
|
+
}
|
|
40
|
+
return parts.join(' ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function _renderManagedTable(items) {
|
|
44
|
+
const rows = items.map(function (s) {
|
|
45
|
+
const healthBadge = s.healthy
|
|
46
|
+
? '<span style="color:var(--green)">●</span> healthy'
|
|
47
|
+
: (s.alive ? '<span style="color:var(--yellow)">●</span> starting' : '<span style="color:var(--muted)">○</span> down');
|
|
48
|
+
const ports = (s.ports && s.ports.length) ? s.ports.join(',') : '';
|
|
49
|
+
const uptime = s.started_at ? _fmtAgo(Date.now() - s.started_at) : '';
|
|
50
|
+
const ttl = s.ttl_expires_at ? _fmtAgo(s.ttl_expires_at - Date.now()) : '';
|
|
51
|
+
const ttlColor = (s.ttl_expires_at && s.ttl_expires_at - Date.now() < 5 * 60 * 1000) ? 'var(--yellow)' : 'var(--muted)';
|
|
52
|
+
const nameAttr = encodeURIComponent(s.name || '');
|
|
53
|
+
return '<tr>' +
|
|
54
|
+
'<td style="padding:4px 8px;font-family:monospace;font-size:11px">' + escHtml(s.name || '') + '</td>' +
|
|
55
|
+
'<td style="padding:4px 8px;font-family:monospace;font-size:11px">' + (s.pid || '') + '</td>' +
|
|
56
|
+
'<td style="padding:4px 8px;font-size:11px">' + healthBadge + '</td>' +
|
|
57
|
+
'<td style="padding:4px 8px;font-size:11px;font-family:monospace">' + escHtml(ports) + '</td>' +
|
|
58
|
+
'<td style="padding:4px 8px;font-size:10px;color:var(--muted);max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(_fmtAttrs(s.attrs).replace(/<[^>]+>/g, '')) + '">' + _fmtAttrs(s.attrs) + '</td>' +
|
|
59
|
+
'<td style="padding:4px 8px;font-size:11px;color:var(--muted)">' + escHtml(uptime) + '</td>' +
|
|
60
|
+
'<td style="padding:4px 8px;font-size:11px;color:' + ttlColor + '">' + escHtml(ttl) + '</td>' +
|
|
61
|
+
'<td style="padding:4px 8px;text-align:right;white-space:nowrap">' +
|
|
62
|
+
'<button onclick="openManagedLog(decodeURIComponent(\'' + nameAttr + '\'))" style="font-size:10px;padding:2px 6px;margin-right:4px;border:1px solid var(--border);background:var(--surface);color:var(--fg);border-radius:3px;cursor:pointer">Log</button>' +
|
|
63
|
+
'<button onclick="restartManagedSpec(decodeURIComponent(\'' + nameAttr + '\'))" style="font-size:10px;padding:2px 6px;margin-right:4px;border:1px solid var(--blue);background:transparent;color:var(--blue);border-radius:3px;cursor:pointer">Restart</button>' +
|
|
64
|
+
'<button onclick="killManagedSpec(decodeURIComponent(\'' + nameAttr + '\'))" style="font-size:10px;padding:2px 6px;border:1px solid var(--red);background:transparent;color:var(--red);border-radius:3px;cursor:pointer">Kill</button>' +
|
|
65
|
+
'</td>' +
|
|
66
|
+
'</tr>';
|
|
67
|
+
}).join('');
|
|
68
|
+
return '<table style="width:100%;border-collapse:collapse;margin-bottom:8px">' +
|
|
69
|
+
'<thead><tr style="text-align:left;font-size:10px;color:var(--muted);text-transform:uppercase">' +
|
|
70
|
+
'<th style="padding:4px 8px">name</th>' +
|
|
71
|
+
'<th style="padding:4px 8px">pid</th>' +
|
|
72
|
+
'<th style="padding:4px 8px">health</th>' +
|
|
73
|
+
'<th style="padding:4px 8px">ports</th>' +
|
|
74
|
+
'<th style="padding:4px 8px">attrs</th>' +
|
|
75
|
+
'<th style="padding:4px 8px">uptime</th>' +
|
|
76
|
+
'<th style="padding:4px 8px">ttl</th>' +
|
|
77
|
+
'<th style="padding:4px 8px;text-align:right">actions</th>' +
|
|
78
|
+
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function renderManagedProcesses() {
|
|
82
|
+
const root = document.getElementById('managed-processes-content');
|
|
83
|
+
const countEl = document.getElementById('managed-processes-count');
|
|
84
|
+
if (!root) return;
|
|
85
|
+
let items;
|
|
86
|
+
let fetchErr = null;
|
|
87
|
+
let notModified = false;
|
|
88
|
+
try {
|
|
89
|
+
const headers = {};
|
|
90
|
+
if (_managedProcessesEtag) headers['If-None-Match'] = _managedProcessesEtag;
|
|
91
|
+
const res = await fetch('/api/managed-processes', { headers });
|
|
92
|
+
if (res.status === 304) {
|
|
93
|
+
notModified = true;
|
|
94
|
+
} else {
|
|
95
|
+
const et = res.headers.get('ETag');
|
|
96
|
+
if (et) _managedProcessesEtag = et;
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
items = (data && Array.isArray(data.items)) ? data.items : [];
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
fetchErr = e;
|
|
102
|
+
}
|
|
103
|
+
if (notModified) return; // nothing changed since last render
|
|
104
|
+
let html;
|
|
105
|
+
if (fetchErr) {
|
|
106
|
+
if (countEl) countEl.textContent = '?';
|
|
107
|
+
html = '<span style="color:var(--red)">Failed to load: ' + escHtml(fetchErr.message) + '</span>';
|
|
108
|
+
} else if (!items || !items.length) {
|
|
109
|
+
if (countEl) countEl.textContent = '0';
|
|
110
|
+
html = '<p class="empty">No managed processes. Agents declare them via <code>agents/<id>/managed-spawn.json</code>.</p>';
|
|
111
|
+
} else {
|
|
112
|
+
if (countEl) countEl.textContent = String(items.length);
|
|
113
|
+
// Group by owner_project (empty/missing groups under "(unassigned)").
|
|
114
|
+
const groups = {};
|
|
115
|
+
for (const s of items) {
|
|
116
|
+
const key = s.owner_project || '(unassigned)';
|
|
117
|
+
(groups[key] = groups[key] || []).push(s);
|
|
118
|
+
}
|
|
119
|
+
const names = Object.keys(groups).sort();
|
|
120
|
+
html = names.map(function (proj) {
|
|
121
|
+
const group = groups[proj];
|
|
122
|
+
group.sort(function (a, b) { return (a.name || '').localeCompare(b.name || ''); });
|
|
123
|
+
return '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:8px;background:var(--surface2)">' +
|
|
124
|
+
'<div style="font-weight:600;font-size:11px;margin-bottom:6px">' + escHtml(proj) +
|
|
125
|
+
' <span style="color:var(--muted);font-weight:400">(' + group.length + ')</span>' +
|
|
126
|
+
'</div>' +
|
|
127
|
+
_renderManagedTable(group) +
|
|
128
|
+
'</div>';
|
|
129
|
+
}).join('');
|
|
130
|
+
}
|
|
131
|
+
// DocumentFragment instead of innerHTML — keeps the file out of the
|
|
132
|
+
// dynamic-innerHTML regression gate (cf. render-other.js renderKeepProcesses).
|
|
133
|
+
const range = document.createRange();
|
|
134
|
+
const frag = range.createContextualFragment(html);
|
|
135
|
+
root.replaceChildren(frag);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function killManagedSpec(name) {
|
|
139
|
+
if (!confirm('Kill managed process "' + name + '"? PID will be terminated and the spec removed from engine state.')) return;
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch('/api/managed-processes/kill', {
|
|
142
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({ name }),
|
|
144
|
+
});
|
|
145
|
+
const data = await res.json().catch(() => ({}));
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
alert('Failed: ' + (data.error || res.status));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (typeof showToast === 'function') showToast('cmd-toast', 'Killed ' + name + (data.killed_pid ? ' (PID ' + data.killed_pid + ')' : ''), true);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
alert('Network error: ' + e.message);
|
|
153
|
+
}
|
|
154
|
+
_managedProcessesEtag = null; // force re-render
|
|
155
|
+
renderManagedProcesses();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function restartManagedSpec(name) {
|
|
159
|
+
if (!confirm('Restart managed process "' + name + '"? The current PID will be killed and a new one spawned from saved state.')) return;
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch('/api/managed-processes/restart', {
|
|
162
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({ name }),
|
|
164
|
+
});
|
|
165
|
+
const data = await res.json().catch(() => ({}));
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
alert('Failed: ' + (data.error || res.status));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (typeof showToast === 'function') showToast('cmd-toast', 'Restarted ' + name + (data.pid ? ' (new PID ' + data.pid + ')' : ''), true);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
alert('Network error: ' + e.message);
|
|
173
|
+
}
|
|
174
|
+
_managedProcessesEtag = null;
|
|
175
|
+
renderManagedProcesses();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _ensureManagedLogModal() {
|
|
179
|
+
let overlay = document.getElementById('managed-log-modal');
|
|
180
|
+
if (overlay) return overlay;
|
|
181
|
+
overlay = document.createElement('div');
|
|
182
|
+
overlay.id = 'managed-log-modal';
|
|
183
|
+
overlay.style.cssText = 'display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:1000;align-items:center;justify-content:center;padding:24px';
|
|
184
|
+
// Static markup, no user data — use createContextualFragment so we stay
|
|
185
|
+
// out of the dynamic-innerHTML regression gate (test/unit.test.js DYNAMIC_INNERHTML_BASELINE).
|
|
186
|
+
const tpl = '' +
|
|
187
|
+
'<div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;width:100%;max-width:1100px;height:80vh;display:flex;flex-direction:column">' +
|
|
188
|
+
'<div style="display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--border)">' +
|
|
189
|
+
'<div id="managed-log-title" style="flex:1;font-weight:600;font-size:12px;font-family:monospace">Managed log</div>' +
|
|
190
|
+
'<div id="managed-log-status" style="font-size:10px;color:var(--muted);margin-right:12px">connecting...</div>' +
|
|
191
|
+
'<button id="managed-log-close-btn" style="font-size:10px;padding:4px 10px;border:1px solid var(--border);background:transparent;color:var(--fg);border-radius:3px;cursor:pointer">Close</button>' +
|
|
192
|
+
'</div>' +
|
|
193
|
+
'<pre id="managed-log-body" style="flex:1;overflow:auto;margin:0;padding:8px 12px;font-family:monospace;font-size:11px;white-space:pre-wrap;word-break:break-all;background:var(--surface2)"></pre>' +
|
|
194
|
+
'</div>';
|
|
195
|
+
const frag = document.createRange().createContextualFragment(tpl);
|
|
196
|
+
overlay.appendChild(frag);
|
|
197
|
+
document.body.appendChild(overlay);
|
|
198
|
+
overlay.addEventListener('click', function (e) {
|
|
199
|
+
if (e.target === overlay) closeManagedLog();
|
|
200
|
+
});
|
|
201
|
+
const closeBtn = overlay.querySelector('#managed-log-close-btn');
|
|
202
|
+
if (closeBtn) closeBtn.addEventListener('click', closeManagedLog);
|
|
203
|
+
return overlay;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function openManagedLog(name) {
|
|
207
|
+
closeManagedLog(); // single-stream invariant
|
|
208
|
+
const overlay = _ensureManagedLogModal();
|
|
209
|
+
const title = overlay.querySelector('#managed-log-title');
|
|
210
|
+
const status = overlay.querySelector('#managed-log-status');
|
|
211
|
+
const body = overlay.querySelector('#managed-log-body');
|
|
212
|
+
if (title) title.textContent = 'Log: ' + name;
|
|
213
|
+
if (body) body.textContent = '';
|
|
214
|
+
if (status) status.textContent = 'connecting...';
|
|
215
|
+
overlay.style.display = 'flex';
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const es = new EventSource('/api/managed-processes/log-stream/' + encodeURIComponent(name));
|
|
219
|
+
_managedLogES = es;
|
|
220
|
+
es.onopen = function () { if (status) status.textContent = 'streaming'; };
|
|
221
|
+
es.onmessage = function (ev) {
|
|
222
|
+
let chunk;
|
|
223
|
+
try { chunk = JSON.parse(ev.data); }
|
|
224
|
+
catch { chunk = ev.data; }
|
|
225
|
+
if (typeof chunk !== 'string') chunk = String(chunk);
|
|
226
|
+
if (!body) return;
|
|
227
|
+
const wasAtBottom = (body.scrollHeight - body.scrollTop - body.clientHeight) < 40;
|
|
228
|
+
body.appendChild(document.createTextNode(chunk));
|
|
229
|
+
if (wasAtBottom) body.scrollTop = body.scrollHeight;
|
|
230
|
+
};
|
|
231
|
+
es.addEventListener('done', function (ev) {
|
|
232
|
+
if (status) status.textContent = 'closed (' + (ev.data || 'done') + ')';
|
|
233
|
+
try { es.close(); } catch { /* optional */ }
|
|
234
|
+
if (_managedLogES === es) _managedLogES = null;
|
|
235
|
+
});
|
|
236
|
+
es.onerror = function () {
|
|
237
|
+
if (status) status.textContent = 'disconnected';
|
|
238
|
+
// EventSource auto-reconnects; if the spec is gone the server will emit
|
|
239
|
+
// a 'done' event on the next attempt.
|
|
240
|
+
};
|
|
241
|
+
} catch (e) {
|
|
242
|
+
if (status) status.textContent = 'failed: ' + e.message;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function closeManagedLog() {
|
|
247
|
+
if (_managedLogES) {
|
|
248
|
+
try { _managedLogES.close(); } catch { /* optional */ }
|
|
249
|
+
_managedLogES = null;
|
|
250
|
+
}
|
|
251
|
+
const overlay = document.getElementById('managed-log-modal');
|
|
252
|
+
if (overlay) overlay.style.display = 'none';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
window.MinionsManagedProcesses = {
|
|
256
|
+
renderManagedProcesses,
|
|
257
|
+
killManagedSpec,
|
|
258
|
+
restartManagedSpec,
|
|
259
|
+
openManagedLog,
|
|
260
|
+
closeManagedLog,
|
|
261
|
+
};
|
|
@@ -23,17 +23,20 @@ function renderProjects(projects) {
|
|
|
23
23
|
|
|
24
24
|
// Renders the per-project current-branch indicator. Driven by the gitState
|
|
25
25
|
// classifier emitted by getProjectGitStatus() in engine/queries.js.
|
|
26
|
+
// Adds a title attribute carrying the full branch name so users can still
|
|
27
|
+
// read it on hover after the CSS ellipsis-truncate (W-mpayac6d000b7d33).
|
|
26
28
|
function _renderProjectBranch(p) {
|
|
27
29
|
if (!p) return '';
|
|
28
30
|
if (p.gitState === 'missing') return '<span class="project-warn" title="Project localPath does not exist on disk">(path not found)</span>';
|
|
29
31
|
if (p.gitState === 'non-git') return '<span class="project-muted" title="Project path exists but is not a git repository">(not a git repo)</span>';
|
|
30
32
|
if (p.gitState !== 'ok' || !p.gitBranch) return '';
|
|
31
33
|
const branch = escHtml(p.gitBranch);
|
|
34
|
+
const titleAttr = ' title="on: ' + branch + (p.gitDetached ? ' (detached)' : '') + (p.gitDirty ? ' (dirty)' : '') + '"';
|
|
32
35
|
const dirty = p.gitDirty ? ' <span class="dot-dirty" title="Working tree has uncommitted changes">●</span>' : '';
|
|
33
36
|
if (p.gitDetached) {
|
|
34
|
-
return '<span class="project-branch">on: ' + branch + ' <span class="muted">(detached)</span>' + dirty + '</span>';
|
|
37
|
+
return '<span class="project-branch"' + titleAttr + '>on: ' + branch + ' <span class="muted">(detached)</span>' + dirty + '</span>';
|
|
35
38
|
}
|
|
36
|
-
return '<span class="project-branch">on: ' + branch + dirty + '</span>';
|
|
39
|
+
return '<span class="project-branch"' + titleAttr + '>on: ' + branch + dirty + '</span>';
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
function _projectCachePath(project) {
|
|
@@ -25,3 +25,9 @@
|
|
|
25
25
|
</h2>
|
|
26
26
|
<div id="keep-processes-content"><p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p></div>
|
|
27
27
|
</section>
|
|
28
|
+
<section id="managed-processes-section">
|
|
29
|
+
<h2>Managed Processes <span class="count" id="managed-processes-count">0</span>
|
|
30
|
+
<span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">engine-managed long-running services (P-6e2a8b13 — managed-spawn primitive)</span>
|
|
31
|
+
</h2>
|
|
32
|
+
<div id="managed-processes-content"><p class="empty">No managed processes. Agents declare them via <code>agents/<id>/managed-spawn.json</code>.</p></div>
|
|
33
|
+
</section>
|
package/dashboard/styles.css
CHANGED
|
@@ -709,9 +709,26 @@
|
|
|
709
709
|
/* Ensure all tables scroll horizontally on narrow screens */
|
|
710
710
|
.pr-table-wrap, #pr-content, #completed-content, #work-items-content { overflow-x: auto; min-width: 0; }
|
|
711
711
|
|
|
712
|
-
/* Project chip — current git branch indicator
|
|
713
|
-
|
|
712
|
+
/* Project chip — current git branch indicator.
|
|
713
|
+
* max-width + ellipsis truncation prevents pathological branch names
|
|
714
|
+
* (e.g. `pipeline/daily-issue-fix/...`) from widening the inline-flex chip
|
|
715
|
+
* past the viewport. Full text remains available via the title attribute
|
|
716
|
+
* added in render-other.js _renderProjectBranch. (W-mpayac6d000b7d33) */
|
|
717
|
+
.project-branch {
|
|
718
|
+
font-size: 10px; color: var(--muted); font-weight: 400;
|
|
719
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
720
|
+
display: inline-block; max-width: 240px; overflow: hidden;
|
|
721
|
+
text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
|
|
722
|
+
}
|
|
714
723
|
.project-branch .muted { color: var(--muted); }
|
|
715
724
|
.project-branch .dot-dirty { color: var(--yellow); margin-left: 2px; }
|
|
716
|
-
.project-warn {
|
|
717
|
-
|
|
725
|
+
.project-warn {
|
|
726
|
+
font-size: 10px; color: var(--yellow); font-style: italic; font-weight: 400;
|
|
727
|
+
display: inline-block; max-width: 240px; overflow: hidden;
|
|
728
|
+
text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
|
|
729
|
+
}
|
|
730
|
+
.project-muted {
|
|
731
|
+
font-size: 10px; color: var(--muted); font-style: italic; font-weight: 400;
|
|
732
|
+
display: inline-block; max-width: 240px; overflow: hidden;
|
|
733
|
+
text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
|
|
734
|
+
}
|
package/dashboard-build.js
CHANGED
|
@@ -32,7 +32,7 @@ function buildDashboardHtml() {
|
|
|
32
32
|
'utils', 'state', 'features-client', 'detail-panel', 'live-stream',
|
|
33
33
|
'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
|
|
34
34
|
'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
|
|
35
|
-
'render-other', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
35
|
+
'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
36
36
|
'command-parser', 'command-input', 'command-center', 'command-history',
|
|
37
37
|
'modal', 'modal-qa', 'settings', 'refresh'
|
|
38
38
|
];
|
package/dashboard.js
CHANGED
|
@@ -874,7 +874,7 @@ function buildDashboardHtml() {
|
|
|
874
874
|
'utils', 'state', 'features-client', 'render-utils', 'detail-panel', 'live-stream',
|
|
875
875
|
'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
|
|
876
876
|
'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
|
|
877
|
-
'render-other', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
877
|
+
'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
878
878
|
'command-parser', 'command-input', 'command-center', 'command-history',
|
|
879
879
|
'modal', 'modal-qa', 'settings', 'refresh'
|
|
880
880
|
];
|
|
@@ -7901,6 +7901,242 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7901
7901
|
}
|
|
7902
7902
|
}
|
|
7903
7903
|
|
|
7904
|
+
// ── Managed processes API (P-4b8d2e57, plan W-mp7k1r760003b5dd item 4) ──
|
|
7905
|
+
// List + by-name + kill + restart for the engine-owned managed-spawn
|
|
7906
|
+
// services (engine/managed-processes.json). Mirrors the keep-processes
|
|
7907
|
+
// pattern above but lives a layer deeper because the state is engine-side
|
|
7908
|
+
// (not per-agent sidecars).
|
|
7909
|
+
|
|
7910
|
+
function _managedSpecToApiShape(rec) {
|
|
7911
|
+
if (!rec || typeof rec !== 'object') return rec;
|
|
7912
|
+
return {
|
|
7913
|
+
name: rec.name,
|
|
7914
|
+
pid: rec.pid,
|
|
7915
|
+
alive: !!rec.alive,
|
|
7916
|
+
healthy: !!rec.healthy,
|
|
7917
|
+
owner_agent: rec.owner_agent || '',
|
|
7918
|
+
owner_wi: rec.owner_wi || '',
|
|
7919
|
+
owner_project: rec.owner_project || '',
|
|
7920
|
+
attrs: rec.attrs || {},
|
|
7921
|
+
ports: Array.isArray(rec.ports) ? rec.ports : [],
|
|
7922
|
+
started_at: rec.started_at,
|
|
7923
|
+
ttl_expires_at: rec.ttl_expires_at,
|
|
7924
|
+
last_health_at: rec.last_health_at,
|
|
7925
|
+
log_path: rec.log_path || '',
|
|
7926
|
+
// Spec body so the dashboard can show what was spawned without an extra
|
|
7927
|
+
// round-trip. attrs is already exposed above.
|
|
7928
|
+
cmd: rec.cmd,
|
|
7929
|
+
args: Array.isArray(rec.args) ? rec.args : [],
|
|
7930
|
+
cwd: rec.cwd || '',
|
|
7931
|
+
healthcheck: rec.healthcheck || null,
|
|
7932
|
+
};
|
|
7933
|
+
}
|
|
7934
|
+
|
|
7935
|
+
function handleManagedProcessesList(req, res) {
|
|
7936
|
+
try {
|
|
7937
|
+
const managedSpawn = require('./engine/managed-spawn');
|
|
7938
|
+
const u = new URL(req.url, 'http://x');
|
|
7939
|
+
const project = (u.searchParams.get('project') || '').trim();
|
|
7940
|
+
const opts = project ? { project } : {};
|
|
7941
|
+
const etag = managedSpawn.computeManagedSpecsEtag(opts);
|
|
7942
|
+
if (req.headers['if-none-match'] === etag) {
|
|
7943
|
+
res.statusCode = 304;
|
|
7944
|
+
res.setHeader('ETag', etag);
|
|
7945
|
+
return res.end();
|
|
7946
|
+
}
|
|
7947
|
+
const items = managedSpawn.listManagedSpecs(opts).map(_managedSpecToApiShape);
|
|
7948
|
+
res.setHeader('ETag', etag);
|
|
7949
|
+
return jsonReply(res, 200, { items, generatedAt: new Date().toISOString() }, req);
|
|
7950
|
+
} catch (e) {
|
|
7951
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
7952
|
+
}
|
|
7953
|
+
}
|
|
7954
|
+
|
|
7955
|
+
function handleManagedProcessesByName(req, res) {
|
|
7956
|
+
try {
|
|
7957
|
+
const managedSpawn = require('./engine/managed-spawn');
|
|
7958
|
+
const u = new URL(req.url, 'http://x');
|
|
7959
|
+
const prefix = '/api/managed-processes/by-name/';
|
|
7960
|
+
const name = decodeURIComponent(u.pathname.slice(prefix.length));
|
|
7961
|
+
if (!name) return jsonReply(res, 400, { error: 'name required' }, req);
|
|
7962
|
+
const rec = managedSpawn.listManagedSpecs().find(s => s && s.name === name);
|
|
7963
|
+
if (!rec) return jsonReply(res, 404, { error: `managed spec not found: ${name}` }, req);
|
|
7964
|
+
return jsonReply(res, 200, _managedSpecToApiShape(rec), req);
|
|
7965
|
+
} catch (e) {
|
|
7966
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
7967
|
+
}
|
|
7968
|
+
}
|
|
7969
|
+
|
|
7970
|
+
async function handleManagedProcessesKill(req, res) {
|
|
7971
|
+
try {
|
|
7972
|
+
const body = await readBody(req);
|
|
7973
|
+
const name = String(body.name || '').trim();
|
|
7974
|
+
if (!name) return jsonReply(res, 400, { error: 'name required' }, req);
|
|
7975
|
+
const managedSpawn = require('./engine/managed-spawn');
|
|
7976
|
+
const before = managedSpawn.listManagedSpecs().find(s => s && s.name === name);
|
|
7977
|
+
if (!before) return jsonReply(res, 404, { error: `managed spec not found: ${name}` }, req);
|
|
7978
|
+
managedSpawn.removeManagedSpec(name);
|
|
7979
|
+
shared.log('info', `managed-processes manual kill: name=${name} pid=${before.pid}`);
|
|
7980
|
+
return jsonReply(res, 200, { ok: true, name, killed_pid: before.pid }, req);
|
|
7981
|
+
} catch (e) {
|
|
7982
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
7983
|
+
}
|
|
7984
|
+
}
|
|
7985
|
+
|
|
7986
|
+
async function handleManagedProcessesRestart(req, res) {
|
|
7987
|
+
try {
|
|
7988
|
+
const body = await readBody(req);
|
|
7989
|
+
const name = String(body.name || '').trim();
|
|
7990
|
+
if (!name) return jsonReply(res, 400, { error: 'name required' }, req);
|
|
7991
|
+
const managedSpawn = require('./engine/managed-spawn');
|
|
7992
|
+
let runtime;
|
|
7993
|
+
try { runtime = managedSpawn.restartManagedSpec(name); }
|
|
7994
|
+
catch (e) {
|
|
7995
|
+
// Distinguish unknown-name (404) from other errors (500).
|
|
7996
|
+
if (/not found/i.test(e.message)) {
|
|
7997
|
+
return jsonReply(res, 404, { error: e.message }, req);
|
|
7998
|
+
}
|
|
7999
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
8000
|
+
}
|
|
8001
|
+
// Kick off the healthcheck loop in the background — same shape the
|
|
8002
|
+
// engine close-handler uses (item 3). We DON'T await it here: the
|
|
8003
|
+
// dashboard endpoint returns immediately so the caller isn't blocked
|
|
8004
|
+
// by a potentially-60s timeout_s. The loop self-flips state.healthy.
|
|
8005
|
+
const newSpec = managedSpawn.listManagedSpecs().find(s => s && s.name === name);
|
|
8006
|
+
if (newSpec && newSpec.healthcheck) {
|
|
8007
|
+
managedSpawn.waitForFirstHealth(newSpec).catch((err) => {
|
|
8008
|
+
shared.log('warn', `managed-processes restart: waitForFirstHealth failed for ${name}: ${err.message}`);
|
|
8009
|
+
});
|
|
8010
|
+
}
|
|
8011
|
+
return jsonReply(res, 200, { ok: true, name, pid: runtime.pid, started_at: runtime.started_at, log_path: runtime.log_path }, req);
|
|
8012
|
+
} catch (e) {
|
|
8013
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
8014
|
+
}
|
|
8015
|
+
}
|
|
8016
|
+
|
|
8017
|
+
// ── Managed-process log SSE (P-6e2a8b13, plan W-mp7k1r760003b5dd item 5) ──
|
|
8018
|
+
// Tail-and-stream `<log_path>` for a named managed spec. Mirrors
|
|
8019
|
+
// handleAgentLiveStream (line ~4590) — same Origin gate, same fs.watchFile
|
|
8020
|
+
// poll, same safeWrite + idempotent cleanup. Spec lookup goes through
|
|
8021
|
+
// managedSpawn.listManagedSpecs so the log path travels through the same
|
|
8022
|
+
// sanitization layer the API/state-file use (no client-supplied paths).
|
|
8023
|
+
function handleManagedProcessesLogStream(req, res, match) {
|
|
8024
|
+
const _origin = req.headers['origin'];
|
|
8025
|
+
if (_origin && !shared.isAllowedOrigin(_origin)) {
|
|
8026
|
+
console.warn(`[sse-origin-gate] reject GET ${req.url} origin=${_origin}`);
|
|
8027
|
+
res.statusCode = 403;
|
|
8028
|
+
res.setHeader('Content-Type', 'application/json');
|
|
8029
|
+
res.end(JSON.stringify({ error: 'Origin not allowed' }));
|
|
8030
|
+
return;
|
|
8031
|
+
}
|
|
8032
|
+
|
|
8033
|
+
const name = decodeURIComponent(match[1] || '');
|
|
8034
|
+
let spec = null;
|
|
8035
|
+
try {
|
|
8036
|
+
const managedSpawn = require('./engine/managed-spawn');
|
|
8037
|
+
spec = managedSpawn.listManagedSpecs().find(s => s && s.name === name);
|
|
8038
|
+
} catch { /* missing module or unreadable state — handled below */ }
|
|
8039
|
+
|
|
8040
|
+
res.writeHead(200, {
|
|
8041
|
+
'Content-Type': 'text/event-stream',
|
|
8042
|
+
'Cache-Control': 'no-cache',
|
|
8043
|
+
'Connection': 'keep-alive',
|
|
8044
|
+
});
|
|
8045
|
+
|
|
8046
|
+
if (!spec) {
|
|
8047
|
+
res.write(`data: ${JSON.stringify('Managed spec not found: ' + name)}\n\n`);
|
|
8048
|
+
res.write(`event: done\ndata: not-found\n\n`);
|
|
8049
|
+
res.end();
|
|
8050
|
+
return;
|
|
8051
|
+
}
|
|
8052
|
+
|
|
8053
|
+
const logPath = spec.log_path || '';
|
|
8054
|
+
if (!logPath || !fs.existsSync(logPath)) {
|
|
8055
|
+
res.write(`data: ${JSON.stringify('Log file not available for ' + name + (logPath ? ' (' + logPath + ')' : ''))}\n\n`);
|
|
8056
|
+
res.write(`event: done\ndata: no-log\n\n`);
|
|
8057
|
+
res.end();
|
|
8058
|
+
return;
|
|
8059
|
+
}
|
|
8060
|
+
|
|
8061
|
+
let _cleanedUp = false;
|
|
8062
|
+
const safeWrite = (data) => {
|
|
8063
|
+
if (_cleanedUp) return;
|
|
8064
|
+
try { res.write(data); } catch { /* EPIPE — client gone */ }
|
|
8065
|
+
};
|
|
8066
|
+
|
|
8067
|
+
// Initial tail (default 64KB, capped by client via ?tail=).
|
|
8068
|
+
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
8069
|
+
const tailBytes = Math.max(0, parseInt(params.get('tail') || '65536', 10) || 65536);
|
|
8070
|
+
let offset = 0;
|
|
8071
|
+
try {
|
|
8072
|
+
const stat = fs.statSync(logPath);
|
|
8073
|
+
if (stat.size > 0) {
|
|
8074
|
+
const readStart = Math.max(0, stat.size - tailBytes);
|
|
8075
|
+
const readLen = stat.size - readStart;
|
|
8076
|
+
const fd = fs.openSync(logPath, 'r');
|
|
8077
|
+
const buf = Buffer.alloc(readLen);
|
|
8078
|
+
fs.readSync(fd, buf, 0, readLen, readStart);
|
|
8079
|
+
fs.closeSync(fd);
|
|
8080
|
+
const content = buf.toString('utf8');
|
|
8081
|
+
if (content) safeWrite(`data: ${JSON.stringify(content)}\n\n`);
|
|
8082
|
+
offset = stat.size;
|
|
8083
|
+
}
|
|
8084
|
+
} catch { /* file may have been rotated/removed between exists and stat */ }
|
|
8085
|
+
|
|
8086
|
+
// Tail-watch for appends. Mirrors handleAgentLiveStream's watchFile loop.
|
|
8087
|
+
const watcher = () => {
|
|
8088
|
+
if (_cleanedUp) return;
|
|
8089
|
+
try {
|
|
8090
|
+
const stat = fs.statSync(logPath);
|
|
8091
|
+
if (stat.size < offset) {
|
|
8092
|
+
// Log was rotated — restart from 0 so the operator sees the new tail.
|
|
8093
|
+
offset = 0;
|
|
8094
|
+
}
|
|
8095
|
+
if (stat.size > offset) {
|
|
8096
|
+
const fd = fs.openSync(logPath, 'r');
|
|
8097
|
+
const buf = Buffer.alloc(stat.size - offset);
|
|
8098
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
8099
|
+
fs.closeSync(fd);
|
|
8100
|
+
offset = stat.size;
|
|
8101
|
+
const chunk = buf.toString('utf8');
|
|
8102
|
+
if (chunk) safeWrite(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
8103
|
+
}
|
|
8104
|
+
} catch { /* file may have been removed (kill); next doneCheck closes the stream */ }
|
|
8105
|
+
};
|
|
8106
|
+
|
|
8107
|
+
fs.watchFile(logPath, { interval: 500 }, watcher);
|
|
8108
|
+
|
|
8109
|
+
const cleanup = () => {
|
|
8110
|
+
if (_cleanedUp) return;
|
|
8111
|
+
_cleanedUp = true;
|
|
8112
|
+
try { clearInterval(doneCheck); } catch { /* optional */ }
|
|
8113
|
+
try { fs.unwatchFile(logPath, watcher); } catch { /* optional */ }
|
|
8114
|
+
};
|
|
8115
|
+
|
|
8116
|
+
// Close the stream when the spec is removed (killed via API or swept).
|
|
8117
|
+
// Polls every 5s — same cadence handleAgentLiveStream uses for its
|
|
8118
|
+
// "is agent still active?" check.
|
|
8119
|
+
const doneCheck = setInterval(() => {
|
|
8120
|
+
if (_cleanedUp) return;
|
|
8121
|
+
try {
|
|
8122
|
+
const managedSpawn = require('./engine/managed-spawn');
|
|
8123
|
+
const still = managedSpawn.listManagedSpecs().some(s => s && s.name === name);
|
|
8124
|
+
if (!still) {
|
|
8125
|
+
watcher(); // flush final content
|
|
8126
|
+
safeWrite(`event: done\ndata: removed\n\n`);
|
|
8127
|
+
cleanup();
|
|
8128
|
+
try { res.end(); } catch { /* optional */ }
|
|
8129
|
+
}
|
|
8130
|
+
} catch (_e) {
|
|
8131
|
+
cleanup();
|
|
8132
|
+
try { res.end(); } catch { /* optional */ }
|
|
8133
|
+
}
|
|
8134
|
+
}, 5000);
|
|
8135
|
+
|
|
8136
|
+
req.on('close', cleanup);
|
|
8137
|
+
return;
|
|
8138
|
+
}
|
|
8139
|
+
|
|
7904
8140
|
// ── Route Registry ──────────────────────────────────────────────────────────
|
|
7905
8141
|
// Order matters: specific routes before general ones (e.g., /api/plans/approve before /api/plans/:file)
|
|
7906
8142
|
|
|
@@ -7958,6 +8194,19 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7958
8194
|
{ method: 'GET', path: '/api/health', desc: 'Lightweight health check for monitoring', handler: handleHealth },
|
|
7959
8195
|
{ method: 'GET', path: '/api/keep-processes', desc: 'List all active agents/<id>/keep-pids.json entries (W-mp68q6ke0010de68)', handler: handleKeepProcessesList },
|
|
7960
8196
|
{ method: 'POST', path: '/api/keep-processes/kill', desc: 'Kill a single kept PID and remove it from the agent\'s keep-pids.json', params: 'agentId, pid', handler: handleKeepProcessesKill },
|
|
8197
|
+
// P-4b8d2e57 (managed-spawn item 4): engine-managed long-running services.
|
|
8198
|
+
// List/by-name return ETag + honor If-None-Match → 304. Kill removes from
|
|
8199
|
+
// state + kills PID. Restart respawns from saved state and kicks off the
|
|
8200
|
+
// first healthcheck async.
|
|
8201
|
+
{ method: 'GET', path: '/api/managed-processes', desc: 'List engine-managed long-running services (managed-spawn). Optional ?project filter. Sends ETag + honors If-None-Match.', handler: handleManagedProcessesList },
|
|
8202
|
+
{ method: 'GET', path: /^\/api\/managed-processes\/by-name\/([^?]+)$/, template: '/api/managed-processes/by-name/<name>', desc: 'Get a single managed spec by name (managed-spawn).', handler: handleManagedProcessesByName },
|
|
8203
|
+
{ method: 'POST', path: '/api/managed-processes/kill', desc: 'Kill a managed spec by name (removes it from state).', params: 'name', handler: handleManagedProcessesKill },
|
|
8204
|
+
{ method: 'POST', path: '/api/managed-processes/restart', desc: 'Respawn a managed spec from its saved state and kick off the healthcheck loop. Returns the new PID.', params: 'name', handler: handleManagedProcessesRestart },
|
|
8205
|
+
// P-6e2a8b13 (managed-spawn item 5): SSE tail-and-stream of <log_path>
|
|
8206
|
+
// for the named spec. Mirrors handleAgentLiveStream — Origin gate,
|
|
8207
|
+
// 64KB initial tail, fs.watchFile poll for appends, auto-close when
|
|
8208
|
+
// the spec is removed from state.
|
|
8209
|
+
{ method: 'GET', path: /^\/api\/managed-processes\/log-stream\/([^?]+)$/, template: '/api/managed-processes/log-stream/<name>', desc: 'SSE tail-and-stream of <log_path> for a managed spec (managed-spawn). Optional ?tail=N initial-tail-bytes (default 65536).', handler: handleManagedProcessesLogStream },
|
|
7961
8210
|
{ method: 'GET', path: '/api/hot-reload', desc: 'SSE stream for dashboard hot-reload notifications', handler: (req, res) => {
|
|
7962
8211
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
7963
8212
|
res.write('data: connected\n\n');
|