@yemi33/minions 0.1.976 → 0.1.978
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 +10 -8
- package/dashboard/js/command-center.js +3 -4
- 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 +82 -2
- package/engine/playbook.js +29 -3
- package/engine/shared.js +6 -0
- package/engine/watches.js +306 -0
- package/engine.js +23 -44
- package/package.json +1 -1
- package/prompts/cc-system.md +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.978 (2026-04-15)
|
|
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
|
|
9
11
|
- reassign tasks when preferred agent is busy too long
|
|
10
12
|
- wire agentBusyReassignMs into settings UI and persist
|
|
11
13
|
- ADO throttle detection, poll guards, and dashboard banner (#1051)
|
|
@@ -21,12 +23,15 @@
|
|
|
21
23
|
- doc-chat abort kills LLM process + queued messages auto-process
|
|
22
24
|
- add /api/work-items/reopen endpoint and reopen-work-item CC action (#982)
|
|
23
25
|
- CC tab unread dot + reopened badge on work items
|
|
24
|
-
- fix dep re-merge failure on retry with existing commits (#977)
|
|
25
|
-
- audit and harden log buffering implementation (#971)
|
|
26
26
|
|
|
27
27
|
### Fixes
|
|
28
|
+
- ENGINE_DEFAULTS undefined in tick + playbook empty-var false positives
|
|
29
|
+
- defer review setCooldown to post-gating in discoverWork
|
|
28
30
|
- move review verdict check before updateWorkItemStatus(DONE)
|
|
31
|
+
- address review feedback — move writeToInbox outside lock, add absolute condition auto-expire
|
|
32
|
+
- fix watches feature gaps — human notifications, branch stub, status-change init, unique keys
|
|
29
33
|
- skip isAlreadyDispatched in needsReReview to allow re-review within 1hr
|
|
34
|
+
- skip SessionStart hook settings test on CI
|
|
30
35
|
- loop queue flush so messages queued during combined send aren't orphaned
|
|
31
36
|
- set lastPushedAt on fix completion to unblock re-review without poller
|
|
32
37
|
- restore ADO throttle test functions deleted during merge conflict resolution
|
|
@@ -40,14 +45,11 @@
|
|
|
40
45
|
- defer setCooldown to post-gating in discoverWork
|
|
41
46
|
- fix CC queued message 'already processing' and thinking UX stacking
|
|
42
47
|
- enforce --timeout 1 on all azureauth calls to prevent agent orphans (#1063)
|
|
43
|
-
- cancel steering kill watcher on resume spawn (#1052) (#1062)
|
|
44
|
-
- suppress warn for optional template variables (#1061)
|
|
45
|
-
- skip SessionStart hook settings test on CI
|
|
46
|
-
- fix boolean settings test and add agentBusyReassignMs to dashboard
|
|
47
|
-
- fix ENGINE_DEFAULTS ref and derive boolean settings from defaults
|
|
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
|
|
@@ -388,15 +388,14 @@ async function ccSend() {
|
|
|
388
388
|
}
|
|
389
389
|
var wasAborted = await _ccDoSend(message, false, originTabId);
|
|
390
390
|
|
|
391
|
-
// Flush
|
|
392
|
-
// Loop in case user queues more messages while the combined flush is processing.
|
|
391
|
+
// Flush queued messages to the ORIGINAL tab, even if user switched tabs
|
|
393
392
|
while (tab._queue && tab._queue.length > 0) {
|
|
394
393
|
if (wasAborted) {
|
|
395
394
|
await new Promise(function(r) { setTimeout(r, 1500); });
|
|
396
395
|
}
|
|
397
|
-
var
|
|
396
|
+
var next = tab._queue.shift();
|
|
398
397
|
_renderQueueIndicator();
|
|
399
|
-
wasAborted = await _ccDoSend(
|
|
398
|
+
wasAborted = await _ccDoSend(next, false, originTabId);
|
|
400
399
|
}
|
|
401
400
|
}
|
|
402
401
|
|
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();
|
|
@@ -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/playbook.js
CHANGED
|
@@ -268,10 +268,32 @@ function validatePlaybookVars(playbookName, vars) {
|
|
|
268
268
|
return { valid: missing.length === 0, missing };
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
// ─── Playbook Path Resolution ───────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Resolve playbook file path, checking project-local override first.
|
|
275
|
+
* If projects/<projectName>/playbooks/<playbookType>.md exists, use it.
|
|
276
|
+
* Otherwise fall back to the global playbooks/<playbookType>.md.
|
|
277
|
+
* @param {string|null|undefined} projectName — project name (directory under projects/)
|
|
278
|
+
* @param {string} playbookType — playbook type name (e.g. 'implement', 'review')
|
|
279
|
+
* @returns {string} absolute path to the playbook file
|
|
280
|
+
*/
|
|
281
|
+
function resolvePlaybookPath(projectName, playbookType) {
|
|
282
|
+
if (projectName) {
|
|
283
|
+
const localPath = path.join(MINIONS_DIR, 'projects', projectName, 'playbooks', `${playbookType}.md`);
|
|
284
|
+
try {
|
|
285
|
+
fs.accessSync(localPath, fs.constants.R_OK);
|
|
286
|
+
return localPath;
|
|
287
|
+
} catch { /* no local override — fall through to global */ }
|
|
288
|
+
}
|
|
289
|
+
return path.join(PLAYBOOKS_DIR, `${playbookType}.md`);
|
|
290
|
+
}
|
|
291
|
+
|
|
271
292
|
// ─── Playbook Renderer ──────────────────────────────────────────────────────
|
|
272
293
|
|
|
273
294
|
function renderPlaybook(type, vars) {
|
|
274
|
-
const
|
|
295
|
+
const projectName = vars.project_name || null;
|
|
296
|
+
const pbPath = resolvePlaybookPath(projectName, type);
|
|
275
297
|
let content;
|
|
276
298
|
try { content = fs.readFileSync(pbPath, 'utf8'); } catch {
|
|
277
299
|
log('warn', `Playbook not found: ${type}`);
|
|
@@ -361,6 +383,9 @@ function renderPlaybook(type, vars) {
|
|
|
361
383
|
return null;
|
|
362
384
|
}
|
|
363
385
|
|
|
386
|
+
// Capture which vars are actually referenced in the template before substitution
|
|
387
|
+
const referencedVars = new Set((content.match(/\{\{(\w+)\}\}/g) || []).map(m => m.slice(2, -2)));
|
|
388
|
+
|
|
364
389
|
// Process conditional blocks: {{#key}}...{{/key}} — include block only if key is truthy
|
|
365
390
|
content = content.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_, key, block) => {
|
|
366
391
|
const val = allVars[key];
|
|
@@ -380,9 +405,9 @@ function renderPlaybook(type, vars) {
|
|
|
380
405
|
log('warn', `Playbook "${type}": substituted values contain unresolved {{...}} patterns (potential self-reference): ${selfRefVars.join(', ')}`);
|
|
381
406
|
}
|
|
382
407
|
|
|
383
|
-
// Warn on variables that resolved to empty string
|
|
408
|
+
// Warn on variables that resolved to empty string — only for vars actually used in the template
|
|
384
409
|
const emptyVars = Object.entries(allVars)
|
|
385
|
-
.filter(([key, val]) => String(val) === '' && !PLAYBOOK_OPTIONAL_VARS.has(key))
|
|
410
|
+
.filter(([key, val]) => String(val) === '' && referencedVars.has(key) && !PLAYBOOK_OPTIONAL_VARS.has(key))
|
|
386
411
|
.map(([key]) => key);
|
|
387
412
|
if (emptyVars.length > 0) {
|
|
388
413
|
log('warn', `Playbook "${type}": template variables resolved to empty string: ${emptyVars.join(', ')}`);
|
|
@@ -565,6 +590,7 @@ function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabe
|
|
|
565
590
|
|
|
566
591
|
module.exports = {
|
|
567
592
|
renderPlaybook,
|
|
593
|
+
resolvePlaybookPath,
|
|
568
594
|
validatePlaybookVars,
|
|
569
595
|
PLAYBOOK_REQUIRED_VARS,
|
|
570
596
|
PLAYBOOK_OPTIONAL_VARS,
|
package/engine/shared.js
CHANGED
|
@@ -613,6 +613,11 @@ 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
|
+
|
|
616
621
|
/** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
|
|
617
622
|
function trackReviewMetric(pr, newReviewStatus, config) {
|
|
618
623
|
if (newReviewStatus !== 'approved' && newReviewStatus !== 'changes-requested') return;
|
|
@@ -1138,6 +1143,7 @@ module.exports = {
|
|
|
1138
1143
|
classifyInboxItem,
|
|
1139
1144
|
ENGINE_DEFAULTS,
|
|
1140
1145
|
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,
|
|
1146
|
+
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION,
|
|
1141
1147
|
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
1142
1148
|
FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
|
|
1143
1149
|
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
|
@@ -1984,46 +1984,6 @@ async function discoverFromPrs(config, project) {
|
|
|
1984
1984
|
if (item) { newWork.push(item); }
|
|
1985
1985
|
}
|
|
1986
1986
|
|
|
1987
|
-
// Re-review after fix: only trigger when fixedAt > lastReviewedAt (not a broad !alreadyReviewed
|
|
1988
|
-
// fallback — that caused infinite re-review loops on GitHub where self-approval is blocked)
|
|
1989
|
-
const fixedAfterReview = !!(pr.minionsReview?.fixedAt &&
|
|
1990
|
-
pr.lastReviewedAt && pr.minionsReview.fixedAt > pr.lastReviewedAt);
|
|
1991
|
-
const needsReReview = autoReview && reviewStatus === 'waiting' &&
|
|
1992
|
-
fixedAfterReview && !evalEscalated;
|
|
1993
|
-
if (needsReReview) {
|
|
1994
|
-
const key = `review-${project?.name || 'default'}-${pr.id}`;
|
|
1995
|
-
// Skip isAlreadyDispatched — fixedAfterReview/alreadyReviewed already dedup; the 1hr
|
|
1996
|
-
// completed-dispatch window would block legitimate re-reviews within the hour after a fix
|
|
1997
|
-
if (isOnCooldown(key, cooldownMs)) continue;
|
|
1998
|
-
|
|
1999
|
-
// Pre-dispatch live vote check — cached 'waiting' may be stale if reviewer already acted
|
|
2000
|
-
try {
|
|
2001
|
-
const checkFn = project.repoHost === 'github' ? ghCheckLiveReview : adoCheckLiveReview;
|
|
2002
|
-
const liveStatus = await checkFn(pr, project);
|
|
2003
|
-
if (liveStatus && liveStatus !== 'waiting') {
|
|
2004
|
-
log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was waiting) — skipping re-review`);
|
|
2005
|
-
if (pr.reviewStatus !== 'approved') pr.reviewStatus = liveStatus;
|
|
2006
|
-
try {
|
|
2007
|
-
mutateJsonFileLocked(projectPrPath(project), data => {
|
|
2008
|
-
if (!Array.isArray(data)) return data;
|
|
2009
|
-
const target = data.find(p => p.id === pr.id);
|
|
2010
|
-
if (target && target.reviewStatus !== 'approved') target.reviewStatus = liveStatus;
|
|
2011
|
-
return data;
|
|
2012
|
-
});
|
|
2013
|
-
} catch {}
|
|
2014
|
-
continue;
|
|
2015
|
-
}
|
|
2016
|
-
} catch (e) { log('warn', `Pre-dispatch vote check for ${pr.id}: ${e.message}`); }
|
|
2017
|
-
|
|
2018
|
-
const agentId = resolveAgent('review', config);
|
|
2019
|
-
if (!agentId) continue;
|
|
2020
|
-
const item = buildPrDispatch(agentId, config, project, pr, 'review', {
|
|
2021
|
-
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
|
|
2022
|
-
pr_author: pr.agent || '', pr_url: pr.url || '',
|
|
2023
|
-
}, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
|
|
2024
|
-
if (item) { newWork.push(item); setCooldown(key); }
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
1987
|
// PRs with changes requested → route back to author for fix
|
|
2028
1988
|
let fixDispatched = false;
|
|
2029
1989
|
if (reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
|
|
@@ -2037,7 +1997,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2037
1997
|
review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
|
|
2038
1998
|
}, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
|
|
2039
1999
|
if (item) {
|
|
2040
|
-
newWork.push(item); fixDispatched = true;
|
|
2000
|
+
newWork.push(item); setCooldown(key); fixDispatched = true;
|
|
2041
2001
|
// Increment review→fix cycle counter
|
|
2042
2002
|
try {
|
|
2043
2003
|
mutatePullRequests(projectPrPath(project), prs => {
|
|
@@ -2075,7 +2035,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2075
2035
|
reviewer: 'Human Reviewer',
|
|
2076
2036
|
review_note: reviewNote,
|
|
2077
2037
|
}, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: pr.branch, project: projMeta });
|
|
2078
|
-
if (item) { newWork.push(item); fixDispatched = true; }
|
|
2038
|
+
if (item) { newWork.push(item); setCooldown(key); fixDispatched = true; }
|
|
2079
2039
|
}
|
|
2080
2040
|
|
|
2081
2041
|
// PRs with build failures — route to author (has session context from implementing)
|
|
@@ -2124,7 +2084,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2124
2084
|
review_note: reviewNote,
|
|
2125
2085
|
}, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
|
|
2126
2086
|
if (item) {
|
|
2127
|
-
newWork.push(item);
|
|
2087
|
+
newWork.push(item); setCooldown(key);
|
|
2128
2088
|
// Increment build fix attempts counter
|
|
2129
2089
|
try {
|
|
2130
2090
|
const prPath = projectPrPath(project);
|
|
@@ -2181,6 +2141,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2181
2141
|
}, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
|
|
2182
2142
|
if (item) {
|
|
2183
2143
|
newWork.push(item);
|
|
2144
|
+
setCooldown(key);
|
|
2184
2145
|
// Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
|
|
2185
2146
|
try {
|
|
2186
2147
|
mutatePullRequests(projectPrPath(project), prs => {
|
|
@@ -3166,6 +3127,24 @@ async function tickInner() {
|
|
|
3166
3127
|
safe('runCleanup', () => runCleanup(config));
|
|
3167
3128
|
}
|
|
3168
3129
|
|
|
3130
|
+
// 2.55. Check persistent watches (every 3 ticks = ~3 minutes)
|
|
3131
|
+
if (tickCount % 3 === 0) {
|
|
3132
|
+
safe('checkWatches', () => {
|
|
3133
|
+
const { checkWatches } = require('./engine/watches');
|
|
3134
|
+
const pullRequests = PROJECTS.flatMap(p => {
|
|
3135
|
+
const prPath = path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json');
|
|
3136
|
+
return safeJson(prPath) || [];
|
|
3137
|
+
});
|
|
3138
|
+
const workItems = PROJECTS.flatMap(p => {
|
|
3139
|
+
const wiPath = path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json');
|
|
3140
|
+
return safeJson(wiPath) || [];
|
|
3141
|
+
});
|
|
3142
|
+
// Also include central work items
|
|
3143
|
+
const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
|
|
3144
|
+
checkWatches(config, { pullRequests, workItems: [...workItems, ...centralWi] });
|
|
3145
|
+
});
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3169
3148
|
const adoPollEnabled = config.engine?.adoPollEnabled ?? DEFAULTS.adoPollEnabled;
|
|
3170
3149
|
const ghPollEnabled = config.engine?.ghPollEnabled ?? DEFAULTS.ghPollEnabled;
|
|
3171
3150
|
const adoPollStatusEvery = Math.max(1, Number(config.engine?.adoPollStatusEvery) || DEFAULTS.adoPollStatusEvery);
|
|
@@ -3372,7 +3351,7 @@ async function tickInner() {
|
|
|
3372
3351
|
if (busyAgents.has(item.agent)) {
|
|
3373
3352
|
// Agent busy reassignment: if item has been waiting on a busy agent past the threshold,
|
|
3374
3353
|
// try to find an alternative agent via routing. Skip explicitly assigned items.
|
|
3375
|
-
const reassignMs = config.engine?.agentBusyReassignMs ??
|
|
3354
|
+
const reassignMs = config.engine?.agentBusyReassignMs ?? DEFAULTS.agentBusyReassignMs;
|
|
3376
3355
|
const isExplicitReassign = !!item.meta?.item?.agent;
|
|
3377
3356
|
if (!isExplicitReassign && reassignMs > 0 && item._agentBusySince) {
|
|
3378
3357
|
const busySinceMs = new Date(item._agentBusySince).getTime();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.978",
|
|
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)
|