@yemi33/minions 0.1.975 → 0.1.977
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/CHANGELOG.md +11 -9
- package/dashboard/js/refresh.js +1 -0
- package/dashboard/js/render-watches.js +310 -0
- package/dashboard/layout.html +1 -0
- package/dashboard/pages/watches.html +7 -0
- package/dashboard-build.js +2 -2
- package/dashboard.js +83 -3
- package/engine/shared.js +8 -0
- package/engine/watches.js +306 -0
- package/engine.js +19 -1
- package/package.json +1 -1
- package/prompts/cc-system.md +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.977 (2026-04-14)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
+
- free-form interval input + CC create-watch action
|
|
6
7
|
- flush queued CC messages as single combined request
|
|
7
8
|
- add quality standard reminder to all agent and CC prompts
|
|
8
9
|
- surface in-flight tool calls in lastAction (#1064)
|
|
10
|
+
- implement dashboard loop/watch management panel
|
|
11
|
+
- reassign tasks when preferred agent is busy too long
|
|
9
12
|
- wire agentBusyReassignMs into settings UI and persist
|
|
10
13
|
- ADO throttle detection, poll guards, and dashboard banner (#1051)
|
|
11
14
|
- gate auto-fix conflict dispatch behind autoFixConflicts flag
|
|
@@ -20,15 +23,17 @@
|
|
|
20
23
|
- doc-chat abort kills LLM process + queued messages auto-process
|
|
21
24
|
- add /api/work-items/reopen endpoint and reopen-work-item CC action (#982)
|
|
22
25
|
- CC tab unread dot + reopened badge on work items
|
|
23
|
-
- fix dep re-merge failure on retry with existing commits (#977)
|
|
24
|
-
- audit and harden log buffering implementation (#971)
|
|
25
|
-
- replace magic string 'active' with PR_STATUS.ACTIVE in lifecycle.js (#969)
|
|
26
26
|
|
|
27
27
|
### Fixes
|
|
28
|
+
- defer review setCooldown to post-gating in discoverWork
|
|
28
29
|
- move review verdict check before updateWorkItemStatus(DONE)
|
|
30
|
+
- address review feedback — move writeToInbox outside lock, add absolute condition auto-expire
|
|
31
|
+
- fix watches feature gaps — human notifications, branch stub, status-change init, unique keys
|
|
29
32
|
- skip isAlreadyDispatched in needsReReview to allow re-review within 1hr
|
|
33
|
+
- skip SessionStart hook settings test on CI
|
|
30
34
|
- loop queue flush so messages queued during combined send aren't orphaned
|
|
31
35
|
- set lastPushedAt on fix completion to unblock re-review without poller
|
|
36
|
+
- restore ADO throttle test functions deleted during merge conflict resolution
|
|
32
37
|
- trigger second-pass re-review after fix agent completes
|
|
33
38
|
- update ccInFlightAborts when retry LLM replaces original
|
|
34
39
|
- skip orphaned LLM retry when client disconnected during CC stream
|
|
@@ -40,14 +45,11 @@
|
|
|
40
45
|
- fix CC queued message 'already processing' and thinking UX stacking
|
|
41
46
|
- enforce --timeout 1 on all azureauth calls to prevent agent orphans (#1063)
|
|
42
47
|
- cancel steering kill watcher on resume spawn (#1052) (#1062)
|
|
43
|
-
- suppress warn for optional template variables (#1061)
|
|
44
|
-
- fix ENGINE_DEFAULTS ref and derive boolean settings from defaults
|
|
45
|
-
- add autoFixConflicts to boolean settings persist list
|
|
46
|
-
- tighten build query guard to require mergeCommitId
|
|
47
|
-
- restore sourceVersion filter after refs/pull merge ref switch
|
|
48
48
|
|
|
49
49
|
### Other
|
|
50
|
+
- refactor(watches): rename maxTriggers→stopAfter, add onNotMet per-poll action
|
|
50
51
|
- refactor(dashboard): extract _releaseCCTab helper and CC_LOCK_WAIT_MS constant
|
|
52
|
+
- merge: resolve conflicts with master on PR-1065
|
|
51
53
|
- test(engine): add regression tests for autoFixConflicts flag and DEFAULTS alias
|
|
52
54
|
- refactor(ado): simplify ado-status and extract build/review status helpers
|
|
53
55
|
- refactor(ado): extract stripRefsHeads helper, deduplicate refs/heads/ stripping
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -88,6 +88,7 @@ function _processStatusUpdate(data) {
|
|
|
88
88
|
if (_changed('skills', data.skills)) renderSkills(data.skills || []);
|
|
89
89
|
if (_changed('mcpServers', data.mcpServers)) renderMcpServers(data.mcpServers || []);
|
|
90
90
|
if (_changed('schedules', data.schedules)) renderSchedules(data.schedules || []);
|
|
91
|
+
if (_changed('watches', data.watches)) renderWatches(data.watches || []);
|
|
91
92
|
if (_changed('meetings', data.meetings)) renderMeetings(data.meetings || []);
|
|
92
93
|
if (_changed('pipelines', data.pipelines) && typeof renderPipelines === 'function') renderPipelines(data.pipelines || []);
|
|
93
94
|
if (_changed('pinned', data.pinned)) renderPinned(data.pinned || []);
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// render-watches.js — Watch rendering and CRUD functions for the dashboard
|
|
2
|
+
|
|
3
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const _WATCH_STATUS_BADGES = {
|
|
6
|
+
active: '<span class="pr-badge approved">active</span>',
|
|
7
|
+
paused: '<span class="pr-badge rejected">paused</span>',
|
|
8
|
+
triggered: '<span class="pr-badge" style="background:rgba(210,153,34,0.15);color:var(--yellow);border-color:var(--yellow)">triggered</span>',
|
|
9
|
+
expired: '<span class="pr-badge" style="background:rgba(139,148,158,0.15);color:var(--muted);border-color:var(--muted)">expired</span>',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const _WATCH_TARGET_LABELS = {
|
|
13
|
+
pr: 'PR',
|
|
14
|
+
'work-item': 'Work Item',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const _WATCH_CONDITION_LABELS = {
|
|
18
|
+
merged: 'Merged',
|
|
19
|
+
'build-fail': 'Build Fail',
|
|
20
|
+
'build-pass': 'Build Pass',
|
|
21
|
+
completed: 'Completed',
|
|
22
|
+
failed: 'Failed',
|
|
23
|
+
'status-change': 'Status Change',
|
|
24
|
+
any: 'Any Change',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function _intervalToHuman(ms) {
|
|
28
|
+
if (!ms) return 'default';
|
|
29
|
+
const sec = Math.floor(ms / 1000);
|
|
30
|
+
if (sec < 60) return sec + 's';
|
|
31
|
+
const min = Math.floor(sec / 60);
|
|
32
|
+
if (min < 60) return min + 'm';
|
|
33
|
+
return Math.floor(min / 60) + 'h ' + (min % 60) + 'm';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Parse human-friendly interval strings: "15m", "2h", "30s", "90000" (ms)
|
|
37
|
+
function _parseIntervalStr(s) {
|
|
38
|
+
if (!s) return 300000;
|
|
39
|
+
s = String(s).trim().toLowerCase();
|
|
40
|
+
if (/^\d+$/.test(s)) {
|
|
41
|
+
const n = parseInt(s, 10);
|
|
42
|
+
return n >= 1000 ? n : n * 1000; // bare numbers: ≥1000 treated as ms, else seconds
|
|
43
|
+
}
|
|
44
|
+
const match = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|hours?)$/);
|
|
45
|
+
if (!match) return 300000;
|
|
46
|
+
const n = parseFloat(match[1]);
|
|
47
|
+
const unit = match[2][0];
|
|
48
|
+
if (unit === 's') return Math.round(n * 1000);
|
|
49
|
+
if (unit === 'm') return Math.round(n * 60000);
|
|
50
|
+
if (unit === 'h') return Math.round(n * 3600000);
|
|
51
|
+
return 300000;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Rendering ──────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
let _watchPage = 0;
|
|
57
|
+
const WATCH_PER_PAGE = 15;
|
|
58
|
+
|
|
59
|
+
function renderWatches(watchesData) {
|
|
60
|
+
var watches = (watchesData || []).filter(function(w) { return !isDeleted('watch:' + w.id); });
|
|
61
|
+
var el = document.getElementById('watches-content');
|
|
62
|
+
var countEl = document.getElementById('watches-count');
|
|
63
|
+
if (!el) return;
|
|
64
|
+
if (countEl) countEl.textContent = watches.length;
|
|
65
|
+
window._lastWatches = watches;
|
|
66
|
+
|
|
67
|
+
if (!watches.length) {
|
|
68
|
+
el.innerHTML = '<p class="empty">No active watches. Create one to monitor PRs or work items.</p>';
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
var totalPages = Math.ceil(watches.length / WATCH_PER_PAGE);
|
|
73
|
+
if (_watchPage >= totalPages) _watchPage = totalPages - 1;
|
|
74
|
+
var start = _watchPage * WATCH_PER_PAGE;
|
|
75
|
+
var pageItems = watches.slice(start, start + WATCH_PER_PAGE);
|
|
76
|
+
|
|
77
|
+
var html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr>' +
|
|
78
|
+
'<th>ID</th><th>Target</th><th>Type</th><th>Condition</th><th>Interval</th><th>Owner</th><th>Status</th><th>Triggers</th><th>Last Checked</th><th></th>' +
|
|
79
|
+
'</tr></thead><tbody>';
|
|
80
|
+
|
|
81
|
+
for (var i = 0; i < pageItems.length; i++) {
|
|
82
|
+
var w = pageItems[i];
|
|
83
|
+
var statusBadge = _WATCH_STATUS_BADGES[w.status] || escHtml(w.status || 'unknown');
|
|
84
|
+
var targetLabel = _WATCH_TARGET_LABELS[w.targetType] || escHtml(w.targetType || '');
|
|
85
|
+
var condLabel = _WATCH_CONDITION_LABELS[w.condition] || escHtml(w.condition || '');
|
|
86
|
+
var lastChecked = w.last_checked ? timeAgo(w.last_checked) : 'never';
|
|
87
|
+
var lastTriggered = w.last_triggered ? timeAgo(w.last_triggered) : 'never';
|
|
88
|
+
var triggerInfo = (w.triggerCount || 0) + (w.stopAfter > 0 ? '/' + w.stopAfter : '');
|
|
89
|
+
|
|
90
|
+
html += '<tr style="cursor:pointer" onclick="openWatchDetail(\'' + escHtml(w.id) + '\')">' +
|
|
91
|
+
'<td><span class="pr-id">' + escHtml(w.id) + '</span></td>' +
|
|
92
|
+
'<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(w.description || w.target) + '">' + escHtml(w.target) + '</td>' +
|
|
93
|
+
'<td><span class="dispatch-type explore">' + escHtml(targetLabel) + '</span></td>' +
|
|
94
|
+
'<td><span style="font-size:11px;color:var(--blue)">' + escHtml(condLabel) + '</span></td>' +
|
|
95
|
+
'<td><span style="font-size:10px;color:var(--muted)">' + escHtml(_intervalToHuman(w.interval)) + '</span></td>' +
|
|
96
|
+
'<td><span class="pr-agent">' + escHtml(w.owner || 'human') + '</span></td>' +
|
|
97
|
+
'<td>' + statusBadge + '</td>' +
|
|
98
|
+
'<td title="Last triggered: ' + escHtml(lastTriggered) + '"><span style="font-size:11px">' + escHtml(triggerInfo) + '</span></td>' +
|
|
99
|
+
'<td><span class="pr-date">' + escHtml(lastChecked) + '</span></td>' +
|
|
100
|
+
'<td style="white-space:nowrap">';
|
|
101
|
+
|
|
102
|
+
// Pause/Resume button
|
|
103
|
+
if (w.status === 'active') {
|
|
104
|
+
html += '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',true)" title="Pause">⏸</button>';
|
|
105
|
+
} else if (w.status === 'paused') {
|
|
106
|
+
html += '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',false)" title="Resume">▶</button>';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Delete button
|
|
110
|
+
html += '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteWatch(\'' + escHtml(w.id) + '\')" title="Delete">✕</button>';
|
|
111
|
+
html += '</td></tr>';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
html += '</tbody></table></div>';
|
|
115
|
+
|
|
116
|
+
if (watches.length > WATCH_PER_PAGE) {
|
|
117
|
+
html += '<div class="pr-pager">' +
|
|
118
|
+
'<span class="pr-page-info">Showing ' + (start + 1) + ' to ' + Math.min(start + WATCH_PER_PAGE, watches.length) + ' of ' + watches.length + '</span>' +
|
|
119
|
+
'<div class="pr-pager-btns">' +
|
|
120
|
+
'<button class="pr-pager-btn ' + (_watchPage === 0 ? 'disabled' : '') + '" onclick="_watchPrev()">Prev</button>' +
|
|
121
|
+
'<button class="pr-pager-btn ' + (_watchPage >= totalPages - 1 ? 'disabled' : '') + '" onclick="_watchNext()">Next</button>' +
|
|
122
|
+
'</div>' +
|
|
123
|
+
'</div>';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
el.innerHTML = html;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _watchPrev() { if (_watchPage > 0) { _watchPage--; renderWatches(window._lastWatches || []); } }
|
|
130
|
+
function _watchNext() { var tp = Math.ceil((window._lastWatches || []).length / WATCH_PER_PAGE); if (_watchPage < tp - 1) { _watchPage++; renderWatches(window._lastWatches || []); } }
|
|
131
|
+
|
|
132
|
+
// ─── Detail Modal ───────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function openWatchDetail(id) {
|
|
135
|
+
var w = (window._lastWatches || []).find(function(x) { return x.id === id; });
|
|
136
|
+
if (!w) return;
|
|
137
|
+
var statusBadge = _WATCH_STATUS_BADGES[w.status] || escHtml(w.status || '');
|
|
138
|
+
var lastChecked = w.last_checked ? new Date(w.last_checked).toLocaleString() : 'never';
|
|
139
|
+
var lastTriggered = w.last_triggered ? new Date(w.last_triggered).toLocaleString() : 'never';
|
|
140
|
+
var createdAt = w.created_at ? new Date(w.created_at).toLocaleString() : 'unknown';
|
|
141
|
+
var targetLabel = _WATCH_TARGET_LABELS[w.targetType] || w.targetType;
|
|
142
|
+
var condLabel = _WATCH_CONDITION_LABELS[w.condition] || w.condition;
|
|
143
|
+
|
|
144
|
+
document.getElementById('modal-title').innerHTML = escHtml(w.description || w.target) +
|
|
145
|
+
' <div style="display:flex;gap:4px;margin-top:4px">' +
|
|
146
|
+
(w.status === 'active' ? '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--yellow)" onclick="toggleWatchPause(\'' + escHtml(w.id) + '\',true);closeModal()">Pause</button>' : '') +
|
|
147
|
+
(w.status === 'paused' ? '<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--green)" onclick="toggleWatchPause(\'' + escHtml(w.id) + '\',false);closeModal()">Resume</button>' : '') +
|
|
148
|
+
'<button class="pr-pager-btn" style="font-size:10px;padding:2px 10px;color:var(--red)" onclick="deleteWatch(\'' + escHtml(w.id) + '\');closeModal()">Delete</button>' +
|
|
149
|
+
'</div>';
|
|
150
|
+
|
|
151
|
+
var body = '<div style="display:flex;flex-direction:column;gap:10px;font-size:12px;line-height:1.6">' +
|
|
152
|
+
'<div><strong style="color:var(--muted)">ID:</strong> ' + escHtml(w.id) + '</div>' +
|
|
153
|
+
'<div><strong style="color:var(--muted)">Target:</strong> ' + escHtml(w.target) + '</div>' +
|
|
154
|
+
'<div><strong style="color:var(--muted)">Target Type:</strong> <span class="dispatch-type explore">' + escHtml(targetLabel) + '</span></div>' +
|
|
155
|
+
'<div><strong style="color:var(--muted)">Condition:</strong> <span style="color:var(--blue)">' + escHtml(condLabel) + '</span></div>' +
|
|
156
|
+
'<div><strong style="color:var(--muted)">Check Interval:</strong> ' + escHtml(_intervalToHuman(w.interval)) + '</div>' +
|
|
157
|
+
'<div><strong style="color:var(--muted)">Owner:</strong> ' + escHtml(w.owner || 'human') + '</div>' +
|
|
158
|
+
'<div><strong style="color:var(--muted)">Status:</strong> ' + statusBadge + '</div>' +
|
|
159
|
+
'<div><strong style="color:var(--muted)">Notify:</strong> ' + escHtml(w.notify || 'inbox') + '</div>' +
|
|
160
|
+
'<div><strong style="color:var(--muted)">Triggers:</strong> ' + (w.triggerCount || 0) + (w.stopAfter > 0 ? ' / ' + w.stopAfter + ' (expires after)' : ' (runs forever)') + '</div>' +
|
|
161
|
+
(w.onNotMet ? '<div><strong style="color:var(--muted)">On Each Poll (not met):</strong> ' + escHtml(w.onNotMet) + '</div>' : '') +
|
|
162
|
+
'<div><strong style="color:var(--muted)">Created:</strong> ' + escHtml(createdAt) + '</div>' +
|
|
163
|
+
'<div><strong style="color:var(--muted)">Last Checked:</strong> ' + escHtml(lastChecked) + '</div>' +
|
|
164
|
+
'<div><strong style="color:var(--muted)">Last Triggered:</strong> ' + escHtml(lastTriggered) + '</div>' +
|
|
165
|
+
(w._lastTriggerMessage ? '<div><strong style="color:var(--muted)">Last Trigger Message:</strong><div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px;font-size:11px">' + escHtml(w._lastTriggerMessage) + '</div></div>' : '') +
|
|
166
|
+
(w.project ? '<div><strong style="color:var(--muted)">Project:</strong> ' + escHtml(w.project) + '</div>' : '') +
|
|
167
|
+
(w.description ? '<div><strong style="color:var(--muted)">Description:</strong> ' + escHtml(w.description) + '</div>' : '') +
|
|
168
|
+
'</div>';
|
|
169
|
+
|
|
170
|
+
document.getElementById('modal-body').innerHTML = body;
|
|
171
|
+
document.getElementById('modal-body').style.whiteSpace = 'normal';
|
|
172
|
+
document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
|
|
173
|
+
document.getElementById('modal').classList.add('open');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── CRUD Actions ───────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
function toggleWatchPause(id, pause) {
|
|
179
|
+
var newStatus = pause ? 'paused' : 'active';
|
|
180
|
+
showToast('cmd-toast', (pause ? 'Pausing' : 'Resuming') + ' watch...', true);
|
|
181
|
+
fetch('/api/watches/update', {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: { 'Content-Type': 'application/json' },
|
|
184
|
+
body: JSON.stringify({ id: id, status: newStatus })
|
|
185
|
+
}).then(function(res) { return res.json(); }).then(function(data) {
|
|
186
|
+
if (data.error) showToast('cmd-toast', 'Error: ' + data.error, false);
|
|
187
|
+
}).catch(function(err) {
|
|
188
|
+
showToast('cmd-toast', 'Error: ' + err.message, false);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function deleteWatch(id) {
|
|
193
|
+
if (!confirm('Delete this watch?')) return;
|
|
194
|
+
markDeleted('watch:' + id);
|
|
195
|
+
showToast('cmd-toast', 'Deleting watch...', true);
|
|
196
|
+
fetch('/api/watches/delete', {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'Content-Type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({ id: id })
|
|
200
|
+
}).then(function(res) { return res.json(); }).then(function(data) {
|
|
201
|
+
if (data.error) showToast('cmd-toast', 'Error: ' + data.error, false);
|
|
202
|
+
else renderWatches(window._lastWatches || []);
|
|
203
|
+
}).catch(function(err) {
|
|
204
|
+
showToast('cmd-toast', 'Error: ' + err.message, false);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Create Watch Modal ─────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
function _watchFormHtml() {
|
|
211
|
+
var inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
|
|
212
|
+
|
|
213
|
+
var targetTypes = [
|
|
214
|
+
{ value: 'pr', label: 'Pull Request' },
|
|
215
|
+
{ value: 'work-item', label: 'Work Item' },
|
|
216
|
+
];
|
|
217
|
+
var conditions = [
|
|
218
|
+
{ value: 'merged', label: 'Merged' },
|
|
219
|
+
{ value: 'build-fail', label: 'Build Fail' },
|
|
220
|
+
{ value: 'build-pass', label: 'Build Pass' },
|
|
221
|
+
{ value: 'completed', label: 'Completed' },
|
|
222
|
+
{ value: 'failed', label: 'Failed' },
|
|
223
|
+
{ value: 'status-change', label: 'Status Change' },
|
|
224
|
+
{ value: 'any', label: 'Any Change' },
|
|
225
|
+
];
|
|
226
|
+
var ttOpts = targetTypes.map(function(t) { return '<option value="' + t.value + '">' + t.label + '</option>'; }).join('');
|
|
227
|
+
var condOpts = conditions.map(function(c) { return '<option value="' + c.value + '">' + c.label + '</option>'; }).join('');
|
|
228
|
+
var agentOpts = '<option value="">human</option>' + (cmdAgents || []).map(function(a) { return '<option value="' + escHtml(a.id) + '">' + escHtml(a.name) + '</option>'; }).join('');
|
|
229
|
+
var projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(function(p) { return '<option value="' + escHtml(p.name) + '">' + escHtml(p.name) + '</option>'; }).join('');
|
|
230
|
+
|
|
231
|
+
return '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
|
|
232
|
+
'<div id="watch-form-error" style="display:none;color:var(--red);font-size:12px;padding:6px 10px;background:rgba(255,50,50,0.1);border-radius:var(--radius-sm)"></div>' +
|
|
233
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Target (PR number or Work Item ID)<input id="watch-edit-target" placeholder="e.g. 1057, W-abc123" style="' + inputStyle + '"></label>' +
|
|
234
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Target Type<select id="watch-edit-target-type" style="' + inputStyle + '">' + ttOpts + '</select></label>' +
|
|
235
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Condition<select id="watch-edit-condition" style="' + inputStyle + '">' + condOpts + '</select></label>' +
|
|
236
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Check Interval <span style="font-size:10px;color:var(--muted)">(e.g. 5m, 15m, 1h — default 5m)</span><input id="watch-edit-interval" placeholder="5m" style="' + inputStyle + '"></label>' +
|
|
237
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Owner (who gets notified)<select id="watch-edit-owner" style="' + inputStyle + '">' + agentOpts + '</select></label>' +
|
|
238
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Project<select id="watch-edit-project" style="' + inputStyle + '">' + projOpts + '</select></label>' +
|
|
239
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Description<input id="watch-edit-desc" placeholder="Optional description" style="' + inputStyle + '"></label>' +
|
|
240
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Stop After N Triggers <span style="font-size:10px;color:var(--muted)">(0 = run forever, 1 = expire on first match)</span><input id="watch-edit-stop-after" type="number" value="0" min="0" style="' + inputStyle + '"></label>' +
|
|
241
|
+
'<label style="color:var(--text);font-size:var(--text-md)">On Each Poll (if condition not met)<select id="watch-edit-on-not-met" style="' + inputStyle + '"><option value="">None — do nothing</option><option value="notify">Notify — write to inbox each poll</option></select></label>' +
|
|
242
|
+
'</div>';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function openCreateWatchModal() {
|
|
246
|
+
document.getElementById('modal-title').innerHTML = 'Create Watch' +
|
|
247
|
+
' <button class="pr-pager-btn" style="font-size:10px;padding:2px 12px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="submitWatch()">Create</button>';
|
|
248
|
+
document.getElementById('modal-body').innerHTML = _watchFormHtml();
|
|
249
|
+
document.getElementById('modal-body').style.whiteSpace = 'normal';
|
|
250
|
+
document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
|
|
251
|
+
document.getElementById('modal').classList.add('open');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function submitWatch() {
|
|
255
|
+
var target = (document.getElementById('watch-edit-target') || {}).value || '';
|
|
256
|
+
var targetType = (document.getElementById('watch-edit-target-type') || {}).value || 'pr';
|
|
257
|
+
var condition = (document.getElementById('watch-edit-condition') || {}).value || 'merged';
|
|
258
|
+
var interval = _parseIntervalStr((document.getElementById('watch-edit-interval') || {}).value);
|
|
259
|
+
var owner = (document.getElementById('watch-edit-owner') || {}).value || '';
|
|
260
|
+
var project = (document.getElementById('watch-edit-project') || {}).value || '';
|
|
261
|
+
var description = (document.getElementById('watch-edit-desc') || {}).value || '';
|
|
262
|
+
var stopAfter = parseInt((document.getElementById('watch-edit-stop-after') || {}).value, 10) || 0;
|
|
263
|
+
var onNotMet = (document.getElementById('watch-edit-on-not-met') || {}).value || '';
|
|
264
|
+
|
|
265
|
+
if (!target.trim()) {
|
|
266
|
+
var errEl = document.getElementById('watch-form-error');
|
|
267
|
+
if (errEl) { errEl.textContent = 'Target is required'; errEl.style.display = 'block'; }
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
showToast('cmd-toast', 'Creating watch...', true);
|
|
272
|
+
fetch('/api/watches', {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: { 'Content-Type': 'application/json' },
|
|
275
|
+
body: JSON.stringify({
|
|
276
|
+
target: target.trim(),
|
|
277
|
+
targetType: targetType,
|
|
278
|
+
condition: condition,
|
|
279
|
+
interval: interval,
|
|
280
|
+
owner: owner || 'human',
|
|
281
|
+
project: project || null,
|
|
282
|
+
description: description || null,
|
|
283
|
+
notify: 'inbox',
|
|
284
|
+
stopAfter: stopAfter,
|
|
285
|
+
onNotMet: onNotMet || null,
|
|
286
|
+
})
|
|
287
|
+
}).then(function(res) { return res.json(); }).then(function(data) {
|
|
288
|
+
if (data.error) {
|
|
289
|
+
showToast('cmd-toast', 'Error: ' + data.error, false);
|
|
290
|
+
} else {
|
|
291
|
+
showToast('cmd-toast', 'Watch created: ' + data.watch.id, true);
|
|
292
|
+
closeModal();
|
|
293
|
+
}
|
|
294
|
+
}).catch(function(err) {
|
|
295
|
+
showToast('cmd-toast', 'Error: ' + err.message, false);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── Exports ────────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
window.MinionsWatches = {
|
|
302
|
+
renderWatches: renderWatches,
|
|
303
|
+
openCreateWatchModal: openCreateWatchModal,
|
|
304
|
+
openWatchDetail: openWatchDetail,
|
|
305
|
+
submitWatch: submitWatch,
|
|
306
|
+
toggleWatchPause: toggleWatchPause,
|
|
307
|
+
deleteWatch: deleteWatch,
|
|
308
|
+
_watchPrev: _watchPrev,
|
|
309
|
+
_watchNext: _watchNext,
|
|
310
|
+
};
|
package/dashboard/layout.html
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
<a class="sidebar-link" data-page="inbox" href="/inbox">Notes & KB</a>
|
|
73
73
|
<a class="sidebar-link" data-page="tools" href="/tools">Skills & MCP</a>
|
|
74
74
|
<a class="sidebar-link" data-page="schedule" href="/schedule" title="Cron-based recurring tasks (single task per schedule)">Schedules</a>
|
|
75
|
+
<a class="sidebar-link" data-page="watches" href="/watches" title="Persistent watches that monitor PRs, work items, and branches">Watches</a>
|
|
75
76
|
<a class="sidebar-link" data-page="pipelines" href="/pipelines" title="Multi-stage workflows — chain tasks, meetings, plans with dependencies">Pipelines</a>
|
|
76
77
|
<a class="sidebar-link" data-page="meetings" href="/meetings">Meetings</a>
|
|
77
78
|
<a class="sidebar-link" data-page="engine" href="/engine">Engine</a>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<section id="watches-section">
|
|
2
|
+
<h2>Watches <span class="count" id="watches-count">0</span>
|
|
3
|
+
<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreateWatchModal()">+ New</button>
|
|
4
|
+
<span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">persistent watches that monitor PRs, work items, and branches for changes</span>
|
|
5
|
+
</h2>
|
|
6
|
+
<div id="watches-content"><p class="empty">No active watches. Create one to monitor PRs, work items, or branches.</p></div>
|
|
7
|
+
</section>
|
package/dashboard-build.js
CHANGED
|
@@ -20,7 +20,7 @@ function buildDashboardHtml() {
|
|
|
20
20
|
const layout = safeRead(layoutPath);
|
|
21
21
|
const css = safeRead(path.join(dashDir, 'styles.css'));
|
|
22
22
|
|
|
23
|
-
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'pipelines', 'meetings', 'engine'];
|
|
23
|
+
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'engine'];
|
|
24
24
|
let pageHtml = '';
|
|
25
25
|
for (const p of pages) {
|
|
26
26
|
const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
|
|
@@ -32,7 +32,7 @@ function buildDashboardHtml() {
|
|
|
32
32
|
'utils', 'state', '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-pipelines', 'render-meetings', 'render-pinned',
|
|
35
|
+
'render-other', '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
|
@@ -23,6 +23,7 @@ const queries = require('./engine/queries');
|
|
|
23
23
|
const teams = require('./engine/teams');
|
|
24
24
|
const ado = require('./engine/ado');
|
|
25
25
|
const gh = require('./engine/github');
|
|
26
|
+
const watchesMod = require('./engine/watches');
|
|
26
27
|
const os = require('os');
|
|
27
28
|
|
|
28
29
|
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, reopenWorkItem } = shared;
|
|
@@ -77,7 +78,7 @@ function buildDashboardHtml() {
|
|
|
77
78
|
const css = safeRead(path.join(dashDir, 'styles.css'));
|
|
78
79
|
|
|
79
80
|
// Assemble page fragments
|
|
80
|
-
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'pipelines', 'meetings', 'engine'];
|
|
81
|
+
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'engine'];
|
|
81
82
|
let pageHtml = '';
|
|
82
83
|
for (const p of pages) {
|
|
83
84
|
const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
|
|
@@ -90,7 +91,7 @@ function buildDashboardHtml() {
|
|
|
90
91
|
'utils', 'state', 'render-utils', 'detail-panel', 'live-stream',
|
|
91
92
|
'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
|
|
92
93
|
'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
|
|
93
|
-
'render-other', 'render-schedules', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
94
|
+
'render-other', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
94
95
|
'command-parser', 'command-input', 'command-center', 'command-history',
|
|
95
96
|
'modal', 'modal-qa', 'settings', 'refresh'
|
|
96
97
|
];
|
|
@@ -396,6 +397,7 @@ function getStatus() {
|
|
|
396
397
|
const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
|
|
397
398
|
return scheds.map(s => ({ ...s, _lastRun: runs[s.id] || null }));
|
|
398
399
|
})(),
|
|
400
|
+
watches: watchesMod.getWatches(),
|
|
399
401
|
meetings: (() => { try { return require('./engine/meeting').getMeetings(); } catch { return []; } })(),
|
|
400
402
|
pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
|
|
401
403
|
pinned: (() => { try { return parsePinnedEntries(safeRead(path.join(MINIONS_DIR, 'pinned.md'))); } catch { return []; } })(),
|
|
@@ -596,6 +598,18 @@ function parseCCActions(text) {
|
|
|
596
598
|
// Actions are executed server-side so all clients (frontend, curl, Teams) get the same behavior.
|
|
597
599
|
// The frontend still shows status toasts but no longer needs to fire the API calls.
|
|
598
600
|
|
|
601
|
+
// Parse interval from CC action — accepts ms number, "15m", "1h", "30s", or null (default 5m).
|
|
602
|
+
function _parseWatchInterval(val) {
|
|
603
|
+
if (!val) return 300000;
|
|
604
|
+
if (typeof val === 'number') return Math.max(60000, val);
|
|
605
|
+
const s = String(val).trim().toLowerCase();
|
|
606
|
+
if (/^\d+$/.test(s)) { const n = parseInt(s, 10); return Math.max(60000, n >= 1000 ? n : n * 1000); }
|
|
607
|
+
const m = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|hours?)$/);
|
|
608
|
+
if (!m) return 300000;
|
|
609
|
+
const n = parseFloat(m[1]), u = m[2][0];
|
|
610
|
+
return Math.max(60000, Math.round(u === 's' ? n * 1000 : u === 'm' ? n * 60000 : n * 3600000));
|
|
611
|
+
}
|
|
612
|
+
|
|
599
613
|
async function executeCCActions(actions) {
|
|
600
614
|
const results = [];
|
|
601
615
|
for (const action of actions) {
|
|
@@ -672,6 +686,23 @@ async function executeCCActions(actions) {
|
|
|
672
686
|
results.push({ type: 'reopen-work-item', id: action.id, ...(reopenResult || { error: 'unexpected' }) });
|
|
673
687
|
break;
|
|
674
688
|
}
|
|
689
|
+
case 'create-watch': {
|
|
690
|
+
const intervalMs = _parseWatchInterval(action.interval);
|
|
691
|
+
const watch = watchesMod.createWatch({
|
|
692
|
+
target: action.target,
|
|
693
|
+
targetType: action.targetType || 'pr',
|
|
694
|
+
condition: action.condition || 'build-pass',
|
|
695
|
+
interval: intervalMs,
|
|
696
|
+
owner: action.owner || 'human',
|
|
697
|
+
description: action.description || null,
|
|
698
|
+
project: action.project || null,
|
|
699
|
+
notify: 'inbox',
|
|
700
|
+
stopAfter: Number(action.stopAfter) || 0,
|
|
701
|
+
onNotMet: action.onNotMet || null,
|
|
702
|
+
});
|
|
703
|
+
results.push({ type: 'create-watch', id: watch.id, ok: true });
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
675
706
|
default:
|
|
676
707
|
// Server didn't handle — frontend must execute
|
|
677
708
|
results.push({ type: action.type });
|
|
@@ -3745,6 +3776,49 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3745
3776
|
}
|
|
3746
3777
|
}
|
|
3747
3778
|
|
|
3779
|
+
// ── Watches API Handlers ─────────────────────────────────────────────────
|
|
3780
|
+
|
|
3781
|
+
async function handleWatchesList(req, res) {
|
|
3782
|
+
return jsonReply(res, 200, { watches: watchesMod.getWatches() });
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
async function handleWatchesCreate(req, res) {
|
|
3786
|
+
const body = await readBody(req);
|
|
3787
|
+
const { target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet } = body;
|
|
3788
|
+
if (!target) return jsonReply(res, 400, { error: 'target is required' });
|
|
3789
|
+
try {
|
|
3790
|
+
const watch = watchesMod.createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet });
|
|
3791
|
+
invalidateStatusCache();
|
|
3792
|
+
return jsonReply(res, 200, { ok: true, watch });
|
|
3793
|
+
} catch (e) {
|
|
3794
|
+
return jsonReply(res, 400, { error: e.message });
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
async function handleWatchesUpdate(req, res) {
|
|
3799
|
+
const body = await readBody(req);
|
|
3800
|
+
const { id, ...updates } = body;
|
|
3801
|
+
if (!id) return jsonReply(res, 400, { error: 'id is required' });
|
|
3802
|
+
try {
|
|
3803
|
+
const watch = watchesMod.updateWatch(id, updates);
|
|
3804
|
+
if (!watch) return jsonReply(res, 404, { error: 'Watch not found' });
|
|
3805
|
+
invalidateStatusCache();
|
|
3806
|
+
return jsonReply(res, 200, { ok: true, watch });
|
|
3807
|
+
} catch (e) {
|
|
3808
|
+
return jsonReply(res, 400, { error: e.message });
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
async function handleWatchesDelete(req, res) {
|
|
3813
|
+
const body = await readBody(req);
|
|
3814
|
+
const { id } = body;
|
|
3815
|
+
if (!id) return jsonReply(res, 400, { error: 'id is required' });
|
|
3816
|
+
const deleted = watchesMod.deleteWatch(id);
|
|
3817
|
+
if (!deleted) return jsonReply(res, 404, { error: 'Watch not found' });
|
|
3818
|
+
invalidateStatusCache();
|
|
3819
|
+
return jsonReply(res, 200, { ok: true });
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3748
3822
|
async function handleEngineRestart(req, res) {
|
|
3749
3823
|
try {
|
|
3750
3824
|
const newPid = restartEngine();
|
|
@@ -3789,7 +3863,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3789
3863
|
versionCheckInterval: [60000],
|
|
3790
3864
|
maxBuildFixAttempts: [1, 10],
|
|
3791
3865
|
adoPollStatusEvery: [1], adoPollCommentsEvery: [1],
|
|
3792
|
-
agentBusyReassignMs: [
|
|
3866
|
+
agentBusyReassignMs: [0],
|
|
3793
3867
|
};
|
|
3794
3868
|
for (const [key, [min, max]] of Object.entries(numericFields)) {
|
|
3795
3869
|
if (e[key] !== undefined) {
|
|
@@ -4442,6 +4516,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
4442
4516
|
{ method: 'POST', path: '/api/schedules/update', desc: 'Update an existing schedule', params: 'id, cron?, title?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesUpdate },
|
|
4443
4517
|
{ method: 'POST', path: '/api/schedules/delete', desc: 'Delete a schedule', params: 'id', handler: handleSchedulesDelete },
|
|
4444
4518
|
|
|
4519
|
+
// Watches
|
|
4520
|
+
{ method: 'GET', path: '/api/watches', desc: 'List all watches', handler: handleWatchesList },
|
|
4521
|
+
{ method: 'POST', path: '/api/watches', desc: 'Create a new watch', params: 'target, targetType, condition, interval?, owner?, description?, project?, notify?, stopAfter?, onNotMet?', handler: handleWatchesCreate },
|
|
4522
|
+
{ method: 'POST', path: '/api/watches/update', desc: 'Update a watch (pause/resume/modify)', params: 'id, status?, interval?, description?, notify?, stopAfter?, onNotMet?, condition?', handler: handleWatchesUpdate },
|
|
4523
|
+
{ method: 'POST', path: '/api/watches/delete', desc: 'Delete a watch', params: 'id', handler: handleWatchesDelete },
|
|
4524
|
+
|
|
4445
4525
|
// Pipelines
|
|
4446
4526
|
{ method: 'GET', path: '/api/pipelines', desc: 'List all pipelines with runs', handler: async (req, res) => {
|
|
4447
4527
|
const { getPipelines, getPipelineRuns } = require('./engine/pipeline');
|
package/engine/shared.js
CHANGED
|
@@ -613,6 +613,13 @@ const PR_STATUS = { ACTIVE: 'active', MERGED: 'merged', ABANDONED: 'abandoned',
|
|
|
613
613
|
// PRs eligible for polling (status/build/comment checks) — excludes terminal statuses
|
|
614
614
|
const PR_POLLABLE_STATUSES = new Set([PR_STATUS.ACTIVE, PR_STATUS.LINKED]);
|
|
615
615
|
|
|
616
|
+
// Watch statuses — engine-level persistent watches that survive restarts
|
|
617
|
+
const WATCH_STATUS = { ACTIVE: 'active', PAUSED: 'paused', TRIGGERED: 'triggered', EXPIRED: 'expired' };
|
|
618
|
+
const WATCH_TARGET_TYPE = { PR: 'pr', WORK_ITEM: 'work-item' };
|
|
619
|
+
const WATCH_CONDITION = { MERGED: 'merged', BUILD_FAIL: 'build-fail', BUILD_PASS: 'build-pass', COMPLETED: 'completed', FAILED: 'failed', STATUS_CHANGE: 'status-change', ANY: 'any' };
|
|
620
|
+
// Absolute conditions check point-in-time state, not transitions — auto-expire after first trigger when stopAfter is 0
|
|
621
|
+
const WATCH_ABSOLUTE_CONDITIONS = new Set([WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS, WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED]);
|
|
622
|
+
|
|
616
623
|
/** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
|
|
617
624
|
function trackReviewMetric(pr, newReviewStatus, config) {
|
|
618
625
|
if (newReviewStatus !== 'approved' && newReviewStatus !== 'changes-requested') return;
|
|
@@ -1138,6 +1145,7 @@ module.exports = {
|
|
|
1138
1145
|
classifyInboxItem,
|
|
1139
1146
|
ENGINE_DEFAULTS,
|
|
1140
1147
|
WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd,
|
|
1148
|
+
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS,
|
|
1141
1149
|
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
1142
1150
|
FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
|
|
1143
1151
|
DEFAULT_AGENT_METRICS,
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/watches.js -- Persistent watch jobs that survive engine restarts.
|
|
3
|
+
* Zero dependencies -- uses only Node.js built-ins + engine/shared.js.
|
|
4
|
+
*
|
|
5
|
+
* Watches monitor targets (PRs, work items, branches) for conditions (merged,
|
|
6
|
+
* build-fail, completed, etc.) and fire inbox notifications when triggered.
|
|
7
|
+
*
|
|
8
|
+
* State stored in engine/watches.json — concurrency-safe via mutateJsonFileLocked.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const shared = require('./shared');
|
|
13
|
+
const { safeJson, mutateJsonFileLocked, ts, uid, log, writeToInbox,
|
|
14
|
+
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
|
|
15
|
+
|
|
16
|
+
// Dynamic path — respects MINIONS_TEST_DIR for test isolation
|
|
17
|
+
function _watchesPath() { return path.join(shared.MINIONS_DIR, 'engine', 'watches.json'); }
|
|
18
|
+
|
|
19
|
+
// Default check interval: 5 minutes (300000ms). Engine tick runs every 60s,
|
|
20
|
+
// so watches are checked every N ticks where N = ceil(interval / tickInterval).
|
|
21
|
+
const DEFAULT_WATCH_INTERVAL = 300000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read all watches from disk.
|
|
25
|
+
* @returns {Array<object>}
|
|
26
|
+
*/
|
|
27
|
+
function getWatches() {
|
|
28
|
+
return safeJson(_watchesPath()) || [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a new watch. Validates required fields and writes atomically.
|
|
33
|
+
* @param {object} opts - Watch definition
|
|
34
|
+
* @returns {object} - Created watch
|
|
35
|
+
*/
|
|
36
|
+
function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet }) {
|
|
37
|
+
if (!target) throw new Error('target is required');
|
|
38
|
+
if (!targetType || !Object.values(WATCH_TARGET_TYPE).includes(targetType)) {
|
|
39
|
+
throw new Error(`targetType must be one of: ${Object.values(WATCH_TARGET_TYPE).join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
if (!condition || !Object.values(WATCH_CONDITION).includes(condition)) {
|
|
42
|
+
throw new Error(`condition must be one of: ${Object.values(WATCH_CONDITION).join(', ')}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const watch = {
|
|
46
|
+
id: 'watch-' + uid(),
|
|
47
|
+
target,
|
|
48
|
+
targetType,
|
|
49
|
+
condition,
|
|
50
|
+
interval: Math.max(60000, Number(interval) || DEFAULT_WATCH_INTERVAL),
|
|
51
|
+
owner: owner || 'human',
|
|
52
|
+
status: WATCH_STATUS.ACTIVE,
|
|
53
|
+
description: description || `Watch ${target} for ${condition}`,
|
|
54
|
+
project: project || null,
|
|
55
|
+
notify: notify || 'inbox',
|
|
56
|
+
stopAfter: Number(stopAfter) || 0, // 0 = run forever; N = expire after N triggers
|
|
57
|
+
onNotMet: onNotMet || null, // null | 'notify' — action per poll when condition not met
|
|
58
|
+
triggerCount: 0,
|
|
59
|
+
created_at: ts(),
|
|
60
|
+
last_checked: null,
|
|
61
|
+
last_triggered: null,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
mutateJsonFileLocked(_watchesPath(), (watches) => {
|
|
65
|
+
if (!Array.isArray(watches)) watches = [];
|
|
66
|
+
watches.push(watch);
|
|
67
|
+
return watches;
|
|
68
|
+
}, { defaultValue: [] });
|
|
69
|
+
|
|
70
|
+
log('info', `Watch created: ${watch.id} → ${watch.target} (${watch.condition})`);
|
|
71
|
+
return watch;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Update a watch by ID. Only updates provided fields.
|
|
76
|
+
* @param {string} id - Watch ID
|
|
77
|
+
* @param {object} updates - Fields to update
|
|
78
|
+
* @returns {object|null} - Updated watch or null if not found
|
|
79
|
+
*/
|
|
80
|
+
function updateWatch(id, updates) {
|
|
81
|
+
if (!id) throw new Error('id is required');
|
|
82
|
+
// Validate status before entering the lock — reject early, never persist invalid values
|
|
83
|
+
if (updates.status !== undefined && !Object.values(WATCH_STATUS).includes(updates.status)) {
|
|
84
|
+
log('warn', `Invalid watch status: ${updates.status}`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
let found = null;
|
|
88
|
+
mutateJsonFileLocked(_watchesPath(), (watches) => {
|
|
89
|
+
if (!Array.isArray(watches)) return watches;
|
|
90
|
+
const watch = watches.find(w => w.id === id);
|
|
91
|
+
if (!watch) return watches;
|
|
92
|
+
// Only allow safe field updates
|
|
93
|
+
const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition'];
|
|
94
|
+
for (const key of allowed) {
|
|
95
|
+
if (updates[key] !== undefined) watch[key] = updates[key];
|
|
96
|
+
}
|
|
97
|
+
found = { ...watch };
|
|
98
|
+
return watches;
|
|
99
|
+
}, { defaultValue: [] });
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Delete a watch by ID.
|
|
105
|
+
* @param {string} id
|
|
106
|
+
* @returns {boolean} - true if deleted
|
|
107
|
+
*/
|
|
108
|
+
function deleteWatch(id) {
|
|
109
|
+
if (!id) return false;
|
|
110
|
+
let deleted = false;
|
|
111
|
+
mutateJsonFileLocked(_watchesPath(), (watches) => {
|
|
112
|
+
if (!Array.isArray(watches)) return watches;
|
|
113
|
+
const idx = watches.findIndex(w => w.id === id);
|
|
114
|
+
if (idx >= 0) {
|
|
115
|
+
watches.splice(idx, 1);
|
|
116
|
+
deleted = true;
|
|
117
|
+
}
|
|
118
|
+
return watches;
|
|
119
|
+
}, { defaultValue: [] });
|
|
120
|
+
if (deleted) log('info', `Watch deleted: ${id}`);
|
|
121
|
+
return deleted;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Evaluate whether a watch condition is met given current state.
|
|
126
|
+
* @param {object} watch - The watch object
|
|
127
|
+
* @param {object} state - { pullRequests, workItems } current state
|
|
128
|
+
* @returns {{ triggered: boolean, message: string }}
|
|
129
|
+
*/
|
|
130
|
+
function evaluateWatch(watch, state) {
|
|
131
|
+
const { target, targetType, condition } = watch;
|
|
132
|
+
|
|
133
|
+
if (targetType === WATCH_TARGET_TYPE.PR) {
|
|
134
|
+
const pr = (state.pullRequests || []).find(p =>
|
|
135
|
+
String(p.prNumber) === String(target) || p.id === target
|
|
136
|
+
);
|
|
137
|
+
if (!pr) return { triggered: false, message: `PR ${target} not found` };
|
|
138
|
+
|
|
139
|
+
// Store previous state for status-change detection
|
|
140
|
+
const prevState = watch._lastState || {};
|
|
141
|
+
|
|
142
|
+
switch (condition) {
|
|
143
|
+
case WATCH_CONDITION.MERGED:
|
|
144
|
+
return { triggered: pr.status === 'merged', message: pr.status === 'merged' ? `PR ${target} was merged` : '' };
|
|
145
|
+
case WATCH_CONDITION.BUILD_FAIL:
|
|
146
|
+
return { triggered: pr.buildStatus === 'failing', message: pr.buildStatus === 'failing' ? `PR ${target} build is failing` : '' };
|
|
147
|
+
case WATCH_CONDITION.BUILD_PASS:
|
|
148
|
+
return { triggered: pr.buildStatus === 'passing', message: pr.buildStatus === 'passing' ? `PR ${target} build is passing` : '' };
|
|
149
|
+
case WATCH_CONDITION.STATUS_CHANGE: {
|
|
150
|
+
const changed = prevState.status !== undefined && prevState.status !== pr.status;
|
|
151
|
+
return { triggered: changed, message: changed ? `PR ${target} status changed: ${prevState.status} → ${pr.status}` : '' };
|
|
152
|
+
}
|
|
153
|
+
case WATCH_CONDITION.ANY: {
|
|
154
|
+
const anyChanged = prevState.status !== undefined && (
|
|
155
|
+
prevState.status !== pr.status ||
|
|
156
|
+
prevState.buildStatus !== pr.buildStatus ||
|
|
157
|
+
prevState.reviewStatus !== pr.reviewStatus
|
|
158
|
+
);
|
|
159
|
+
return { triggered: anyChanged, message: anyChanged ? `PR ${target} changed` : '' };
|
|
160
|
+
}
|
|
161
|
+
default:
|
|
162
|
+
return { triggered: false, message: `Unknown condition: ${condition}` };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (targetType === WATCH_TARGET_TYPE.WORK_ITEM) {
|
|
167
|
+
const wi = (state.workItems || []).find(w => w.id === target);
|
|
168
|
+
if (!wi) return { triggered: false, message: `Work item ${target} not found` };
|
|
169
|
+
|
|
170
|
+
const prevState = watch._lastState || {};
|
|
171
|
+
|
|
172
|
+
switch (condition) {
|
|
173
|
+
case WATCH_CONDITION.COMPLETED:
|
|
174
|
+
return { triggered: shared.DONE_STATUSES.has(wi.status), message: shared.DONE_STATUSES.has(wi.status) ? `Work item ${target} completed (${wi.status})` : '' };
|
|
175
|
+
case WATCH_CONDITION.FAILED:
|
|
176
|
+
return { triggered: wi.status === shared.WI_STATUS.FAILED, message: wi.status === shared.WI_STATUS.FAILED ? `Work item ${target} failed` : '' };
|
|
177
|
+
case WATCH_CONDITION.STATUS_CHANGE: {
|
|
178
|
+
const changed = prevState.status !== undefined && prevState.status !== wi.status;
|
|
179
|
+
return { triggered: changed, message: changed ? `Work item ${target} status: ${prevState.status} → ${wi.status}` : '' };
|
|
180
|
+
}
|
|
181
|
+
case WATCH_CONDITION.ANY: {
|
|
182
|
+
const anyChanged = prevState.status !== undefined && prevState.status !== wi.status;
|
|
183
|
+
return { triggered: anyChanged, message: anyChanged ? `Work item ${target} changed (${wi.status})` : '' };
|
|
184
|
+
}
|
|
185
|
+
default:
|
|
186
|
+
return { triggered: false, message: `Unknown condition: ${condition}` };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { triggered: false, message: `Unknown target type: ${targetType}` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check all active watches against current state. Called from engine tick.
|
|
195
|
+
* @param {object} config - Engine config
|
|
196
|
+
* @param {object} state - { pullRequests, workItems } from queries
|
|
197
|
+
*/
|
|
198
|
+
function checkWatches(config, state) {
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
// Collect notifications to fire AFTER lock is released — never do I/O inside the lock callback
|
|
201
|
+
const notifications = [];
|
|
202
|
+
|
|
203
|
+
mutateJsonFileLocked(_watchesPath(), (watches) => {
|
|
204
|
+
if (!Array.isArray(watches) || watches.length === 0) return watches;
|
|
205
|
+
|
|
206
|
+
for (const watch of watches) {
|
|
207
|
+
try {
|
|
208
|
+
if (watch.status !== WATCH_STATUS.ACTIVE) continue;
|
|
209
|
+
|
|
210
|
+
// Check interval — skip if checked too recently
|
|
211
|
+
if (watch.last_checked) {
|
|
212
|
+
const elapsed = now - new Date(watch.last_checked).getTime();
|
|
213
|
+
if (elapsed < watch.interval) continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
watch.last_checked = ts();
|
|
217
|
+
|
|
218
|
+
// Initialize baseline state on first check for change-detection conditions.
|
|
219
|
+
// Without this, status-change/any conditions would have no previous state to compare.
|
|
220
|
+
if (!watch._lastState || Object.keys(watch._lastState).length === 0) {
|
|
221
|
+
watch._lastState = _captureState(watch, state);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const result = evaluateWatch(watch, state);
|
|
225
|
+
if (result.triggered) {
|
|
226
|
+
watch.triggerCount = (watch.triggerCount || 0) + 1;
|
|
227
|
+
watch.last_triggered = ts();
|
|
228
|
+
watch._lastTriggerMessage = result.message;
|
|
229
|
+
|
|
230
|
+
// Queue trigger notification — unique key per trigger to avoid overwriting previous messages
|
|
231
|
+
if (watch.notify === 'inbox' && watch.owner) {
|
|
232
|
+
notifications.push({
|
|
233
|
+
type: 'trigger', owner: watch.owner,
|
|
234
|
+
slug: `watch-${watch.id}-${watch.triggerCount}`,
|
|
235
|
+
body: `## Watch Triggered: ${watch.description}\n\n${result.message}\n\nWatch ID: ${watch.id} | Target: ${watch.target} | Condition: ${watch.condition}`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
log('info', `Watch triggered: ${watch.id} — ${result.message}`);
|
|
239
|
+
|
|
240
|
+
// Expire: absolute conditions auto-expire when stopAfter is 0 (fire-once semantics),
|
|
241
|
+
// change-based conditions (status-change, any) respect stopAfter literally (0 = run forever).
|
|
242
|
+
const isAbsolute = WATCH_ABSOLUTE_CONDITIONS.has(watch.condition);
|
|
243
|
+
if (watch.stopAfter > 0 && watch.triggerCount >= watch.stopAfter) {
|
|
244
|
+
watch.status = WATCH_STATUS.EXPIRED;
|
|
245
|
+
log('info', `Watch expired (stopAfter limit reached): ${watch.id}`);
|
|
246
|
+
} else if (isAbsolute && watch.stopAfter === 0) {
|
|
247
|
+
watch.status = WATCH_STATUS.EXPIRED;
|
|
248
|
+
log('info', `Watch expired (absolute condition auto-expire): ${watch.id}`);
|
|
249
|
+
}
|
|
250
|
+
} else if (watch.onNotMet === 'notify' && watch.owner) {
|
|
251
|
+
// Queue per-poll notification when condition is not yet met — unique key per poll
|
|
252
|
+
notifications.push({
|
|
253
|
+
type: 'poll', owner: watch.owner,
|
|
254
|
+
slug: `watch-poll-${watch.id}-${Date.now()}`,
|
|
255
|
+
body: `## Watch Polling: ${watch.description}\n\nCondition not yet met (${watch.condition}) — still watching.\n\nWatch ID: ${watch.id} | Target: ${watch.target} | Checks so far: ${watch.triggerCount || 0}`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Capture state for change detection on next check
|
|
260
|
+
watch._lastState = _captureState(watch, state);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
log('warn', `Watch check error (${watch.id}): ${err.message}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return watches;
|
|
267
|
+
}, { defaultValue: [] });
|
|
268
|
+
|
|
269
|
+
// Fire notifications outside the lock — writeToInbox does disk I/O
|
|
270
|
+
for (const n of notifications) {
|
|
271
|
+
try {
|
|
272
|
+
writeToInbox(n.owner, n.slug, n.body);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
log('warn', `Watch notification error: ${err.message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Internal: capture state snapshot for a watch target.
|
|
281
|
+
*/
|
|
282
|
+
function _captureState(watch, state) {
|
|
283
|
+
if (watch.targetType === WATCH_TARGET_TYPE.PR) {
|
|
284
|
+
const pr = (state.pullRequests || []).find(p =>
|
|
285
|
+
String(p.prNumber) === String(watch.target) || p.id === watch.target
|
|
286
|
+
);
|
|
287
|
+
if (pr) return { status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus };
|
|
288
|
+
}
|
|
289
|
+
if (watch.targetType === WATCH_TARGET_TYPE.WORK_ITEM) {
|
|
290
|
+
const wi = (state.workItems || []).find(w => w.id === watch.target);
|
|
291
|
+
if (wi) return { status: wi.status };
|
|
292
|
+
}
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
DEFAULT_WATCH_INTERVAL,
|
|
298
|
+
getWatches,
|
|
299
|
+
createWatch,
|
|
300
|
+
updateWatch,
|
|
301
|
+
deleteWatch,
|
|
302
|
+
evaluateWatch,
|
|
303
|
+
checkWatches,
|
|
304
|
+
_captureState, // exported for testing
|
|
305
|
+
_watchesPath, // exported for testing — dynamic, respects MINIONS_TEST_DIR
|
|
306
|
+
};
|
package/engine.js
CHANGED
|
@@ -2021,7 +2021,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2021
2021
|
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
|
|
2022
2022
|
pr_author: pr.agent || '', pr_url: pr.url || '',
|
|
2023
2023
|
}, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
|
|
2024
|
-
if (item) { newWork.push(item);
|
|
2024
|
+
if (item) { newWork.push(item); }
|
|
2025
2025
|
}
|
|
2026
2026
|
|
|
2027
2027
|
// PRs with changes requested → route back to author for fix
|
|
@@ -3166,6 +3166,24 @@ async function tickInner() {
|
|
|
3166
3166
|
safe('runCleanup', () => runCleanup(config));
|
|
3167
3167
|
}
|
|
3168
3168
|
|
|
3169
|
+
// 2.55. Check persistent watches (every 3 ticks = ~3 minutes)
|
|
3170
|
+
if (tickCount % 3 === 0) {
|
|
3171
|
+
safe('checkWatches', () => {
|
|
3172
|
+
const { checkWatches } = require('./engine/watches');
|
|
3173
|
+
const pullRequests = PROJECTS.flatMap(p => {
|
|
3174
|
+
const prPath = path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json');
|
|
3175
|
+
return safeJson(prPath) || [];
|
|
3176
|
+
});
|
|
3177
|
+
const workItems = PROJECTS.flatMap(p => {
|
|
3178
|
+
const wiPath = path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json');
|
|
3179
|
+
return safeJson(wiPath) || [];
|
|
3180
|
+
});
|
|
3181
|
+
// Also include central work items
|
|
3182
|
+
const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
|
|
3183
|
+
checkWatches(config, { pullRequests, workItems: [...workItems, ...centralWi] });
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3169
3187
|
const adoPollEnabled = config.engine?.adoPollEnabled ?? DEFAULTS.adoPollEnabled;
|
|
3170
3188
|
const ghPollEnabled = config.engine?.ghPollEnabled ?? DEFAULTS.ghPollEnabled;
|
|
3171
3189
|
const adoPollStatusEvery = Math.max(1, Number(config.engine?.adoPollStatusEvery) || DEFAULTS.adoPollStatusEvery);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.977",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|
package/prompts/cc-system.md
CHANGED
|
@@ -82,6 +82,9 @@ Additional actions (all take `id` or `file` as primary key):
|
|
|
82
82
|
- Meetings: add-meeting-note (id, note), advance-meeting (id), end-meeting (id), archive-meeting (id), unarchive-meeting (id), delete-meeting (id)
|
|
83
83
|
- Work item ops: delete-work-item (id, source), archive-work-item (id), work-item-feedback (id, rating: up/down, comment), reopen-work-item (id, project[, description] — reopen a done/failed item back to pending)
|
|
84
84
|
- PRD ops: edit-prd-item, remove-prd-item, reopen-prd-item (id, file — re-dispatches on existing branch)
|
|
85
|
+
- **create-watch**: target, targetType (pr/work-item/branch), condition (merged/build-fail/build-pass/completed/failed/status-change/any), interval ("15m", "1h", "30s" — default "5m"), owner (agent id or "human"), description, project, stopAfter (0=run forever, 1=expire on first match, N=expire after N matches), onNotMet (null=do nothing, "notify"=write to inbox each poll while condition not met)
|
|
86
|
+
Example: user says "check PR 1065 build every 15 min until green" → `{"type":"create-watch","target":"1065","targetType":"pr","condition":"build-pass","interval":"15m","stopAfter":1,"description":"Watch PR 1065 build until green"}`
|
|
87
|
+
Example: user says "ping me every 15 min while build is still failing" → `{"type":"create-watch","target":"1065","targetType":"pr","condition":"build-pass","interval":"15m","stopAfter":1,"onNotMet":"notify","description":"Watch PR 1065 build — notify each poll"}`
|
|
85
88
|
- KB/Inbox: promote-to-kb (file, category), kb-sweep, toggle-kb-pin (key)
|
|
86
89
|
- Plan lifecycle: revise-plan (file, feedback — dispatches agent to revise)
|
|
87
90
|
- Pipeline: continue-pipeline (id — resume past wait stage)
|