@yemi33/minions 0.1.1857 → 0.1.1859
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 +16 -2
- package/dashboard/js/command-parser.js +8 -1
- package/dashboard/js/render-kb.js +26 -2
- package/dashboard/js/render-meetings.js +5 -6
- package/dashboard/js/render-prs.js +4 -4
- package/dashboard/js/render-watches.js +74 -11
- package/dashboard/pages/prs.html +1 -0
- package/dashboard.js +60 -14
- package/engine/kb-sweep.js +35 -0
- package/engine/shared.js +16 -1
- package/engine/watch-actions.js +514 -0
- package/engine/watches.js +90 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1859 (2026-05-10)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- support follow-up actions on watch trigger (#2323)
|
|
7
|
+
- persist KB sweep state + remove frontend timeout (#2319)
|
|
8
|
+
|
|
9
|
+
### Other
|
|
10
|
+
- ui(toast): auto-route generic cmd-toast calls to the active page's inline slot
|
|
11
|
+
- ui(prs): show PR link/unlink feedback inline under the section header
|
|
12
|
+
|
|
13
|
+
## 0.1.1858 (2026-05-10)
|
|
14
|
+
|
|
15
|
+
### Other
|
|
16
|
+
- ui(meetings,kb): make plan-from-meeting and KB sweep optimistically immediate
|
|
17
|
+
|
|
18
|
+
## 0.1.1856 (2026-05-10)
|
|
4
19
|
|
|
5
20
|
### Other
|
|
6
|
-
- ci: re-run PR Tests when latest result is older than 3h (#2331)
|
|
7
21
|
- ui(toast): add inline toast variant + scope KB sweep / meeting plan feedback locally
|
|
8
22
|
|
|
9
23
|
## 0.1.1855 (2026-05-10)
|
|
@@ -12,7 +12,14 @@ function cmdUpdateProjectList(projects) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function showToast(id, msg, ok, durationMs) {
|
|
15
|
-
|
|
15
|
+
let el = document.getElementById(id);
|
|
16
|
+
// Auto-route the generic 'cmd-toast' id to the active page's inline slot
|
|
17
|
+
// (a .page-toast injected by dashboard.js). Falls back to the floating
|
|
18
|
+
// top-level #cmd-toast when no page is active (modals, etc.).
|
|
19
|
+
if (id === 'cmd-toast') {
|
|
20
|
+
const pageInline = document.querySelector('.page.active .page-toast');
|
|
21
|
+
if (pageInline) el = pageInline;
|
|
22
|
+
}
|
|
16
23
|
if (!el) { console.warn('[showToast] no element with id', id, '— message:', msg); return; }
|
|
17
24
|
el.classList.add('cmd-toast');
|
|
18
25
|
el.classList.remove('success', 'error');
|
|
@@ -20,6 +20,16 @@ let _kbPage = 0;
|
|
|
20
20
|
function _kbPrev() { if (_kbPage > 0) { _kbPage--; renderKnowledgeBase(); } }
|
|
21
21
|
function _kbNext() { _kbPage++; renderKnowledgeBase(); }
|
|
22
22
|
|
|
23
|
+
function _formatSweepElapsed(startedAt) {
|
|
24
|
+
if (!startedAt) return '';
|
|
25
|
+
const elapsedMs = Date.now() - Number(startedAt);
|
|
26
|
+
if (!isFinite(elapsedMs) || elapsedMs < 0) return '';
|
|
27
|
+
const mins = Math.floor(elapsedMs / 60000);
|
|
28
|
+
if (mins < 1) return Math.max(1, Math.floor(elapsedMs / 1000)) + 's';
|
|
29
|
+
if (mins < 60) return mins + 'm';
|
|
30
|
+
return Math.floor(mins / 60) + 'h ' + (mins % 60) + 'm';
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
async function refreshKnowledgeBase() {
|
|
24
34
|
try {
|
|
25
35
|
_kbData = await fetch('/api/knowledge').then(r => r.json());
|
|
@@ -60,7 +70,16 @@ function renderKnowledgeBase() {
|
|
|
60
70
|
countEl.textContent = allItems.length;
|
|
61
71
|
|
|
62
72
|
const sweptEl = document.getElementById('kb-swept-time');
|
|
63
|
-
if (sweptEl)
|
|
73
|
+
if (sweptEl) {
|
|
74
|
+
let sweptText = _kbData.lastSwept ? 'swept ' + timeSinceStr(new Date(_kbData.lastSwept)) : '';
|
|
75
|
+
if (_kbData.sweepInFlight) {
|
|
76
|
+
const dur = _formatSweepElapsed(_kbData.sweepStartedAt);
|
|
77
|
+
sweptText = sweptText
|
|
78
|
+
? sweptText + ' \u00b7 now sweeping' + (dur ? ' (' + dur + ')' : '')
|
|
79
|
+
: 'now sweeping' + (dur ? ' (' + dur + ')' : '');
|
|
80
|
+
}
|
|
81
|
+
sweptEl.textContent = sweptText;
|
|
82
|
+
}
|
|
64
83
|
|
|
65
84
|
if (allItems.length === 0) {
|
|
66
85
|
tabsEl.innerHTML = '';
|
|
@@ -145,6 +164,8 @@ function kbSetTab(tab) {
|
|
|
145
164
|
}
|
|
146
165
|
|
|
147
166
|
async function kbSweep() {
|
|
167
|
+
// Optimistic: instant feedback before any network round-trip.
|
|
168
|
+
showToast('kb-sweep-toast', 'KB sweep queued — runs in the background, this can take many minutes for a large KB', true, 8000);
|
|
148
169
|
try {
|
|
149
170
|
const res = await fetch('/api/knowledge/sweep', {
|
|
150
171
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
@@ -155,7 +176,10 @@ async function kbSweep() {
|
|
|
155
176
|
showToast('kb-sweep-toast', 'Sweep failed: ' + (data.error || 'unknown'), false);
|
|
156
177
|
return;
|
|
157
178
|
}
|
|
158
|
-
|
|
179
|
+
if (data.alreadyRunning) {
|
|
180
|
+
const dur = _formatSweepElapsed(data.startedAt);
|
|
181
|
+
showToast('kb-sweep-toast', 'KB sweep already running' + (dur ? ' (' + dur + ' elapsed)' : '') + ' — let it finish first', true, 8000);
|
|
182
|
+
}
|
|
159
183
|
} catch (e) {
|
|
160
184
|
showToast('kb-sweep-toast', 'Sweep error: ' + e.message, false);
|
|
161
185
|
}
|
|
@@ -420,17 +420,14 @@ function _findLinkedPlan(meeting) {
|
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
async function _createPlanFromMeeting(id, btn) {
|
|
423
|
+
// Optimistic: instant feedback before fetching the meeting + posting the WI.
|
|
424
|
+
showToast('meeting-plan-toast', 'Plan task queued — agent will draft the plan from this meeting', true);
|
|
423
425
|
try {
|
|
424
426
|
const res = await fetch('/api/meetings/' + encodeURIComponent(id));
|
|
425
427
|
const data = await res.json();
|
|
426
428
|
if (!data.meeting) { showToast('meeting-plan-toast', 'Meeting not found', false); return; }
|
|
427
429
|
const m = data.meeting;
|
|
428
430
|
|
|
429
|
-
const existing = _findLinkedPlan(m);
|
|
430
|
-
if (existing) {
|
|
431
|
-
if (!confirm('A plan already exists: "' + existing.summary + '"\n\nQueue another plan task anyway?')) return;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
431
|
const transcript = (m.transcript || []).map(function(t) {
|
|
435
432
|
return '### ' + (t.agent || 'agent') + ' (' + (t.type || '') + ', Round ' + (t.round || '?') + ')\n\n' + (t.content || '');
|
|
436
433
|
}).join('\n\n---\n\n');
|
|
@@ -461,7 +458,9 @@ async function _createPlanFromMeeting(id, btn) {
|
|
|
461
458
|
showToast('meeting-plan-toast', 'Failed to queue plan task: ' + (planData.error || 'unknown'), false);
|
|
462
459
|
return;
|
|
463
460
|
}
|
|
464
|
-
|
|
461
|
+
if (planData.id) {
|
|
462
|
+
showToast('meeting-plan-toast', 'Plan task queued (' + planData.id + ')', true);
|
|
463
|
+
}
|
|
465
464
|
if (typeof wakeEngine === 'function') wakeEngine();
|
|
466
465
|
if (typeof refresh === 'function') refresh();
|
|
467
466
|
} catch (e) {
|
|
@@ -158,7 +158,7 @@ async function _submitLinkPr(e) {
|
|
|
158
158
|
});
|
|
159
159
|
const data = await res.json();
|
|
160
160
|
if (res.ok) {
|
|
161
|
-
showToast('
|
|
161
|
+
showToast('pr-toast', (data.message || 'PR linked') + (autoObserve ? ' (auto-observe on)' : ''), true, 6000);
|
|
162
162
|
refresh();
|
|
163
163
|
} else { alert('Failed: ' + (data.error || 'unknown')); openAddPrModal(); }
|
|
164
164
|
} catch (e) { alert('Error: ' + e.message); openAddPrModal(); }
|
|
@@ -166,7 +166,7 @@ async function _submitLinkPr(e) {
|
|
|
166
166
|
|
|
167
167
|
async function unlinkPr(id) {
|
|
168
168
|
if (!confirm('Remove ' + id + ' from tracking?')) return;
|
|
169
|
-
showToast('
|
|
169
|
+
showToast('pr-toast', id + ' removed', true);
|
|
170
170
|
markDeleted('pr:' + id);
|
|
171
171
|
const escId = window.CSS && CSS.escape ? CSS.escape(id) : id;
|
|
172
172
|
const row = document.querySelector('[data-pr-id="' + escId + '"]')?.closest('tr');
|
|
@@ -176,8 +176,8 @@ async function unlinkPr(id) {
|
|
|
176
176
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
177
177
|
body: JSON.stringify({ id })
|
|
178
178
|
});
|
|
179
|
-
if (!res.ok) { clearDeleted('pr:' + id); const d = await res.json().catch(() => ({})); showToast('
|
|
180
|
-
} catch (e) { clearDeleted('pr:' + id); showToast('
|
|
179
|
+
if (!res.ok) { clearDeleted('pr:' + id); const d = await res.json().catch(() => ({})); showToast('pr-toast', 'Failed: ' + (d.error || 'unknown'), false); refresh(); return; }
|
|
180
|
+
} catch (e) { clearDeleted('pr:' + id); showToast('pr-toast', 'Error: ' + e.message, false); refresh(); }
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, unlinkPr };
|
|
@@ -14,6 +14,11 @@ const _WATCH_STATUS_BADGES = {
|
|
|
14
14
|
// { value, label, conditions: [...], description }
|
|
15
15
|
let _watchTargetTypesCache = null;
|
|
16
16
|
|
|
17
|
+
// Cache of action types fetched from /api/watches/action-types. Populated on
|
|
18
|
+
// first modal open. Each entry:
|
|
19
|
+
// { value, label, description, params: [{ key, required, description }] }
|
|
20
|
+
let _watchActionTypesCache = null;
|
|
21
|
+
|
|
17
22
|
// Built-in fallback labels — used when the API response hasn't loaded yet, or
|
|
18
23
|
// for legacy targetTypes/conditions that may appear on stored watches but not
|
|
19
24
|
// in the live registry. The registry from the API is authoritative when present.
|
|
@@ -113,7 +118,7 @@ function renderWatches(watchesData) {
|
|
|
113
118
|
var pageItems = watches.slice(start, start + WATCH_PER_PAGE);
|
|
114
119
|
|
|
115
120
|
var html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr>' +
|
|
116
|
-
'<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>' +
|
|
121
|
+
'<th>ID</th><th>Target</th><th>Type</th><th>Condition</th><th>Action</th><th>Interval</th><th>Owner</th><th>Status</th><th>Triggers</th><th>Last Checked</th><th></th>' +
|
|
117
122
|
'</tr></thead><tbody>';
|
|
118
123
|
|
|
119
124
|
for (var i = 0; i < pageItems.length; i++) {
|
|
@@ -121,6 +126,7 @@ function renderWatches(watchesData) {
|
|
|
121
126
|
var statusBadge = _WATCH_STATUS_BADGES[w.status] || escHtml(w.status || 'unknown');
|
|
122
127
|
var targetLabel = escHtml(_targetTypeLabel(w.targetType));
|
|
123
128
|
var condLabel = escHtml(_conditionLabel(w.condition));
|
|
129
|
+
var actionLabel = (w.action && w.action.type) ? escHtml(w.action.type) : '<span style="color:var(--muted)">notify</span>';
|
|
124
130
|
var lastChecked = w.last_checked ? timeAgo(w.last_checked) : 'never';
|
|
125
131
|
var lastTriggered = w.last_triggered ? timeAgo(w.last_triggered) : 'never';
|
|
126
132
|
var triggerInfo = (w.triggerCount || 0) + (w.stopAfter > 0 ? '/' + w.stopAfter : '');
|
|
@@ -130,6 +136,7 @@ function renderWatches(watchesData) {
|
|
|
130
136
|
'<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(w.description || w.target) + '">' + escHtml(w.target) + '</td>' +
|
|
131
137
|
'<td><span class="dispatch-type explore">' + escHtml(targetLabel) + '</span></td>' +
|
|
132
138
|
'<td><span style="font-size:11px;color:var(--blue)">' + escHtml(condLabel) + '</span></td>' +
|
|
139
|
+
'<td><span style="font-size:10px">' + actionLabel + '</span></td>' +
|
|
133
140
|
'<td><span style="font-size:10px;color:var(--muted)">' + escHtml(_intervalToHuman(w.interval)) + '</span></td>' +
|
|
134
141
|
'<td><span class="pr-agent">' + escHtml(w.owner || 'human') + '</span></td>' +
|
|
135
142
|
'<td>' + statusBadge + '</td>' +
|
|
@@ -197,6 +204,10 @@ function openWatchDetail(id) {
|
|
|
197
204
|
'<div><strong style="color:var(--muted)">Notify:</strong> ' + escHtml(w.notify || 'inbox') + '</div>' +
|
|
198
205
|
'<div><strong style="color:var(--muted)">Triggers:</strong> ' + (w.triggerCount || 0) + (w.stopAfter > 0 ? ' / ' + w.stopAfter + ' (expires after)' : ' (runs forever)') + '</div>' +
|
|
199
206
|
(w.onNotMet ? '<div><strong style="color:var(--muted)">On Each Poll (not met):</strong> ' + escHtml(w.onNotMet) + '</div>' : '') +
|
|
207
|
+
(w.action && w.action.type ? '<div><strong style="color:var(--muted)">Follow-up Action:</strong> <span class="dispatch-type explore">' + escHtml(w.action.type) + '</span>' +
|
|
208
|
+
(w.action.params && Object.keys(w.action.params).length ? '<div style="margin-top:4px;padding:8px;background:var(--surface2);border-radius:4px;font-size:11px;font-family:monospace;white-space:pre-wrap">' + escHtml(JSON.stringify(w.action.params, null, 2)) + '</div>' : '') +
|
|
209
|
+
'</div>' : '') +
|
|
210
|
+
(w._lastActionResult ? '<div><strong style="color:var(--muted)">Last Action Result:</strong> <span style="color:' + (w._lastActionResult.ok ? 'var(--green)' : 'var(--red)') + '">' + (w._lastActionResult.ok ? 'OK' : 'FAILED') + '</span> — ' + escHtml(w._lastActionResult.summary || '') + (w._lastActionResult.dispatchedItemId ? ' (dispatched: ' + escHtml(w._lastActionResult.dispatchedItemId) + ')' : '') + '</div>' : '') +
|
|
200
211
|
'<div><strong style="color:var(--muted)">Created:</strong> ' + escHtml(createdAt) + '</div>' +
|
|
201
212
|
'<div><strong style="color:var(--muted)">Last Checked:</strong> ' + escHtml(lastChecked) + '</div>' +
|
|
202
213
|
'<div><strong style="color:var(--muted)">Last Triggered:</strong> ' + escHtml(lastTriggered) + '</div>' +
|
|
@@ -279,6 +290,14 @@ function _watchFormHtml() {
|
|
|
279
290
|
var agentOpts = '<option value="">human</option>' + (cmdAgents || []).map(function(a) { return '<option value="' + escHtml(a.id) + '">' + escHtml(a.name) + '</option>'; }).join('');
|
|
280
291
|
var projOpts = '<option value="">Any</option>' + (cmdProjects || []).map(function(p) { return '<option value="' + escHtml(p.name) + '">' + escHtml(p.name) + '</option>'; }).join('');
|
|
281
292
|
|
|
293
|
+
// Action picker: empty value = no follow-up action (legacy notify-only behavior).
|
|
294
|
+
var actionOpts = '<option value="">None — notify only</option>';
|
|
295
|
+
if (_watchActionTypesCache && _watchActionTypesCache.length) {
|
|
296
|
+
actionOpts += _watchActionTypesCache.map(function(a) {
|
|
297
|
+
return '<option value="' + escHtml(a.value) + '" title="' + escHtml(a.description || '') + '">' + escHtml(a.label) + '</option>';
|
|
298
|
+
}).join('');
|
|
299
|
+
}
|
|
300
|
+
|
|
282
301
|
return '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
|
|
283
302
|
'<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>' +
|
|
284
303
|
'<label style="color:var(--text);font-size:var(--text-md)">Target (e.g. PR number, work item ID, meeting/plan/schedule/pipeline/dispatch/agent ID)<input id="watch-edit-target" placeholder="e.g. 1057, W-abc123, M-xyz, schedule-42" style="' + inputStyle + '"></label>' +
|
|
@@ -290,9 +309,27 @@ function _watchFormHtml() {
|
|
|
290
309
|
'<label style="color:var(--text);font-size:var(--text-md)">Description<input id="watch-edit-desc" placeholder="Optional description" style="' + inputStyle + '"></label>' +
|
|
291
310
|
'<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>' +
|
|
292
311
|
'<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>' +
|
|
312
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Follow-up Action <span style="font-size:10px;color:var(--muted)">(runs after the inbox notification when the watch fires)</span><select id="watch-edit-action-type" style="' + inputStyle + '" onchange="_updateWatchActionParamsHint()">' + actionOpts + '</select></label>' +
|
|
313
|
+
'<label style="color:var(--text);font-size:var(--text-md)" id="watch-edit-action-params-label" style="display:none">Action Params (JSON) <span style="font-size:10px;color:var(--muted)" id="watch-edit-action-params-hint"></span><textarea id="watch-edit-action-params" rows="4" placeholder=\'{"title": "Re-check {{target}} after merge", "type": "verify"}\' style="' + inputStyle + ';font-family:monospace"></textarea></label>' +
|
|
293
314
|
'</div>';
|
|
294
315
|
}
|
|
295
316
|
|
|
317
|
+
// Refresh the action params textarea hint based on the selected action type.
|
|
318
|
+
function _updateWatchActionParamsHint() {
|
|
319
|
+
var sel = document.getElementById('watch-edit-action-type');
|
|
320
|
+
var label = document.getElementById('watch-edit-action-params-label');
|
|
321
|
+
var hint = document.getElementById('watch-edit-action-params-hint');
|
|
322
|
+
if (!sel || !label || !hint) return;
|
|
323
|
+
if (!sel.value) { label.style.display = 'none'; return; }
|
|
324
|
+
label.style.display = '';
|
|
325
|
+
var entry = (_watchActionTypesCache || []).find(function(a) { return a.value === sel.value; });
|
|
326
|
+
if (!entry) { hint.textContent = ''; return; }
|
|
327
|
+
var paramHints = (entry.params || []).map(function(p) {
|
|
328
|
+
return p.key + (p.required ? ' (required)' : '?');
|
|
329
|
+
}).join(', ');
|
|
330
|
+
hint.textContent = paramHints ? '— ' + paramHints : '';
|
|
331
|
+
}
|
|
332
|
+
|
|
296
333
|
// Refresh the condition <select> based on the currently selected target type.
|
|
297
334
|
function _updateWatchConditionOptions() {
|
|
298
335
|
var ttSel = document.getElementById('watch-edit-target-type');
|
|
@@ -319,16 +356,27 @@ function openCreateWatchModal() {
|
|
|
319
356
|
_renderCreateWatchModal();
|
|
320
357
|
// Fetch the live registry and re-render so newly registered target types
|
|
321
358
|
// appear without requiring a page reload.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
359
|
+
Promise.all([
|
|
360
|
+
fetch('/api/watches/target-types').then(function(res) { return res.json(); }).catch(function() { return null; }),
|
|
361
|
+
fetch('/api/watches/action-types').then(function(res) { return res.json(); }).catch(function() { return null; }),
|
|
362
|
+
]).then(function(results) {
|
|
363
|
+
var ttData = results[0], atData = results[1];
|
|
364
|
+
var changed = false;
|
|
365
|
+
if (ttData && Array.isArray(ttData.targetTypes) && ttData.targetTypes.length > 0) {
|
|
366
|
+
_watchTargetTypesCache = ttData.targetTypes;
|
|
367
|
+
changed = true;
|
|
368
|
+
}
|
|
369
|
+
if (atData && Array.isArray(atData.actionTypes) && atData.actionTypes.length > 0) {
|
|
370
|
+
_watchActionTypesCache = atData.actionTypes;
|
|
371
|
+
changed = true;
|
|
372
|
+
}
|
|
373
|
+
if (changed) {
|
|
326
374
|
var titleEl = document.getElementById('modal-title');
|
|
327
375
|
if (titleEl && titleEl.textContent.indexOf('Create Watch') === 0) {
|
|
328
376
|
_renderCreateWatchModal();
|
|
329
377
|
}
|
|
330
378
|
}
|
|
331
|
-
})
|
|
379
|
+
});
|
|
332
380
|
}
|
|
333
381
|
|
|
334
382
|
function submitWatch() {
|
|
@@ -341,11 +389,25 @@ function submitWatch() {
|
|
|
341
389
|
var description = (document.getElementById('watch-edit-desc') || {}).value || '';
|
|
342
390
|
var stopAfter = parseInt((document.getElementById('watch-edit-stop-after') || {}).value, 10) || 0;
|
|
343
391
|
var onNotMet = (document.getElementById('watch-edit-on-not-met') || {}).value || '';
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
392
|
+
var actionType = (document.getElementById('watch-edit-action-type') || {}).value || '';
|
|
393
|
+
var actionParamsRaw = (document.getElementById('watch-edit-action-params') || {}).value || '';
|
|
394
|
+
|
|
395
|
+
var errEl = document.getElementById('watch-form-error');
|
|
396
|
+
function showErr(msg) { if (errEl) { errEl.textContent = msg; errEl.style.display = 'block'; } }
|
|
397
|
+
|
|
398
|
+
if (!target.trim()) { showErr('Target is required'); return; }
|
|
399
|
+
|
|
400
|
+
var action = null;
|
|
401
|
+
if (actionType) {
|
|
402
|
+
var params = {};
|
|
403
|
+
if (actionParamsRaw.trim()) {
|
|
404
|
+
try { params = JSON.parse(actionParamsRaw); }
|
|
405
|
+
catch (e) { showErr('Action Params must be valid JSON: ' + e.message); return; }
|
|
406
|
+
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
|
|
407
|
+
showErr('Action Params must be a JSON object'); return;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
action = { type: actionType, params: params };
|
|
349
411
|
}
|
|
350
412
|
|
|
351
413
|
showToast('cmd-toast', 'Creating watch...', true);
|
|
@@ -363,6 +425,7 @@ function submitWatch() {
|
|
|
363
425
|
notify: 'inbox',
|
|
364
426
|
stopAfter: stopAfter,
|
|
365
427
|
onNotMet: onNotMet || null,
|
|
428
|
+
action: action,
|
|
366
429
|
})
|
|
367
430
|
}).then(async function(res) {
|
|
368
431
|
var data = await res.json().catch(function() { return {}; });
|
package/dashboard/pages/prs.html
CHANGED
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openAddPrModal()">+ Link PR</button>
|
|
4
4
|
<span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">created by agents, tracked with review + build status</span>
|
|
5
5
|
</h2>
|
|
6
|
+
<div id="pr-toast" class="cmd-toast cmd-toast-inline" style="margin:6px 0"></div>
|
|
6
7
|
<div id="pr-content"><p class="pr-empty">No pull requests yet.</p></div>
|
|
7
8
|
</section>
|
package/dashboard.js
CHANGED
|
@@ -833,13 +833,16 @@ function buildDashboardHtml() {
|
|
|
833
833
|
const layout = safeRead(layoutPath);
|
|
834
834
|
const css = safeRead(path.join(dashDir, 'styles.css'));
|
|
835
835
|
|
|
836
|
-
// Assemble page fragments
|
|
836
|
+
// Assemble page fragments. Each wrapper gets a `.page-toast` inline slot at
|
|
837
|
+
// the top — showToast('cmd-toast', …) auto-routes here when a page is active,
|
|
838
|
+
// so feedback lands near the action instead of the floating top-right toast.
|
|
837
839
|
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'engine'];
|
|
840
|
+
const pageToast = ' <div class="cmd-toast cmd-toast-inline page-toast" style="margin:6px 0"></div>\n';
|
|
838
841
|
let pageHtml = '';
|
|
839
842
|
for (const p of pages) {
|
|
840
843
|
const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
|
|
841
844
|
const activeClass = p === 'home' ? ' active' : '';
|
|
842
|
-
pageHtml += ` <div class="page${activeClass}" id="page-${p}">\n${content}\n </div>\n\n`;
|
|
845
|
+
pageHtml += ` <div class="page${activeClass}" id="page-${p}">\n${pageToast}${content}\n </div>\n\n`;
|
|
843
846
|
}
|
|
844
847
|
|
|
845
848
|
// Assemble JS modules (order matters: utils → state → renderers → commands → refresh)
|
|
@@ -4221,6 +4224,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
4221
4224
|
}
|
|
4222
4225
|
const swept = safeJson(path.join(ENGINE_DIR, 'kb-swept.json'));
|
|
4223
4226
|
if (swept) result.lastSwept = swept.timestamp;
|
|
4227
|
+
// Surface in-flight sweep state so the UI can render a 'now sweeping (Xm)'
|
|
4228
|
+
// badge alongside the previous-completion 'swept N days ago' indicator.
|
|
4229
|
+
// Memory wins when present, disk fallback survives dashboard restarts.
|
|
4230
|
+
const sweepState = safeJson(path.join(ENGINE_DIR, 'kb-sweep-state.json'));
|
|
4231
|
+
const memInFlight = !!global._kbSweepInFlight;
|
|
4232
|
+
const diskInFlight = !!(sweepState && sweepState.status === 'in-flight');
|
|
4233
|
+
if (memInFlight || diskInFlight) {
|
|
4234
|
+
result.sweepInFlight = true;
|
|
4235
|
+
result.sweepStartedAt = global._kbSweepStartedAt || (sweepState && sweepState.startedAt) || null;
|
|
4236
|
+
}
|
|
4224
4237
|
return jsonReply(res, 200, result);
|
|
4225
4238
|
}
|
|
4226
4239
|
|
|
@@ -4247,8 +4260,23 @@ const server = http.createServer(async (req, res) => {
|
|
|
4247
4260
|
console.log(`[kb-sweep] Auto-releasing stale guard (>${Math.round(guardMs / 60000)}min for ${entryCount} entries)`);
|
|
4248
4261
|
global._kbSweepInFlight = false;
|
|
4249
4262
|
}
|
|
4250
|
-
if
|
|
4251
|
-
|
|
4263
|
+
// Disk-state fallback: if a previous dashboard process died mid-sweep, the
|
|
4264
|
+
// state file says 'in-flight' forever. Treat it as stale past the guard so
|
|
4265
|
+
// a new sweep can start.
|
|
4266
|
+
const sweepStateFile = path.join(ENGINE_DIR, 'kb-sweep-state.json');
|
|
4267
|
+
const diskState = safeJson(sweepStateFile);
|
|
4268
|
+
const diskInFlight = !!(diskState && diskState.status === 'in-flight');
|
|
4269
|
+
const diskStartedAt = diskState && diskState.startedAt ? Number(diskState.startedAt) : 0;
|
|
4270
|
+
const diskStale = diskInFlight && diskStartedAt && Date.now() - diskStartedAt > guardMs;
|
|
4271
|
+
if (diskStale) {
|
|
4272
|
+
console.log(`[kb-sweep] Auto-releasing stale disk-state guard (>${Math.round(guardMs / 60000)}min)`);
|
|
4273
|
+
try { shared.safeUnlink(sweepStateFile); } catch { /* ignore */ }
|
|
4274
|
+
}
|
|
4275
|
+
if (global._kbSweepInFlight || (diskInFlight && !diskStale)) {
|
|
4276
|
+
return jsonReply(res, 200, {
|
|
4277
|
+
ok: true, alreadyRunning: true,
|
|
4278
|
+
startedAt: global._kbSweepStartedAt || diskStartedAt || null,
|
|
4279
|
+
});
|
|
4252
4280
|
}
|
|
4253
4281
|
const sweepToken = Date.now() + Math.random();
|
|
4254
4282
|
global._kbSweepToken = sweepToken;
|
|
@@ -4274,12 +4302,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
4274
4302
|
|
|
4275
4303
|
|
|
4276
4304
|
function handleKnowledgeSweepStatus(req, res) {
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4305
|
+
// Disk-state fallback: when the dashboard restarts mid-sweep the in-memory
|
|
4306
|
+
// globals get reset, but engine/kb-sweep-state.json survives. Memory still
|
|
4307
|
+
// wins when present (faster, no disk read on every poll).
|
|
4308
|
+
const diskState = safeJson(path.join(ENGINE_DIR, 'kb-sweep-state.json'));
|
|
4309
|
+
const memInFlight = !!global._kbSweepInFlight;
|
|
4310
|
+
const diskInFlight = !!(diskState && diskState.status === 'in-flight');
|
|
4311
|
+
const inFlight = memInFlight || diskInFlight;
|
|
4312
|
+
const startedAt = global._kbSweepStartedAt || (diskState && diskState.startedAt) || null;
|
|
4313
|
+
let lastResult = global._kbSweepLastResult || null;
|
|
4314
|
+
let lastCompletedAt = global._kbSweepLastCompletedAt || null;
|
|
4315
|
+
if (!lastResult && diskState && (diskState.status === 'completed' || diskState.status === 'failed')) {
|
|
4316
|
+
if (diskState.status === 'failed') {
|
|
4317
|
+
lastResult = { ok: false, error: diskState.error || 'sweep failed' };
|
|
4318
|
+
} else {
|
|
4319
|
+
lastResult = diskState.lastResult || { ok: true, summary: diskState.summary };
|
|
4320
|
+
}
|
|
4321
|
+
if (!lastCompletedAt && diskState.completedAt) lastCompletedAt = diskState.completedAt;
|
|
4322
|
+
}
|
|
4323
|
+
return jsonReply(res, 200, { inFlight, startedAt, lastResult, lastCompletedAt });
|
|
4283
4324
|
}
|
|
4284
4325
|
|
|
4285
4326
|
async function handlePlansList(req, res) {
|
|
@@ -6305,12 +6346,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6305
6346
|
return jsonReply(res, 200, { targetTypes: watchesMod.listTargetTypes() });
|
|
6306
6347
|
}
|
|
6307
6348
|
|
|
6349
|
+
async function handleWatchesActionTypes(req, res) {
|
|
6350
|
+
return jsonReply(res, 200, { actionTypes: watchesMod.listActionTypes() });
|
|
6351
|
+
}
|
|
6352
|
+
|
|
6308
6353
|
async function handleWatchesCreate(req, res) {
|
|
6309
6354
|
const body = await readBody(req);
|
|
6310
|
-
const { target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet } = body;
|
|
6355
|
+
const { target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action } = body;
|
|
6311
6356
|
if (!target) return jsonReply(res, 400, { error: 'target is required' });
|
|
6312
6357
|
try {
|
|
6313
|
-
const watch = watchesMod.createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet });
|
|
6358
|
+
const watch = watchesMod.createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action });
|
|
6314
6359
|
invalidateStatusCache();
|
|
6315
6360
|
recordCcTurnIfPresent(req, {
|
|
6316
6361
|
kind: 'watch', id: watch?.id || null, title: `Watch ${target}` + (condition ? ` (${condition})` : ''), project: project || null,
|
|
@@ -7318,8 +7363,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7318
7363
|
// Watches
|
|
7319
7364
|
{ method: 'GET', path: '/api/watches', desc: 'List all watches', handler: handleWatchesList },
|
|
7320
7365
|
{ method: 'GET', path: '/api/watches/target-types', desc: 'List registered watch target types and their valid conditions', handler: handleWatchesTargetTypes },
|
|
7321
|
-
{ method: '
|
|
7322
|
-
{ method: 'POST', path: '/api/watches
|
|
7366
|
+
{ method: 'GET', path: '/api/watches/action-types', desc: 'List registered follow-up action types (notify, dispatch-work-item, webhook, ...)', handler: handleWatchesActionTypes },
|
|
7367
|
+
{ method: 'POST', path: '/api/watches', desc: 'Create a new watch', params: 'target, targetType, condition, interval?, owner?, description?, project?, notify?, stopAfter?, onNotMet?, action?', handler: handleWatchesCreate },
|
|
7368
|
+
{ method: 'POST', path: '/api/watches/update', desc: 'Update a watch (pause/resume/modify)', params: 'id, status?, interval?, description?, notify?, stopAfter?, onNotMet?, condition?, action?', handler: handleWatchesUpdate },
|
|
7323
7369
|
{ method: 'POST', path: '/api/watches/delete', desc: 'Delete a watch', params: 'id', handler: handleWatchesDelete },
|
|
7324
7370
|
|
|
7325
7371
|
// Pipelines
|
package/engine/kb-sweep.js
CHANGED
|
@@ -19,6 +19,7 @@ const { MINIONS_DIR, ENGINE_DIR } = queries;
|
|
|
19
19
|
|
|
20
20
|
const KB_DIR = path.join(MINIONS_DIR, 'knowledge');
|
|
21
21
|
const SWEPT_DIR = path.join(KB_DIR, '_swept');
|
|
22
|
+
const KB_SWEEP_STATE_PATH = path.join(ENGINE_DIR, 'kb-sweep-state.json');
|
|
22
23
|
const SWEPT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
23
24
|
const COMPRESS_THRESHOLD_BYTES = 5000;
|
|
24
25
|
const LLM_BATCH_SIZE = 30;
|
|
@@ -277,15 +278,48 @@ function _applyLlmPlan(plan, manifest, opts = {}) {
|
|
|
277
278
|
return { removed, merged, reclassified };
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
function _writeSweepState(state) {
|
|
282
|
+
try { safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify(state)); } catch { /* ignore */ }
|
|
283
|
+
}
|
|
284
|
+
|
|
280
285
|
/**
|
|
281
286
|
* Run the full sweep. Returns a rich summary.
|
|
282
287
|
*
|
|
288
|
+
* Writes `engine/kb-sweep-state.json` at start (`{ status: 'in-flight', startedAt }`)
|
|
289
|
+
* and at completion (`{ status: 'completed' | 'failed', ... }`) so the dashboard
|
|
290
|
+
* can surface in-flight state across process restarts. The file is the persistent
|
|
291
|
+
* fallback signal; in-memory globals in dashboard.js still win when present.
|
|
292
|
+
*
|
|
283
293
|
* @param {object} opts
|
|
284
294
|
* @param {string[]} [opts.pinnedKeys] - extra pinned keys (e.g. from request body)
|
|
285
295
|
* @param {boolean} [opts.dryRun] - count actions but don't mutate files
|
|
286
296
|
* @returns {Promise<object>} summary
|
|
287
297
|
*/
|
|
288
298
|
async function runKbSweep(opts = {}) {
|
|
299
|
+
const dryRun = !!opts.dryRun;
|
|
300
|
+
const startedAt = Date.now();
|
|
301
|
+
if (!dryRun) _writeSweepState({ status: 'in-flight', startedAt, startedAtIso: ts() });
|
|
302
|
+
try {
|
|
303
|
+
const result = await _runKbSweepImpl(opts);
|
|
304
|
+
if (!dryRun) {
|
|
305
|
+
_writeSweepState({
|
|
306
|
+
status: 'completed', startedAt, completedAt: Date.now(), completedAtIso: ts(),
|
|
307
|
+
durationMs: result.durationMs, summary: result.summary, lastResult: result,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return result;
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (!dryRun) {
|
|
313
|
+
_writeSweepState({
|
|
314
|
+
status: 'failed', startedAt, completedAt: Date.now(), completedAtIso: ts(),
|
|
315
|
+
error: e && e.message ? e.message : String(e),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
throw e;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function _runKbSweepImpl(opts = {}) {
|
|
289
323
|
const { callLLM, trackEngineUsage } = require('./llm');
|
|
290
324
|
const summary = {
|
|
291
325
|
ok: true,
|
|
@@ -385,6 +419,7 @@ function staleGuardMs(entryCount) {
|
|
|
385
419
|
module.exports = {
|
|
386
420
|
runKbSweep,
|
|
387
421
|
staleGuardMs,
|
|
422
|
+
KB_SWEEP_STATE_PATH,
|
|
388
423
|
// Exported for tests
|
|
389
424
|
_hashEntry,
|
|
390
425
|
_parseFrontmatter,
|
package/engine/shared.js
CHANGED
|
@@ -1595,6 +1595,21 @@ const WATCH_ABSOLUTE_CONDITIONS = new Set([
|
|
|
1595
1595
|
WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
|
|
1596
1596
|
WATCH_CONDITION.CONCLUDED, WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED,
|
|
1597
1597
|
]);
|
|
1598
|
+
// Built-in follow-up action types invoked by the engine when a watch fires.
|
|
1599
|
+
// The action registry in engine/watch-actions.js is the source of truth; these
|
|
1600
|
+
// constants exist so engine/dashboard code can reference action keys without
|
|
1601
|
+
// magic strings. New action types can be registered at runtime via
|
|
1602
|
+
// registerActionType() — unknown keys here are fine if registered elsewhere.
|
|
1603
|
+
const WATCH_ACTION_TYPE = {
|
|
1604
|
+
NOTIFY: 'notify',
|
|
1605
|
+
DISPATCH_WORK_ITEM: 'dispatch-work-item',
|
|
1606
|
+
RUN_SKILL: 'run-skill',
|
|
1607
|
+
WEBHOOK: 'webhook',
|
|
1608
|
+
CANCEL_WORK_ITEM: 'cancel-work-item',
|
|
1609
|
+
TRIGGER_PIPELINE: 'trigger-pipeline',
|
|
1610
|
+
ARCHIVE_PLAN: 'archive-plan',
|
|
1611
|
+
RESUME_PLAN: 'resume-plan',
|
|
1612
|
+
};
|
|
1598
1613
|
|
|
1599
1614
|
/** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
|
|
1600
1615
|
function trackReviewMetric(pr, newReviewStatus, config) {
|
|
@@ -3499,7 +3514,7 @@ module.exports = {
|
|
|
3499
3514
|
projectWorkSourceWarnings,
|
|
3500
3515
|
backfillProjectWorkSourceDefaults,
|
|
3501
3516
|
WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
|
|
3502
|
-
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS,
|
|
3517
|
+
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
|
|
3503
3518
|
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
3504
3519
|
FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
|
|
3505
3520
|
DEFAULT_AGENT_METRICS,
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/watch-actions.js — Follow-up action registry for watches.
|
|
3
|
+
*
|
|
4
|
+
* When a watch fires, the engine first writes the inbox alert (the existing
|
|
5
|
+
* `notify` field) and then — if the watch carries a structured `action` —
|
|
6
|
+
* looks up the action's handler in this registry and invokes it with the
|
|
7
|
+
* trigger context. Handlers are plain async functions:
|
|
8
|
+
*
|
|
9
|
+
* handler(watch, ctx) => Promise<{ ok, summary, dispatchedItemId? }>
|
|
10
|
+
*
|
|
11
|
+
* Handlers MUST swallow their own errors and return `{ ok: false, summary }`
|
|
12
|
+
* rather than throwing — this keeps a single bad action from poisoning the
|
|
13
|
+
* watches tick (other watches should still fire). The caller in
|
|
14
|
+
* engine/watches.js wraps the invocation in try/catch as a backstop.
|
|
15
|
+
*
|
|
16
|
+
* Templating: action params support {{var}} substitution from `ctx`. Strings
|
|
17
|
+
* and one level of nested string fields are templated recursively. Built-in
|
|
18
|
+
* vars: target, targetType, condition, newState, previousState, plus
|
|
19
|
+
* target-specific helpers (e.g. {{prNumber}}, {{branch}}, {{workItemId}}).
|
|
20
|
+
*
|
|
21
|
+
* Built-in actions: notify, dispatch-work-item, run-skill, webhook,
|
|
22
|
+
* cancel-work-item, trigger-pipeline, archive-plan, resume-plan.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const http = require('http');
|
|
28
|
+
const https = require('https');
|
|
29
|
+
const { URL } = require('url');
|
|
30
|
+
|
|
31
|
+
const shared = require('./shared');
|
|
32
|
+
const {
|
|
33
|
+
WATCH_ACTION_TYPE, WI_STATUS, WORK_TYPE, DONE_STATUSES, PLAN_STATUS,
|
|
34
|
+
log, ts, uid, mutateWorkItems, mutateJsonFileLocked, projectWorkItemsPath,
|
|
35
|
+
} = shared;
|
|
36
|
+
|
|
37
|
+
// ── Action registry ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const ACTION_TYPES = {};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register a follow-up action handler.
|
|
43
|
+
* @param {string} type - Unique action key (e.g. 'webhook', 'my-custom').
|
|
44
|
+
* @param {object} spec - { label, description, params, handler }
|
|
45
|
+
* - label: string shown in dashboard pickers
|
|
46
|
+
* - description: human-readable description
|
|
47
|
+
* - params: array of { key, required, description } for UI/validation hints
|
|
48
|
+
* - handler: async (watch, ctx) => ({ ok, summary, dispatchedItemId? })
|
|
49
|
+
*/
|
|
50
|
+
function registerActionType(type, spec) {
|
|
51
|
+
if (!type || typeof type !== 'string') throw new Error('registerActionType: type must be a non-empty string');
|
|
52
|
+
if (!spec || typeof spec !== 'object') throw new Error('registerActionType: spec must be an object');
|
|
53
|
+
if (typeof spec.handler !== 'function') throw new Error(`registerActionType(${type}): spec.handler must be a function`);
|
|
54
|
+
ACTION_TYPES[type] = {
|
|
55
|
+
label: spec.label || type,
|
|
56
|
+
description: spec.description || '',
|
|
57
|
+
params: Array.isArray(spec.params) ? spec.params : [],
|
|
58
|
+
handler: spec.handler,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Returns the registered spec for an action type, or null. */
|
|
63
|
+
function getActionType(type) { return ACTION_TYPES[type] || null; }
|
|
64
|
+
|
|
65
|
+
/** Returns the registered action types as a serializable array (for /api/watches/action-types). */
|
|
66
|
+
function listActionTypes() {
|
|
67
|
+
return Object.keys(ACTION_TYPES).sort().map(key => ({
|
|
68
|
+
value: key,
|
|
69
|
+
label: ACTION_TYPES[key].label,
|
|
70
|
+
description: ACTION_TYPES[key].description,
|
|
71
|
+
params: ACTION_TYPES[key].params.map(p => ({ ...p })),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Templating ───────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Substitute {{var}} placeholders in `value` with values from `ctx`.
|
|
79
|
+
* Operates on strings; recurses one level into plain objects/arrays so action
|
|
80
|
+
* params like { headers: { 'X-Foo': '{{prNumber}}' } } work transparently.
|
|
81
|
+
* Unknown vars are left as `{{var}}` so callers can detect them downstream.
|
|
82
|
+
*/
|
|
83
|
+
function substituteTemplate(value, ctx) {
|
|
84
|
+
if (value == null) return value;
|
|
85
|
+
if (typeof value === 'string') {
|
|
86
|
+
return value.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (match, key) => {
|
|
87
|
+
if (ctx && Object.prototype.hasOwnProperty.call(ctx, key) && ctx[key] !== undefined && ctx[key] !== null) {
|
|
88
|
+
return String(ctx[key]);
|
|
89
|
+
}
|
|
90
|
+
return match;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(value)) return value.map(v => substituteTemplate(v, ctx));
|
|
94
|
+
if (typeof value === 'object') {
|
|
95
|
+
const out = {};
|
|
96
|
+
for (const k of Object.keys(value)) out[k] = substituteTemplate(value[k], ctx);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build the trigger context passed to action handlers and used for templating.
|
|
104
|
+
* Pulls type-specific extras from the entity captured for the watch (e.g. PR
|
|
105
|
+
* number/branch for pr targets, work item id for work-item targets).
|
|
106
|
+
*/
|
|
107
|
+
function buildTriggerContext(watch, opts = {}) {
|
|
108
|
+
const ctx = {
|
|
109
|
+
target: watch.target,
|
|
110
|
+
targetType: watch.targetType,
|
|
111
|
+
condition: watch.condition,
|
|
112
|
+
watchId: watch.id,
|
|
113
|
+
triggerCount: watch.triggerCount || 0,
|
|
114
|
+
previousState: opts.previousState || {},
|
|
115
|
+
newState: opts.newState || {},
|
|
116
|
+
message: opts.message || '',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Promote previous/new state scalars to top-level template vars for
|
|
120
|
+
// convenience, plus a few well-known target-specific aliases.
|
|
121
|
+
for (const k of Object.keys(ctx.newState || {})) {
|
|
122
|
+
const v = ctx.newState[k];
|
|
123
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') ctx[k] = v;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const entity = opts.entity || null;
|
|
127
|
+
if (entity) {
|
|
128
|
+
if (watch.targetType === 'pr') {
|
|
129
|
+
if (entity.prNumber !== undefined) ctx.prNumber = entity.prNumber;
|
|
130
|
+
if (entity.branch) ctx.branch = entity.branch;
|
|
131
|
+
if (entity.id) ctx.prId = entity.id;
|
|
132
|
+
if (entity.url) ctx.prUrl = entity.url;
|
|
133
|
+
if (entity.project) ctx.project = entity.project;
|
|
134
|
+
} else if (watch.targetType === 'work-item') {
|
|
135
|
+
ctx.workItemId = entity.id;
|
|
136
|
+
if (entity.project) ctx.project = entity.project;
|
|
137
|
+
if (entity.title) ctx.workItemTitle = entity.title;
|
|
138
|
+
} else if (watch.targetType === 'meeting') {
|
|
139
|
+
ctx.meetingId = entity.id;
|
|
140
|
+
if (entity.title) ctx.meetingTitle = entity.title;
|
|
141
|
+
} else if (watch.targetType === 'plan') {
|
|
142
|
+
if (entity._source) ctx.planFile = entity._source;
|
|
143
|
+
if (entity._sourcePlan) ctx.planMd = entity._sourcePlan;
|
|
144
|
+
} else if (watch.targetType === 'pipeline') {
|
|
145
|
+
if (entity._pipelineId) ctx.pipelineId = entity._pipelineId;
|
|
146
|
+
if (entity.runId) ctx.runId = entity.runId;
|
|
147
|
+
} else if (watch.targetType === 'dispatch') {
|
|
148
|
+
if (entity.id) ctx.dispatchId = entity.id;
|
|
149
|
+
} else if (watch.targetType === 'agent') {
|
|
150
|
+
if (entity.id) ctx.agentId = entity.id;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return ctx;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run a watch's configured action — async, error-isolated.
|
|
158
|
+
* Returns a result object the caller persists on `watch._lastActionResult`.
|
|
159
|
+
*/
|
|
160
|
+
async function runWatchAction(watch, ctx) {
|
|
161
|
+
const action = watch && watch.action;
|
|
162
|
+
if (!action || typeof action !== 'object') return { ok: false, summary: 'no action configured', skipped: true };
|
|
163
|
+
const type = String(action.type || '').toLowerCase();
|
|
164
|
+
if (!type) return { ok: false, summary: 'action.type missing' };
|
|
165
|
+
const spec = ACTION_TYPES[type];
|
|
166
|
+
if (!spec) return { ok: false, summary: `unknown action type: ${type}` };
|
|
167
|
+
|
|
168
|
+
const params = substituteTemplate(action.params || {}, ctx);
|
|
169
|
+
try {
|
|
170
|
+
const result = await spec.handler(watch, { ...ctx, params });
|
|
171
|
+
const out = result && typeof result === 'object' ? result : { ok: !!result };
|
|
172
|
+
if (out.ok === undefined) out.ok = true;
|
|
173
|
+
return out;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
log('warn', `watch action ${type} for ${watch.id} threw: ${err.message}`);
|
|
176
|
+
return { ok: false, summary: `${type} threw: ${err.message}` };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Built-in action handlers ─────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
// notify — explicit no-op redundant with the watch.notify field. Useful when a
|
|
183
|
+
// caller wants to set notify=none and still configure the alert via the action
|
|
184
|
+
// surface (e.g. to template the body), or as a placeholder.
|
|
185
|
+
registerActionType(WATCH_ACTION_TYPE.NOTIFY, {
|
|
186
|
+
label: 'Notify (inbox)',
|
|
187
|
+
description: 'Write a notification to the configured owner inbox. Redundant with the legacy `notify` field but useful as an explicit action.',
|
|
188
|
+
params: [
|
|
189
|
+
{ key: 'owner', required: false, description: 'Override owner; defaults to watch.owner.' },
|
|
190
|
+
{ key: 'body', required: false, description: 'Override body; defaults to standard trigger message.' },
|
|
191
|
+
],
|
|
192
|
+
handler: async (watch, ctx) => {
|
|
193
|
+
const owner = ctx.params.owner || watch.owner;
|
|
194
|
+
if (!owner) return { ok: false, summary: 'no owner — skipping notify action' };
|
|
195
|
+
const body = ctx.params.body
|
|
196
|
+
|| `## Watch Triggered: ${watch.description || watch.id}\n\n${ctx.message || ''}\n\nWatch ID: ${watch.id} | Target: ${watch.target} | Condition: ${watch.condition}`;
|
|
197
|
+
const slug = `watch-${watch.id}-action-${watch.triggerCount || 0}-${Date.now()}`;
|
|
198
|
+
shared.writeToInbox(owner, slug, body);
|
|
199
|
+
return { ok: true, summary: `notified ${owner}` };
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// dispatch-work-item — append a new WI to the project (or central) work-items.json.
|
|
204
|
+
registerActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM, {
|
|
205
|
+
label: 'Dispatch Work Item',
|
|
206
|
+
description: 'Create and queue a new work item when the watch fires. Templated params support {{target}}, {{condition}}, {{prNumber}}, {{workItemId}}, etc.',
|
|
207
|
+
params: [
|
|
208
|
+
{ key: 'title', required: true, description: 'Work item title.' },
|
|
209
|
+
{ key: 'description', required: false, description: 'Work item description.' },
|
|
210
|
+
{ key: 'type', required: false, description: 'Work type (implement|fix|review|...). Defaults to implement.' },
|
|
211
|
+
{ key: 'agent', required: false, description: 'Specific agent to route to.' },
|
|
212
|
+
{ key: 'project', required: false, description: 'Target project (defaults to watch.project, or central).' },
|
|
213
|
+
{ key: 'priority', required: false, description: 'low|medium|high. Defaults to medium.' },
|
|
214
|
+
],
|
|
215
|
+
handler: async (watch, ctx) => {
|
|
216
|
+
const p = ctx.params || {};
|
|
217
|
+
const title = String(p.title || '').trim();
|
|
218
|
+
if (!title) return { ok: false, summary: 'dispatch-work-item: title is required' };
|
|
219
|
+
|
|
220
|
+
const project = p.project || watch.project || null;
|
|
221
|
+
const wiPath = project
|
|
222
|
+
? projectWorkItemsPath({ name: project })
|
|
223
|
+
: path.join(shared.MINIONS_DIR, 'work-items.json');
|
|
224
|
+
|
|
225
|
+
const id = 'W-' + uid();
|
|
226
|
+
const item = {
|
|
227
|
+
id,
|
|
228
|
+
title,
|
|
229
|
+
type: p.type || WORK_TYPE.IMPLEMENT,
|
|
230
|
+
priority: p.priority || 'medium',
|
|
231
|
+
description: p.description || '',
|
|
232
|
+
status: WI_STATUS.PENDING,
|
|
233
|
+
created: ts(),
|
|
234
|
+
createdBy: `watch:${watch.id}`,
|
|
235
|
+
};
|
|
236
|
+
if (project) item.project = project;
|
|
237
|
+
if (p.agent) item.agent = String(p.agent);
|
|
238
|
+
|
|
239
|
+
let appended = false;
|
|
240
|
+
mutateWorkItems(wiPath, (items) => {
|
|
241
|
+
items.push(item);
|
|
242
|
+
appended = true;
|
|
243
|
+
});
|
|
244
|
+
if (!appended) return { ok: false, summary: 'dispatch-work-item: write failed' };
|
|
245
|
+
log('info', `Watch ${watch.id} dispatched work item ${id} (${title})`);
|
|
246
|
+
return { ok: true, summary: `dispatched work item ${id}`, dispatchedItemId: id };
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// run-skill — convenience wrapper that dispatches a work item instructing the
|
|
251
|
+
// assigned agent to run a specific .claude skill.
|
|
252
|
+
registerActionType(WATCH_ACTION_TYPE.RUN_SKILL, {
|
|
253
|
+
label: 'Run Skill',
|
|
254
|
+
description: 'Dispatch a work item that asks the assigned agent to run a specific .claude skill with optional args.',
|
|
255
|
+
params: [
|
|
256
|
+
{ key: 'skill', required: true, description: 'Skill name (e.g. "run-tests").' },
|
|
257
|
+
{ key: 'args', required: false, description: 'Args/instructions to pass to the skill.' },
|
|
258
|
+
{ key: 'agent', required: false, description: 'Specific agent to route to.' },
|
|
259
|
+
{ key: 'project', required: false, description: 'Target project.' },
|
|
260
|
+
],
|
|
261
|
+
handler: async (watch, ctx) => {
|
|
262
|
+
const p = ctx.params || {};
|
|
263
|
+
const skill = String(p.skill || '').trim();
|
|
264
|
+
if (!skill) return { ok: false, summary: 'run-skill: skill name is required' };
|
|
265
|
+
const args = p.args ? `\n\nArgs/instructions:\n${p.args}` : '';
|
|
266
|
+
const dispatchSpec = getActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM);
|
|
267
|
+
return dispatchSpec.handler(watch, {
|
|
268
|
+
...ctx,
|
|
269
|
+
params: {
|
|
270
|
+
title: `Run skill: ${skill}`,
|
|
271
|
+
description: `Watch ${watch.id} (target ${watch.target}, condition ${watch.condition}) requested running the \`${skill}\` skill.${args}`,
|
|
272
|
+
type: WORK_TYPE.IMPLEMENT,
|
|
273
|
+
agent: p.agent,
|
|
274
|
+
project: p.project,
|
|
275
|
+
priority: p.priority || 'medium',
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// webhook — POST/GET/PUT to an arbitrary http(s) URL. Bare-bones (no shell exec).
|
|
282
|
+
registerActionType(WATCH_ACTION_TYPE.WEBHOOK, {
|
|
283
|
+
label: 'Webhook',
|
|
284
|
+
description: 'Make an HTTP(S) request to an arbitrary URL when the watch fires. Only http and https schemes are allowed.',
|
|
285
|
+
params: [
|
|
286
|
+
{ key: 'url', required: true, description: 'Full http:// or https:// URL.' },
|
|
287
|
+
{ key: 'method', required: false, description: 'HTTP method (default POST).' },
|
|
288
|
+
{ key: 'body', required: false, description: 'Request body (object → JSON, string → raw).' },
|
|
289
|
+
{ key: 'headers', required: false, description: 'Object map of extra headers.' },
|
|
290
|
+
],
|
|
291
|
+
handler: async (watch, ctx) => {
|
|
292
|
+
const p = ctx.params || {};
|
|
293
|
+
const rawUrl = String(p.url || '').trim();
|
|
294
|
+
if (!rawUrl) return { ok: false, summary: 'webhook: url is required' };
|
|
295
|
+
let parsed;
|
|
296
|
+
try { parsed = new URL(rawUrl); } catch { return { ok: false, summary: `webhook: invalid url ${rawUrl}` }; }
|
|
297
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
298
|
+
return { ok: false, summary: `webhook: only http/https allowed, got ${parsed.protocol}` };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const method = String(p.method || 'POST').toUpperCase();
|
|
302
|
+
let body = null;
|
|
303
|
+
const headers = { 'User-Agent': 'minions-watch/1.0', ...(p.headers || {}) };
|
|
304
|
+
if (p.body !== undefined && p.body !== null && method !== 'GET' && method !== 'HEAD') {
|
|
305
|
+
if (typeof p.body === 'string') {
|
|
306
|
+
body = p.body;
|
|
307
|
+
if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'text/plain';
|
|
308
|
+
} else {
|
|
309
|
+
body = JSON.stringify(p.body);
|
|
310
|
+
if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'application/json';
|
|
311
|
+
}
|
|
312
|
+
headers['Content-Length'] = Buffer.byteLength(body);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
316
|
+
return new Promise((resolve) => {
|
|
317
|
+
const req = lib.request({
|
|
318
|
+
hostname: parsed.hostname,
|
|
319
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
320
|
+
path: parsed.pathname + parsed.search,
|
|
321
|
+
method,
|
|
322
|
+
headers,
|
|
323
|
+
}, (res) => {
|
|
324
|
+
// Drain to free socket; we don't care about the body.
|
|
325
|
+
res.on('data', () => { });
|
|
326
|
+
res.on('end', () => {
|
|
327
|
+
const ok = res.statusCode >= 200 && res.statusCode < 400;
|
|
328
|
+
resolve({ ok, summary: `webhook ${method} ${rawUrl} → ${res.statusCode}`, statusCode: res.statusCode });
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
req.on('error', (err) => {
|
|
332
|
+
resolve({ ok: false, summary: `webhook ${method} ${rawUrl} failed: ${err.message}` });
|
|
333
|
+
});
|
|
334
|
+
// 10s safety timeout — watches must not hang the tick.
|
|
335
|
+
req.setTimeout(10000, () => {
|
|
336
|
+
req.destroy(new Error('timeout'));
|
|
337
|
+
});
|
|
338
|
+
if (body !== null) req.write(body);
|
|
339
|
+
req.end();
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// cancel-work-item — flip a WI to CANCELLED across project + central files.
|
|
345
|
+
registerActionType(WATCH_ACTION_TYPE.CANCEL_WORK_ITEM, {
|
|
346
|
+
label: 'Cancel Work Item',
|
|
347
|
+
description: 'Cancel a work item by ID. Searches the central work-items.json plus all project work-items.json files.',
|
|
348
|
+
params: [
|
|
349
|
+
{ key: 'id', required: true, description: 'Work item ID to cancel.' },
|
|
350
|
+
{ key: 'reason', required: false, description: 'Reason recorded on the item (defaults to "watch:<id>").' },
|
|
351
|
+
],
|
|
352
|
+
handler: async (watch, ctx) => {
|
|
353
|
+
const p = ctx.params || {};
|
|
354
|
+
const targetId = String(p.id || ctx.workItemId || '').trim();
|
|
355
|
+
if (!targetId) return { ok: false, summary: 'cancel-work-item: id is required' };
|
|
356
|
+
|
|
357
|
+
const reason = p.reason || `watch:${watch.id}`;
|
|
358
|
+
const candidates = [path.join(shared.MINIONS_DIR, 'work-items.json')];
|
|
359
|
+
try {
|
|
360
|
+
const config = shared.safeJson(path.join(shared.MINIONS_DIR, 'config.json')) || {};
|
|
361
|
+
for (const proj of (config.projects || [])) {
|
|
362
|
+
if (proj && proj.name) candidates.push(projectWorkItemsPath(proj));
|
|
363
|
+
}
|
|
364
|
+
} catch { /* config optional */ }
|
|
365
|
+
|
|
366
|
+
let cancelled = false;
|
|
367
|
+
for (const wiPath of candidates) {
|
|
368
|
+
if (!fs.existsSync(wiPath)) continue;
|
|
369
|
+
mutateWorkItems(wiPath, (items) => {
|
|
370
|
+
const it = items.find(i => i && i.id === targetId);
|
|
371
|
+
if (!it) return items;
|
|
372
|
+
if (DONE_STATUSES.has(it.status) || it.status === WI_STATUS.CANCELLED) return items;
|
|
373
|
+
it.status = WI_STATUS.CANCELLED;
|
|
374
|
+
it._cancelledBy = reason;
|
|
375
|
+
it.cancelledAt = ts();
|
|
376
|
+
cancelled = true;
|
|
377
|
+
return items;
|
|
378
|
+
});
|
|
379
|
+
if (cancelled) break;
|
|
380
|
+
}
|
|
381
|
+
return cancelled
|
|
382
|
+
? { ok: true, summary: `cancelled work item ${targetId}` }
|
|
383
|
+
: { ok: false, summary: `cancel-work-item: ${targetId} not found or already terminal` };
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// trigger-pipeline — start a new run of a pipeline (no-op if already active).
|
|
388
|
+
registerActionType(WATCH_ACTION_TYPE.TRIGGER_PIPELINE, {
|
|
389
|
+
label: 'Trigger Pipeline',
|
|
390
|
+
description: 'Start a new run of a pipeline. Skipped if the pipeline already has an active run.',
|
|
391
|
+
params: [
|
|
392
|
+
{ key: 'pipelineId', required: true, description: 'Pipeline ID to trigger.' },
|
|
393
|
+
],
|
|
394
|
+
handler: async (watch, ctx) => {
|
|
395
|
+
const p = ctx.params || {};
|
|
396
|
+
const pipelineId = String(p.pipelineId || ctx.pipelineId || '').trim();
|
|
397
|
+
if (!pipelineId) return { ok: false, summary: 'trigger-pipeline: pipelineId is required' };
|
|
398
|
+
let pipelineMod;
|
|
399
|
+
try { pipelineMod = require('./pipeline'); }
|
|
400
|
+
catch (err) { return { ok: false, summary: `trigger-pipeline: pipeline module unavailable (${err.message})` }; }
|
|
401
|
+
const pipeline = pipelineMod.getPipeline(pipelineId);
|
|
402
|
+
if (!pipeline) return { ok: false, summary: `trigger-pipeline: pipeline ${pipelineId} not found` };
|
|
403
|
+
if (pipelineMod.getActiveRun(pipelineId)) {
|
|
404
|
+
return { ok: false, summary: `trigger-pipeline: ${pipelineId} already has an active run` };
|
|
405
|
+
}
|
|
406
|
+
const run = pipelineMod.startRun(pipelineId, pipeline);
|
|
407
|
+
if (!run) return { ok: false, summary: `trigger-pipeline: ${pipelineId} startRun returned null` };
|
|
408
|
+
return { ok: true, summary: `triggered pipeline ${pipelineId} run ${run.runId}`, runId: run.runId };
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// archive-plan — mark the PRD JSON file as archived (status=archived, archivedAt=now).
|
|
413
|
+
registerActionType(WATCH_ACTION_TYPE.ARCHIVE_PLAN, {
|
|
414
|
+
label: 'Archive Plan',
|
|
415
|
+
description: 'Mark a PRD/plan as archived. Sets status="archived" and archivedAt on the PRD JSON.',
|
|
416
|
+
params: [
|
|
417
|
+
{ key: 'planFile', required: false, description: 'PRD .json filename (defaults to {{planFile}} from trigger context).' },
|
|
418
|
+
],
|
|
419
|
+
handler: async (watch, ctx) => {
|
|
420
|
+
const p = ctx.params || {};
|
|
421
|
+
const planFile = String(p.planFile || ctx.planFile || '').trim();
|
|
422
|
+
if (!planFile) return { ok: false, summary: 'archive-plan: planFile is required' };
|
|
423
|
+
const prdPath = path.join(shared.MINIONS_DIR, 'prd', planFile);
|
|
424
|
+
if (!fs.existsSync(prdPath)) return { ok: false, summary: `archive-plan: ${planFile} not found` };
|
|
425
|
+
let updated = false;
|
|
426
|
+
mutateJsonFileLocked(prdPath, (data) => {
|
|
427
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
|
|
428
|
+
data.status = 'archived';
|
|
429
|
+
data.archivedAt = ts();
|
|
430
|
+
updated = true;
|
|
431
|
+
return data;
|
|
432
|
+
}, { defaultValue: {} });
|
|
433
|
+
return updated
|
|
434
|
+
? { ok: true, summary: `archived plan ${planFile}` }
|
|
435
|
+
: { ok: false, summary: `archive-plan: ${planFile} write failed` };
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// resume-plan — flip a paused/awaiting-approval plan to active and clear planStale.
|
|
440
|
+
registerActionType(WATCH_ACTION_TYPE.RESUME_PLAN, {
|
|
441
|
+
label: 'Resume Plan',
|
|
442
|
+
description: 'Resume a paused plan by setting status="active" and clearing the planStale flag.',
|
|
443
|
+
params: [
|
|
444
|
+
{ key: 'planFile', required: false, description: 'PRD .json filename (defaults to {{planFile}}).' },
|
|
445
|
+
],
|
|
446
|
+
handler: async (watch, ctx) => {
|
|
447
|
+
const p = ctx.params || {};
|
|
448
|
+
const planFile = String(p.planFile || ctx.planFile || '').trim();
|
|
449
|
+
if (!planFile) return { ok: false, summary: 'resume-plan: planFile is required' };
|
|
450
|
+
const prdPath = path.join(shared.MINIONS_DIR, 'prd', planFile);
|
|
451
|
+
if (!fs.existsSync(prdPath)) return { ok: false, summary: `resume-plan: ${planFile} not found` };
|
|
452
|
+
let updated = false;
|
|
453
|
+
mutateJsonFileLocked(prdPath, (data) => {
|
|
454
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
|
|
455
|
+
data.status = PLAN_STATUS.ACTIVE;
|
|
456
|
+
delete data.planStale;
|
|
457
|
+
updated = true;
|
|
458
|
+
return data;
|
|
459
|
+
}, { defaultValue: {} });
|
|
460
|
+
return updated
|
|
461
|
+
? { ok: true, summary: `resumed plan ${planFile}` }
|
|
462
|
+
: { ok: false, summary: `resume-plan: ${planFile} write failed` };
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ── Validation ───────────────────────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Validate that a candidate `action` object is a usable shape: type registered,
|
|
470
|
+
* required params present (after templating). Returns null when valid, or an
|
|
471
|
+
* error message string. Used by createWatch/updateWatch.
|
|
472
|
+
*/
|
|
473
|
+
function validateAction(action) {
|
|
474
|
+
if (action === null || action === undefined) return null; // optional field
|
|
475
|
+
if (typeof action !== 'object' || Array.isArray(action)) return 'action must be an object';
|
|
476
|
+
const type = String(action.type || '').toLowerCase();
|
|
477
|
+
if (!type) return 'action.type is required';
|
|
478
|
+
const spec = ACTION_TYPES[type];
|
|
479
|
+
if (!spec) return `unknown action type: ${type} (must be one of: ${Object.keys(ACTION_TYPES).sort().join(', ')})`;
|
|
480
|
+
const params = action.params || {};
|
|
481
|
+
if (typeof params !== 'object' || Array.isArray(params)) return 'action.params must be an object';
|
|
482
|
+
// Required params check is best-effort — values may contain {{templates}}
|
|
483
|
+
// resolved at trigger time, so we only require the key to exist.
|
|
484
|
+
for (const p of spec.params) {
|
|
485
|
+
if (p.required && (params[p.key] === undefined || params[p.key] === null || params[p.key] === '')) {
|
|
486
|
+
return `action.params.${p.key} is required for action type ${type}`;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Extra validation for webhook URL — fail fast on unsupported schemes.
|
|
490
|
+
if (type === WATCH_ACTION_TYPE.WEBHOOK && params.url) {
|
|
491
|
+
const url = String(params.url);
|
|
492
|
+
// Allow templated URLs (containing {{...}}) — they'll be re-validated at run time.
|
|
493
|
+
if (!/\{\{/.test(url)) {
|
|
494
|
+
try {
|
|
495
|
+
const parsed = new URL(url);
|
|
496
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
497
|
+
return `webhook url must use http:// or https://, got ${parsed.protocol}`;
|
|
498
|
+
}
|
|
499
|
+
} catch { return `webhook url is invalid: ${url}`; }
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
module.exports = {
|
|
506
|
+
registerActionType,
|
|
507
|
+
getActionType,
|
|
508
|
+
listActionTypes,
|
|
509
|
+
runWatchAction,
|
|
510
|
+
buildTriggerContext,
|
|
511
|
+
substituteTemplate,
|
|
512
|
+
validateAction,
|
|
513
|
+
_ACTION_TYPES: ACTION_TYPES, // exported for testing
|
|
514
|
+
};
|
package/engine/watches.js
CHANGED
|
@@ -25,6 +25,7 @@ const path = require('path');
|
|
|
25
25
|
const shared = require('./shared');
|
|
26
26
|
const { safeJsonArr, mutateJsonFileLocked, ts, uid, log, writeToInbox,
|
|
27
27
|
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
|
|
28
|
+
const watchActions = require('./watch-actions');
|
|
28
29
|
|
|
29
30
|
// Dynamic path — respects MINIONS_TEST_DIR for test isolation
|
|
30
31
|
function _watchesPath() { return path.join(shared.MINIONS_DIR, 'engine', 'watches.json'); }
|
|
@@ -99,7 +100,7 @@ function getWatches() {
|
|
|
99
100
|
* @param {object} opts - Watch definition
|
|
100
101
|
* @returns {object} - Created watch
|
|
101
102
|
*/
|
|
102
|
-
function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet }) {
|
|
103
|
+
function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action }) {
|
|
103
104
|
if (!target) throw new Error('target is required');
|
|
104
105
|
if (!targetType || !TARGET_TYPES[targetType]) {
|
|
105
106
|
throw new Error(`targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`);
|
|
@@ -108,6 +109,10 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
|
|
|
108
109
|
if (!condition || !tt.conditions.includes(condition)) {
|
|
109
110
|
throw new Error(`condition must be one of: ${tt.conditions.join(', ')} (for targetType ${targetType})`);
|
|
110
111
|
}
|
|
112
|
+
if (action !== undefined && action !== null) {
|
|
113
|
+
const actionErr = watchActions.validateAction(action);
|
|
114
|
+
if (actionErr) throw new Error(actionErr);
|
|
115
|
+
}
|
|
111
116
|
|
|
112
117
|
const watch = {
|
|
113
118
|
id: 'watch-' + uid(),
|
|
@@ -122,6 +127,7 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
|
|
|
122
127
|
notify: notify || 'inbox',
|
|
123
128
|
stopAfter: Number(stopAfter) || 0, // 0 = run forever; N = expire after N triggers
|
|
124
129
|
onNotMet: onNotMet || null, // null | 'notify' — action per poll when condition not met
|
|
130
|
+
action: action || null, // optional follow-up action — see engine/watch-actions.js
|
|
125
131
|
triggerCount: 0,
|
|
126
132
|
created_at: ts(),
|
|
127
133
|
last_checked: null,
|
|
@@ -151,13 +157,17 @@ function updateWatch(id, updates) {
|
|
|
151
157
|
log('warn', `Invalid watch status: ${updates.status}`);
|
|
152
158
|
return null;
|
|
153
159
|
}
|
|
160
|
+
if (updates.action !== undefined && updates.action !== null) {
|
|
161
|
+
const actionErr = watchActions.validateAction(updates.action);
|
|
162
|
+
if (actionErr) throw new Error(actionErr);
|
|
163
|
+
}
|
|
154
164
|
let found = null;
|
|
155
165
|
mutateJsonFileLocked(_watchesPath(), (watches) => {
|
|
156
166
|
if (!Array.isArray(watches)) return watches;
|
|
157
167
|
const watch = watches.find(w => w.id === id);
|
|
158
168
|
if (!watch) return watches;
|
|
159
169
|
// Only allow safe field updates
|
|
160
|
-
const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition'];
|
|
170
|
+
const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition', 'action'];
|
|
161
171
|
for (const key of allowed) {
|
|
162
172
|
if (updates[key] !== undefined) watch[key] = updates[key];
|
|
163
173
|
}
|
|
@@ -217,11 +227,22 @@ function evaluateWatch(watch, state) {
|
|
|
217
227
|
* Check all active watches against current state. Called from engine tick.
|
|
218
228
|
* @param {object} config - Engine config
|
|
219
229
|
* @param {object} state - { pullRequests, workItems } from queries
|
|
230
|
+
*
|
|
231
|
+
* Order of operations when a watch fires:
|
|
232
|
+
* 1. Increment trigger count and update last_triggered (inside the lock).
|
|
233
|
+
* 2. Queue the legacy inbox notification (watch.notify === 'inbox').
|
|
234
|
+
* 3. Queue the configured action (watch.action) for async invocation.
|
|
235
|
+
* 4. Release the lock.
|
|
236
|
+
* 5. Fire notifications first (so the human/agent inbox gets the alert),
|
|
237
|
+
* then fire actions in parallel — failures isolated per action so one
|
|
238
|
+
* bad action doesn't stop the others. Each action's result is written
|
|
239
|
+
* back onto the watch as `_lastActionResult` in a follow-up locked write.
|
|
220
240
|
*/
|
|
221
241
|
function checkWatches(config, state) {
|
|
222
242
|
const now = Date.now();
|
|
223
|
-
// Collect notifications to fire AFTER lock is released — never do I/O inside the lock callback
|
|
243
|
+
// Collect notifications and actions to fire AFTER lock is released — never do I/O inside the lock callback
|
|
224
244
|
const notifications = [];
|
|
245
|
+
const actionsToRun = [];
|
|
225
246
|
|
|
226
247
|
mutateJsonFileLocked(_watchesPath(), (watches) => {
|
|
227
248
|
if (!Array.isArray(watches) || watches.length === 0) return watches;
|
|
@@ -244,11 +265,13 @@ function checkWatches(config, state) {
|
|
|
244
265
|
watch._lastState = _captureState(watch, state);
|
|
245
266
|
}
|
|
246
267
|
|
|
268
|
+
const previousState = { ...watch._lastState };
|
|
247
269
|
const result = evaluateWatch(watch, state);
|
|
248
270
|
if (result.triggered) {
|
|
249
271
|
watch.triggerCount = (watch.triggerCount || 0) + 1;
|
|
250
272
|
watch.last_triggered = ts();
|
|
251
273
|
watch._lastTriggerMessage = result.message;
|
|
274
|
+
const newState = _captureState(watch, state);
|
|
252
275
|
|
|
253
276
|
// Queue trigger notification — unique key per trigger to avoid overwriting previous messages
|
|
254
277
|
if (watch.notify === 'inbox' && watch.owner) {
|
|
@@ -260,6 +283,22 @@ function checkWatches(config, state) {
|
|
|
260
283
|
}
|
|
261
284
|
log('info', `Watch triggered: ${watch.id} — ${result.message}`);
|
|
262
285
|
|
|
286
|
+
// Queue follow-up action for after-lock invocation. We snapshot the
|
|
287
|
+
// watch + entity here so the async handler can run with the same
|
|
288
|
+
// state that triggered the watch even if the live state changes.
|
|
289
|
+
if (watch.action && typeof watch.action === 'object' && watch.action.type) {
|
|
290
|
+
const tt = TARGET_TYPES[watch.targetType];
|
|
291
|
+
const entity = tt ? tt.fetchEntity(watch.target, state || {}) : null;
|
|
292
|
+
actionsToRun.push({
|
|
293
|
+
watchId: watch.id,
|
|
294
|
+
snapshot: JSON.parse(JSON.stringify(watch)),
|
|
295
|
+
previousState,
|
|
296
|
+
newState,
|
|
297
|
+
entity: entity ? JSON.parse(JSON.stringify(entity)) : null,
|
|
298
|
+
message: result.message,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
263
302
|
// Expire when stopAfter > 0 and trigger count reaches the limit.
|
|
264
303
|
// Absolute conditions (merged, build-pass, etc.) auto-expire on first trigger
|
|
265
304
|
// when stopAfter=0 — fire-once semantics. Change-based conditions run forever.
|
|
@@ -296,6 +335,45 @@ function checkWatches(config, state) {
|
|
|
296
335
|
log('warn', `Watch notification error: ${err.message}`);
|
|
297
336
|
}
|
|
298
337
|
}
|
|
338
|
+
|
|
339
|
+
// Fire follow-up actions outside the lock. Each action runs independently —
|
|
340
|
+
// failures in one don't prevent others from firing. Results are best-effort
|
|
341
|
+
// logged and persisted onto the watch as _lastActionResult / _lastActionAt.
|
|
342
|
+
for (const task of actionsToRun) {
|
|
343
|
+
_runActionTask(task).catch(err => log('warn', `Watch action task ${task.watchId} crashed: ${err.message}`));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Internal: invoke a watch's action and persist the result back onto the watch. */
|
|
348
|
+
async function _runActionTask(task) {
|
|
349
|
+
const ctx = watchActions.buildTriggerContext(task.snapshot, {
|
|
350
|
+
previousState: task.previousState,
|
|
351
|
+
newState: task.newState,
|
|
352
|
+
entity: task.entity,
|
|
353
|
+
message: task.message,
|
|
354
|
+
});
|
|
355
|
+
const result = await watchActions.runWatchAction(task.snapshot, ctx);
|
|
356
|
+
log(result.ok ? 'info' : 'warn',
|
|
357
|
+
`Watch ${task.watchId} action ${task.snapshot.action?.type || '?'}: ${result.summary || (result.ok ? 'ok' : 'failed')}`);
|
|
358
|
+
// Persist back onto the watch (best-effort).
|
|
359
|
+
try {
|
|
360
|
+
mutateJsonFileLocked(_watchesPath(), (watches) => {
|
|
361
|
+
if (!Array.isArray(watches)) return watches;
|
|
362
|
+
const w = watches.find(x => x.id === task.watchId);
|
|
363
|
+
if (w) {
|
|
364
|
+
w._lastActionResult = {
|
|
365
|
+
type: task.snapshot.action?.type || null,
|
|
366
|
+
ok: !!result.ok,
|
|
367
|
+
summary: result.summary || '',
|
|
368
|
+
dispatchedItemId: result.dispatchedItemId || null,
|
|
369
|
+
at: ts(),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return watches;
|
|
373
|
+
}, { defaultValue: [] });
|
|
374
|
+
} catch (err) {
|
|
375
|
+
log('warn', `Watch ${task.watchId} action result persist failed: ${err.message}`);
|
|
376
|
+
}
|
|
299
377
|
}
|
|
300
378
|
|
|
301
379
|
/**
|
|
@@ -680,7 +758,16 @@ module.exports = {
|
|
|
680
758
|
registerTargetType,
|
|
681
759
|
getTargetType,
|
|
682
760
|
listTargetTypes,
|
|
761
|
+
// Re-exported from engine/watch-actions for callers that prefer a single
|
|
762
|
+
// import surface (dashboard.js handlers, tests). See engine/watch-actions.js
|
|
763
|
+
// for the canonical implementations and the action registry.
|
|
764
|
+
registerActionType: watchActions.registerActionType,
|
|
765
|
+
getActionType: watchActions.getActionType,
|
|
766
|
+
listActionTypes: watchActions.listActionTypes,
|
|
767
|
+
runWatchAction: watchActions.runWatchAction,
|
|
768
|
+
validateAction: watchActions.validateAction,
|
|
683
769
|
_captureState, // exported for testing
|
|
684
770
|
_watchesPath, // exported for testing — dynamic, respects MINIONS_TEST_DIR
|
|
771
|
+
_runActionTask, // exported for testing — invoked by checkWatches per fired action
|
|
685
772
|
_TARGET_TYPES: TARGET_TYPES, // exported for testing — direct registry access
|
|
686
773
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1859",
|
|
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"
|