@yemi33/minions 0.1.1902 → 0.1.1903
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/dashboard/js/render-watches.js +328 -45
- package/dashboard.js +18 -2
- package/engine/ado.js +64 -0
- package/engine/github.js +68 -0
- package/engine/queries.js +3 -0
- package/engine/safe-expr.js +350 -0
- package/engine/shared.js +40 -0
- package/engine/watch-actions.js +314 -8
- package/engine/watches.js +474 -30
- package/package.json +1 -1
package/engine/watches.js
CHANGED
|
@@ -34,6 +34,13 @@ function _watchesPath() { return path.join(shared.MINIONS_DIR, 'engine', 'watche
|
|
|
34
34
|
// so watches are checked every N ticks where N = ceil(interval / tickInterval).
|
|
35
35
|
const DEFAULT_WATCH_INTERVAL = 300000;
|
|
36
36
|
|
|
37
|
+
// P-w12d8f3a — Phase 6.2: per-watch evaluation history is capped at 25
|
|
38
|
+
// entries (oldest-first). Bounded to keep watches.json compact:
|
|
39
|
+
// 25 entries × ~200 bytes × 100 watches ≈ 500 KB ceiling. History entries
|
|
40
|
+
// never contain full state snapshots — only the trigger reason — per the
|
|
41
|
+
// "history is small and bounded" design principle.
|
|
42
|
+
const WATCH_HISTORY_MAX = 25;
|
|
43
|
+
|
|
37
44
|
/** Find a PR by target (prNumber, canonical ID, or display ID). */
|
|
38
45
|
function findPrByTarget(pullRequests, target) {
|
|
39
46
|
const t = String(target);
|
|
@@ -100,7 +107,7 @@ function getWatches() {
|
|
|
100
107
|
* @param {object} opts - Watch definition
|
|
101
108
|
* @returns {object} - Created watch
|
|
102
109
|
*/
|
|
103
|
-
function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action }) {
|
|
110
|
+
function createWatch({ target, targetType, condition, interval, owner, description, project, notify, stopAfter, onNotMet, action, requires }) {
|
|
104
111
|
if (!target) throw new Error('target is required');
|
|
105
112
|
if (!targetType || !TARGET_TYPES[targetType]) {
|
|
106
113
|
throw new Error(`targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`);
|
|
@@ -113,6 +120,11 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
|
|
|
113
120
|
const actionErr = watchActions.validateAction(action);
|
|
114
121
|
if (actionErr) throw new Error(actionErr);
|
|
115
122
|
}
|
|
123
|
+
// P-w10b6e2d — Phase 5: validate optional requires[] cross-target join.
|
|
124
|
+
if (requires !== undefined && requires !== null) {
|
|
125
|
+
const reqErr = _validateRequires(requires);
|
|
126
|
+
if (reqErr) throw new Error(reqErr);
|
|
127
|
+
}
|
|
116
128
|
|
|
117
129
|
const watch = {
|
|
118
130
|
id: 'watch-' + uid(),
|
|
@@ -128,10 +140,20 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
|
|
|
128
140
|
stopAfter: Number(stopAfter) || 0, // 0 = run forever; N = expire after N triggers
|
|
129
141
|
onNotMet: onNotMet || null, // null | 'notify' — action per poll when condition not met
|
|
130
142
|
action: action || null, // optional follow-up action — see engine/watch-actions.js
|
|
143
|
+
// P-w10b6e2d — Phase 5: optional requires[] — when present every entry
|
|
144
|
+
// must also evaluate truthy for the watch to fire. See evaluateWatch.
|
|
145
|
+
requires: Array.isArray(requires) ? requires.map(r => ({
|
|
146
|
+
target: r.target, targetType: r.targetType, condition: r.condition,
|
|
147
|
+
})) : null,
|
|
131
148
|
triggerCount: 0,
|
|
132
149
|
created_at: ts(),
|
|
133
150
|
last_checked: null,
|
|
134
151
|
last_triggered: null,
|
|
152
|
+
// P-w12d8f3a — Phase 6.2: bounded evaluation history (last
|
|
153
|
+
// WATCH_HISTORY_MAX=25 checks, oldest-first). Appended in checkWatches
|
|
154
|
+
// on every poll (fired AND not-fired). Powers GET /api/watches/:id/history
|
|
155
|
+
// (P-w13c2b6f) without needing a separate file.
|
|
156
|
+
_history: [],
|
|
135
157
|
};
|
|
136
158
|
|
|
137
159
|
mutateJsonFileLocked(_watchesPath(), (watches) => {
|
|
@@ -161,13 +183,19 @@ function updateWatch(id, updates) {
|
|
|
161
183
|
const actionErr = watchActions.validateAction(updates.action);
|
|
162
184
|
if (actionErr) throw new Error(actionErr);
|
|
163
185
|
}
|
|
186
|
+
// P-w10b6e2d — Phase 5: validate requires[] before entering the lock so a
|
|
187
|
+
// bad shape never lands on disk. `[]` is allowed (clears the join).
|
|
188
|
+
if (updates.requires !== undefined && updates.requires !== null) {
|
|
189
|
+
const reqErr = _validateRequires(updates.requires);
|
|
190
|
+
if (reqErr) throw new Error(reqErr);
|
|
191
|
+
}
|
|
164
192
|
let found = null;
|
|
165
193
|
mutateJsonFileLocked(_watchesPath(), (watches) => {
|
|
166
194
|
if (!Array.isArray(watches)) return watches;
|
|
167
195
|
const watch = watches.find(w => w.id === id);
|
|
168
196
|
if (!watch) return watches;
|
|
169
197
|
// Only allow safe field updates
|
|
170
|
-
const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition', 'action'];
|
|
198
|
+
const allowed = ['status', 'interval', 'description', 'notify', 'stopAfter', 'onNotMet', 'condition', 'action', 'requires'];
|
|
171
199
|
for (const key of allowed) {
|
|
172
200
|
if (updates[key] !== undefined) watch[key] = updates[key];
|
|
173
201
|
}
|
|
@@ -204,6 +232,14 @@ function deleteWatch(id) {
|
|
|
204
232
|
* @param {object} watch - The watch object
|
|
205
233
|
* @param {object} state - { pullRequests, workItems, meetings?, plans?, schedules?, scheduleRuns?, pipelineRuns?, dispatch?, agents?, config? }
|
|
206
234
|
* @returns {{ triggered: boolean, message: string }}
|
|
235
|
+
*
|
|
236
|
+
* P-w10b6e2d — Phase 5: when `watch.requires[]` is present, every entry
|
|
237
|
+
* must also evaluate truthy or the watch does not fire. Requirements are
|
|
238
|
+
* evaluated against the same `state` object via a recursive call into
|
|
239
|
+
* evaluateWatch with a synthetic mini-watch (no _lastState). The depth
|
|
240
|
+
* cap is 1 — `_validateRequires` rejects requirement entries that
|
|
241
|
+
* themselves declare a `requires` field at create/update time, so the
|
|
242
|
+
* recursion can never go past one level here.
|
|
207
243
|
*/
|
|
208
244
|
function evaluateWatch(watch, state) {
|
|
209
245
|
const { target, targetType, condition } = watch;
|
|
@@ -215,12 +251,71 @@ function evaluateWatch(watch, state) {
|
|
|
215
251
|
if (!entity) return { triggered: false, message: `${tt.label} ${target} not found` };
|
|
216
252
|
|
|
217
253
|
const prevState = watch._lastState || {};
|
|
254
|
+
let primary;
|
|
218
255
|
try {
|
|
219
|
-
|
|
256
|
+
primary = tt.evaluate(condition, entity, prevState, target) || { triggered: false, message: '' };
|
|
220
257
|
} catch (err) {
|
|
221
258
|
log('warn', `evaluateWatch ${targetType}/${condition}: ${err.message}`);
|
|
222
259
|
return { triggered: false, message: `evaluator error: ${err.message}` };
|
|
223
260
|
}
|
|
261
|
+
// Short-circuit when primary is not truthy — requires only constrain
|
|
262
|
+
// an already-firing primary; an un-fired primary stays un-fired.
|
|
263
|
+
if (!primary.triggered) return primary;
|
|
264
|
+
|
|
265
|
+
// P-w10b6e2d — Phase 5: AND-join with each requires[] entry.
|
|
266
|
+
if (Array.isArray(watch.requires) && watch.requires.length > 0) {
|
|
267
|
+
for (let i = 0; i < watch.requires.length; i++) {
|
|
268
|
+
const req = watch.requires[i];
|
|
269
|
+
// Synthetic mini-watch — no _lastState, no nested requires (depth cap
|
|
270
|
+
// enforced at create/update time). evaluateWatch handles missing
|
|
271
|
+
// entity / unknown type / unknown condition the same way it does
|
|
272
|
+
// for a top-level watch.
|
|
273
|
+
const reqResult = evaluateWatch({
|
|
274
|
+
target: req.target, targetType: req.targetType, condition: req.condition,
|
|
275
|
+
}, state);
|
|
276
|
+
if (!reqResult.triggered) {
|
|
277
|
+
return {
|
|
278
|
+
triggered: false,
|
|
279
|
+
message: `${primary.message || 'primary truthy'} BUT requires[${i}] (${req.targetType}/${req.target}/${req.condition}) not met: ${reqResult.message || 'condition not met'}`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return primary;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Validate a `watch.requires` array. Returns an error message string when
|
|
290
|
+
* invalid, or null when valid. Enforces the Phase 5 contract:
|
|
291
|
+
* - must be an array (use `[]` to clear; null/undefined to omit entirely)
|
|
292
|
+
* - each entry must be a plain object with a non-empty target, a
|
|
293
|
+
* targetType registered in TARGET_TYPES, and a condition listed in
|
|
294
|
+
* that target type's `conditions` array
|
|
295
|
+
* - depth cap of 1: requirement entries MUST NOT themselves declare a
|
|
296
|
+
* `requires` field (otherwise we'd need cycle detection)
|
|
297
|
+
*/
|
|
298
|
+
function _validateRequires(requires) {
|
|
299
|
+
if (!Array.isArray(requires)) return 'requires must be an array of {target, targetType, condition}';
|
|
300
|
+
for (let i = 0; i < requires.length; i++) {
|
|
301
|
+
const r = requires[i];
|
|
302
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) {
|
|
303
|
+
return `requires[${i}] must be a {target, targetType, condition} object`;
|
|
304
|
+
}
|
|
305
|
+
if (!r.target || typeof r.target !== 'string') {
|
|
306
|
+
return `requires[${i}].target is required (non-empty string)`;
|
|
307
|
+
}
|
|
308
|
+
if (!r.targetType || !TARGET_TYPES[r.targetType]) {
|
|
309
|
+
return `requires[${i}].targetType must be one of: ${Object.keys(TARGET_TYPES).sort().join(', ')}`;
|
|
310
|
+
}
|
|
311
|
+
if (!r.condition || !TARGET_TYPES[r.targetType].conditions.includes(r.condition)) {
|
|
312
|
+
return `requires[${i}].condition must be one of: ${TARGET_TYPES[r.targetType].conditions.join(', ')} (for targetType ${r.targetType})`;
|
|
313
|
+
}
|
|
314
|
+
if (r.requires !== undefined) {
|
|
315
|
+
return `requires[${i}] must not contain a nested 'requires' field (depth cap is 1)`;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
224
319
|
}
|
|
225
320
|
|
|
226
321
|
/**
|
|
@@ -273,25 +368,48 @@ function checkWatches(config, state) {
|
|
|
273
368
|
watch._lastTriggerMessage = result.message;
|
|
274
369
|
const newState = _captureState(watch, state);
|
|
275
370
|
|
|
276
|
-
// Queue trigger notification — unique key per trigger to avoid overwriting previous messages
|
|
277
|
-
if (watch.notify === 'inbox' && watch.owner) {
|
|
278
|
-
notifications.push({
|
|
279
|
-
type: 'trigger', owner: watch.owner,
|
|
280
|
-
slug: `watch-${watch.id}-${watch.triggerCount}`,
|
|
281
|
-
body: `## Watch Triggered: ${watch.description}\n\n${result.message}\n\nWatch ID: ${watch.id} | Target: ${watch.target} | Condition: ${watch.condition}`,
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
371
|
log('info', `Watch triggered: ${watch.id} — ${result.message}`);
|
|
285
372
|
|
|
286
373
|
// Queue follow-up action for after-lock invocation. We snapshot the
|
|
287
374
|
// watch + entity here so the async handler can run with the same
|
|
288
375
|
// state that triggered the watch even if the live state changes.
|
|
289
|
-
|
|
376
|
+
//
|
|
377
|
+
// P-w8e9b1f4 — Phase 4: action may be either a single object
|
|
378
|
+
// (`{type, ...}`) or an array-form chain (`[{type,...}, ...]`).
|
|
379
|
+
//
|
|
380
|
+
// P-w11a4c9e — Phase 6.1: NOTIFY is no longer a special case.
|
|
381
|
+
// When `watch.notify === 'inbox'` (and the watch has an owner),
|
|
382
|
+
// an implicit `{type:'notify'}` step is prepended to the chain
|
|
383
|
+
// here so the legacy inbox alert and any explicit follow-up
|
|
384
|
+
// action both run through the registered action surface.
|
|
385
|
+
//
|
|
386
|
+
// When there is NO implicit notify to prepend, the original
|
|
387
|
+
// action shape is preserved (single-object stays single-object,
|
|
388
|
+
// array stays array) so back-compat _lastActionResult shape
|
|
389
|
+
// (`type: 'webhook'`, `dispatchedItemId`, …) is unchanged.
|
|
390
|
+
const _explicitAction = watch.action;
|
|
391
|
+
const _wantImplicitNotify = (watch.notify === 'inbox' && watch.owner);
|
|
392
|
+
const _hasExplicitArray = Array.isArray(_explicitAction) && _explicitAction.length > 0;
|
|
393
|
+
const _hasExplicitObject = _explicitAction && typeof _explicitAction === 'object'
|
|
394
|
+
&& !Array.isArray(_explicitAction) && _explicitAction.type;
|
|
395
|
+
let _effectiveAction = null;
|
|
396
|
+
if (_wantImplicitNotify && _hasExplicitArray) {
|
|
397
|
+
_effectiveAction = [{ type: 'notify' }].concat(_explicitAction);
|
|
398
|
+
} else if (_wantImplicitNotify && _hasExplicitObject) {
|
|
399
|
+
_effectiveAction = [{ type: 'notify' }, _explicitAction];
|
|
400
|
+
} else if (_wantImplicitNotify) {
|
|
401
|
+
_effectiveAction = [{ type: 'notify' }];
|
|
402
|
+
} else if (_hasExplicitArray || _hasExplicitObject) {
|
|
403
|
+
_effectiveAction = _explicitAction;
|
|
404
|
+
}
|
|
405
|
+
if (_effectiveAction) {
|
|
290
406
|
const tt = TARGET_TYPES[watch.targetType];
|
|
291
407
|
const entity = tt ? tt.fetchEntity(watch.target, state || {}) : null;
|
|
408
|
+
const snapshot = JSON.parse(JSON.stringify(watch));
|
|
409
|
+
snapshot.action = _effectiveAction;
|
|
292
410
|
actionsToRun.push({
|
|
293
411
|
watchId: watch.id,
|
|
294
|
-
snapshot
|
|
412
|
+
snapshot,
|
|
295
413
|
previousState,
|
|
296
414
|
newState,
|
|
297
415
|
entity: entity ? JSON.parse(JSON.stringify(entity)) : null,
|
|
@@ -319,6 +437,21 @@ function checkWatches(config, state) {
|
|
|
319
437
|
|
|
320
438
|
// Capture state for change detection on next check
|
|
321
439
|
watch._lastState = _captureState(watch, state);
|
|
440
|
+
|
|
441
|
+
// P-w12d8f3a — Phase 6.2: append a bounded evaluation history
|
|
442
|
+
// entry on EVERY check (fired AND not-fired). Trim oldest first
|
|
443
|
+
// when the buffer exceeds WATCH_HISTORY_MAX. action_results is
|
|
444
|
+
// populated post-lock by _runActionTask once the action runs.
|
|
445
|
+
if (!Array.isArray(watch._history)) watch._history = [];
|
|
446
|
+
watch._history.push({
|
|
447
|
+
checked_at: watch.last_checked,
|
|
448
|
+
fired: !!result.triggered,
|
|
449
|
+
reason: result.message || '',
|
|
450
|
+
condition: watch.condition,
|
|
451
|
+
});
|
|
452
|
+
if (watch._history.length > WATCH_HISTORY_MAX) {
|
|
453
|
+
watch._history.splice(0, watch._history.length - WATCH_HISTORY_MAX);
|
|
454
|
+
}
|
|
322
455
|
} catch (err) {
|
|
323
456
|
log('warn', `Watch check error (${watch.id}): ${err.message}`);
|
|
324
457
|
}
|
|
@@ -353,21 +486,64 @@ async function _runActionTask(task) {
|
|
|
353
486
|
message: task.message,
|
|
354
487
|
});
|
|
355
488
|
const result = await watchActions.runWatchAction(task.snapshot, ctx);
|
|
489
|
+
const isChain = Array.isArray(task.snapshot.action);
|
|
490
|
+
const actionLabel = isChain
|
|
491
|
+
? `chain[${task.snapshot.action.length}]`
|
|
492
|
+
: (task.snapshot.action?.type || '?');
|
|
356
493
|
log(result.ok ? 'info' : 'warn',
|
|
357
|
-
`Watch ${task.watchId} action ${
|
|
494
|
+
`Watch ${task.watchId} action ${actionLabel}: ${result.summary || (result.ok ? 'ok' : 'failed')}`);
|
|
358
495
|
// Persist back onto the watch (best-effort).
|
|
496
|
+
// P-w8e9b1f4 — Phase 4: array-form actions persist a `steps[]` array
|
|
497
|
+
// alongside the aggregate {ok, summary} so dashboards / downstream readers
|
|
498
|
+
// can inspect each step's result. Single-action watches keep the legacy
|
|
499
|
+
// `dispatchedItemId` shortcut for back-compat.
|
|
359
500
|
try {
|
|
360
501
|
mutateJsonFileLocked(_watchesPath(), (watches) => {
|
|
361
502
|
if (!Array.isArray(watches)) return watches;
|
|
362
503
|
const w = watches.find(x => x.id === task.watchId);
|
|
363
504
|
if (w) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
505
|
+
if (isChain) {
|
|
506
|
+
w._lastActionResult = {
|
|
507
|
+
type: 'chain',
|
|
508
|
+
ok: !!result.ok,
|
|
509
|
+
summary: result.summary || '',
|
|
510
|
+
aborted: !!result.aborted,
|
|
511
|
+
steps: Array.isArray(result.steps) ? result.steps.map(s => ({
|
|
512
|
+
type: s.type || null,
|
|
513
|
+
ok: !!s.ok,
|
|
514
|
+
skipped: !!s.skipped,
|
|
515
|
+
summary: s.summary || '',
|
|
516
|
+
dispatchedItemId: (s.result && s.result.dispatchedItemId) || null,
|
|
517
|
+
})) : [],
|
|
518
|
+
at: ts(),
|
|
519
|
+
};
|
|
520
|
+
} else {
|
|
521
|
+
w._lastActionResult = {
|
|
522
|
+
type: task.snapshot.action?.type || null,
|
|
523
|
+
ok: !!result.ok,
|
|
524
|
+
summary: result.summary || '',
|
|
525
|
+
dispatchedItemId: result.dispatchedItemId || null,
|
|
526
|
+
at: ts(),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// P-w12d8f3a — Phase 6.2: backfill action_results onto the most
|
|
531
|
+
// recent (fired) history entry. The history append already
|
|
532
|
+
// happened inside the checkWatches lock; here we attach a small
|
|
533
|
+
// bounded summary so the GET /api/watches/:id/history surface
|
|
534
|
+
// (P-w13c2b6f) can show whether the action succeeded. Step-level
|
|
535
|
+
// detail stays on _lastActionResult — history keeps only the
|
|
536
|
+
// aggregate to honor the "small + bounded" contract.
|
|
537
|
+
if (Array.isArray(w._history) && w._history.length > 0) {
|
|
538
|
+
const last = w._history[w._history.length - 1];
|
|
539
|
+
if (last && last.fired) {
|
|
540
|
+
last.action_results = {
|
|
541
|
+
type: w._lastActionResult.type,
|
|
542
|
+
ok: w._lastActionResult.ok,
|
|
543
|
+
summary: w._lastActionResult.summary || '',
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
}
|
|
371
547
|
}
|
|
372
548
|
return watches;
|
|
373
549
|
}, { defaultValue: [] });
|
|
@@ -386,7 +562,11 @@ function _captureState(watch, state) {
|
|
|
386
562
|
const entity = tt.fetchEntity(watch.target, state || {});
|
|
387
563
|
if (!entity) return {};
|
|
388
564
|
try {
|
|
389
|
-
|
|
565
|
+
// P-w5b8d2c9 — Phase 2.2: pass prevState so captureState can carry
|
|
566
|
+
// forward unchanged-tick counters (e.g. _unchangedTicks for work-item
|
|
567
|
+
// stalled, _stuckStageTicks for pipeline stuck-in-stage). Existing
|
|
568
|
+
// captureState fns that take only 1 arg ignore this — backward-compat.
|
|
569
|
+
return tt.captureState(entity, watch._lastState || {}) || {};
|
|
390
570
|
} catch (err) {
|
|
391
571
|
log('warn', `_captureState ${watch.targetType}: ${err.message}`);
|
|
392
572
|
return {};
|
|
@@ -403,11 +583,28 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
|
|
|
403
583
|
WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
|
|
404
584
|
WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
|
|
405
585
|
WATCH_CONDITION.NEW_COMMENTS, WATCH_CONDITION.VOTE_CHANGE,
|
|
586
|
+
// P-w4e2f6a1 — Phase 2.1: head/mergeability/draft/behind predicates.
|
|
587
|
+
// Backed by Phase 1.1 captureState plumbing (headRefOid, mergeable,
|
|
588
|
+
// isDraft, mergeStateStatus) and Phase 1.3 behindBy (read at fire-time
|
|
589
|
+
// so this list is safe to advertise even before behindBy lands).
|
|
590
|
+
WATCH_CONDITION.HEAD_COMMIT_CHANGE, WATCH_CONDITION.MERGEABLE_FLIPPED,
|
|
591
|
+
WATCH_CONDITION.READY_FOR_MERGE, WATCH_CONDITION.BEHIND_MASTER,
|
|
592
|
+
WATCH_CONDITION.DRAFT_FLIPPED,
|
|
406
593
|
],
|
|
407
594
|
fetchEntity: (target, state) => findPrByTarget(state.pullRequests, target),
|
|
408
595
|
captureState: (pr) => ({
|
|
409
596
|
status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus,
|
|
410
597
|
lastCommentDate: pr.humanFeedback?.lastProcessedCommentDate || null,
|
|
598
|
+
// P-w1a3f9b2 — Phase 1.1: plumb head-commit / mergeability / draft / merge-state
|
|
599
|
+
// so future predicates (Phase 2.1: head-commit-change, mergeable-flipped,
|
|
600
|
+
// ready-for-merge, draft-flipped) can read them. Values are passed through
|
|
601
|
+
// verbatim — including `undefined` for legacy PR objects that haven't been
|
|
602
|
+
// re-polled yet — so symmetric prevState/newState comparisons stay false on
|
|
603
|
+
// the first post-upgrade tick instead of synthesizing a phantom change.
|
|
604
|
+
headRefOid: pr.headRefOid,
|
|
605
|
+
mergeable: pr.mergeable,
|
|
606
|
+
isDraft: pr.isDraft,
|
|
607
|
+
mergeStateStatus: pr.mergeStateStatus,
|
|
411
608
|
}),
|
|
412
609
|
evaluate: (condition, pr, prevState, target) => {
|
|
413
610
|
switch (condition) {
|
|
@@ -439,6 +636,59 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
|
|
|
439
636
|
const changed = prevState.reviewStatus !== undefined && prevState.reviewStatus !== pr.reviewStatus;
|
|
440
637
|
return { triggered: changed, message: changed ? `PR ${target} vote changed: ${prevState.reviewStatus} → ${pr.reviewStatus}` : '' };
|
|
441
638
|
}
|
|
639
|
+
// ── P-w4e2f6a1 — Phase 2.1 PR predicates ──────────────────────────
|
|
640
|
+
case WATCH_CONDITION.HEAD_COMMIT_CHANGE: {
|
|
641
|
+
// Fire only when both sides are defined, non-null SHAs that differ.
|
|
642
|
+
// Suppresses post-upgrade phantom triggers when prevState is missing
|
|
643
|
+
// headRefOid (legacy `_lastState`) or the poller hasn't populated it
|
|
644
|
+
// yet on this tick.
|
|
645
|
+
const prev = prevState.headRefOid;
|
|
646
|
+
const cur = pr.headRefOid;
|
|
647
|
+
const changed = !!(prev && cur && prev !== cur);
|
|
648
|
+
return { triggered: changed, message: changed ? `PR ${target} head commit changed: ${prev} → ${cur}` : '' };
|
|
649
|
+
}
|
|
650
|
+
case WATCH_CONDITION.MERGEABLE_FLIPPED: {
|
|
651
|
+
// engine/github.js:902-923 documents that mergeable=null means
|
|
652
|
+
// "GitHub still computing" — NOT a positive signal. Only fire on
|
|
653
|
+
// transitions between defined boolean values (true↔false). Any
|
|
654
|
+
// transition involving null or undefined is ignored.
|
|
655
|
+
const prev = prevState.mergeable;
|
|
656
|
+
const cur = pr.mergeable;
|
|
657
|
+
const isBool = (v) => v === true || v === false;
|
|
658
|
+
const flipped = isBool(prev) && isBool(cur) && prev !== cur;
|
|
659
|
+
return { triggered: flipped, message: flipped ? `PR ${target} mergeable flipped: ${prev} → ${cur}` : '' };
|
|
660
|
+
}
|
|
661
|
+
case WATCH_CONDITION.READY_FOR_MERGE: {
|
|
662
|
+
// Compound predicate — fires when ALL gating signals line up.
|
|
663
|
+
// Registered as an absolute condition (engine/shared.js
|
|
664
|
+
// WATCH_ABSOLUTE_CONDITIONS) so it auto-expires on first trigger
|
|
665
|
+
// when stopAfter=0. Treat isDraft !== true (catches null/undefined
|
|
666
|
+
// legacy PRs that don't expose the field).
|
|
667
|
+
const ready = pr.status === 'active'
|
|
668
|
+
&& pr.reviewStatus === 'approved'
|
|
669
|
+
&& pr.buildStatus === 'passing'
|
|
670
|
+
&& pr.mergeable === true
|
|
671
|
+
&& pr.isDraft !== true;
|
|
672
|
+
return { triggered: ready, message: ready ? `PR ${target} is ready for merge` : '' };
|
|
673
|
+
}
|
|
674
|
+
case WATCH_CONDITION.BEHIND_MASTER: {
|
|
675
|
+
// Phase 1.3 will plumb pr.behindBy. Until then, this stays dormant
|
|
676
|
+
// (typeof check rejects null/undefined). Treat null as not-behind
|
|
677
|
+
// per the PRD spec — only a numeric > 0 fires.
|
|
678
|
+
const b = pr.behindBy;
|
|
679
|
+
const behind = typeof b === 'number' && b > 0;
|
|
680
|
+
return { triggered: behind, message: behind ? `PR ${target} is behind master by ${b} commit${b === 1 ? '' : 's'}` : '' };
|
|
681
|
+
}
|
|
682
|
+
case WATCH_CONDITION.DRAFT_FLIPPED: {
|
|
683
|
+
// Symmetric to mergeable-flipped: only fire on true↔false transitions
|
|
684
|
+
// between defined boolean values. Suppresses post-upgrade phantom
|
|
685
|
+
// triggers when isDraft was previously undefined.
|
|
686
|
+
const prev = prevState.isDraft;
|
|
687
|
+
const cur = pr.isDraft;
|
|
688
|
+
const isBool = (v) => v === true || v === false;
|
|
689
|
+
const flipped = isBool(prev) && isBool(cur) && prev !== cur;
|
|
690
|
+
return { triggered: flipped, message: flipped ? `PR ${target} draft flipped: ${prev} → ${cur}` : '' };
|
|
691
|
+
}
|
|
442
692
|
default:
|
|
443
693
|
return { triggered: false, message: `Unknown condition: ${condition}` };
|
|
444
694
|
}
|
|
@@ -452,9 +702,36 @@ registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
|
|
|
452
702
|
conditions: [
|
|
453
703
|
WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
|
|
454
704
|
WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
|
|
705
|
+
// P-w5b8d2c9 — Phase 2.2: stalled / retry-limit / dependency predicates.
|
|
706
|
+
// Backed by Phase 1.2 captureState plumbing (retries, _pendingReason)
|
|
707
|
+
// plus a Phase 2.2 _unchangedTicks counter computed inside captureState.
|
|
708
|
+
WATCH_CONDITION.STALLED, WATCH_CONDITION.RETRY_LIMIT_REACHED,
|
|
709
|
+
WATCH_CONDITION.DEPENDENCY_MET,
|
|
455
710
|
],
|
|
456
711
|
fetchEntity: (target, state) => (state.workItems || []).find(w => w.id === target) || null,
|
|
457
|
-
|
|
712
|
+
// P-w2c8d1e7 — Phase 1.2: surface retry count, pending-reason, and branch
|
|
713
|
+
// for upcoming stalled / branch-aware predicates. `retries` aliases the
|
|
714
|
+
// private `_retryCount` so guards can read a stable field name.
|
|
715
|
+
// P-w5b8d2c9 — Phase 2.2: also carry forward `_unchangedTicks`, an
|
|
716
|
+
// unbounded counter incremented each tick that the observable fields
|
|
717
|
+
// (status / retries / _pendingReason / branch) all match prevState. Reset
|
|
718
|
+
// to 0 on any change. Drives the `stalled` predicate.
|
|
719
|
+
captureState: (wi, prev) => {
|
|
720
|
+
const status = wi.status;
|
|
721
|
+
const retries = wi._retryCount || 0;
|
|
722
|
+
const pendingReason = wi._pendingReason || null;
|
|
723
|
+
const branch = wi.branch || null;
|
|
724
|
+
const unchanged = !!prev
|
|
725
|
+
&& prev.status === status
|
|
726
|
+
&& (prev.retries || 0) === retries
|
|
727
|
+
&& (prev._pendingReason || null) === pendingReason
|
|
728
|
+
&& (prev.branch || null) === branch;
|
|
729
|
+
const unchangedTicks = unchanged ? ((prev._unchangedTicks || 0) + 1) : 0;
|
|
730
|
+
return {
|
|
731
|
+
status, retries, _pendingReason: pendingReason, branch,
|
|
732
|
+
_unchangedTicks: unchangedTicks,
|
|
733
|
+
};
|
|
734
|
+
},
|
|
458
735
|
evaluate: (condition, wi, prevState, target) => {
|
|
459
736
|
switch (condition) {
|
|
460
737
|
case WATCH_CONDITION.COMPLETED: {
|
|
@@ -473,6 +750,41 @@ registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
|
|
|
473
750
|
const anyChanged = prevState.status !== undefined && prevState.status !== wi.status;
|
|
474
751
|
return { triggered: anyChanged, message: anyChanged ? `Work item ${target} changed (${wi.status})` : '' };
|
|
475
752
|
}
|
|
753
|
+
// ── P-w5b8d2c9 — Phase 2.2 work-item predicates ──────────────────
|
|
754
|
+
case WATCH_CONDITION.STALLED: {
|
|
755
|
+
// Suppress on terminal items — a done/failed work item is not
|
|
756
|
+
// "stalled", it's finished. Otherwise fire when the carried-forward
|
|
757
|
+
// _unchangedTicks counter has crossed the threshold.
|
|
758
|
+
const isTerminal = shared.DONE_STATUSES.has(wi.status)
|
|
759
|
+
|| wi.status === shared.WI_STATUS.FAILED
|
|
760
|
+
|| wi.status === shared.WI_STATUS.CANCELLED;
|
|
761
|
+
if (isTerminal) return { triggered: false, message: '' };
|
|
762
|
+
const ticks = prevState._unchangedTicks || 0;
|
|
763
|
+
const threshold = shared.WATCH_STALLED_DEFAULT_TICKS;
|
|
764
|
+
const stalled = ticks >= threshold;
|
|
765
|
+
return { triggered: stalled, message: stalled ? `Work item ${target} stalled (${wi.status}, no change for ${ticks} checks)` : '' };
|
|
766
|
+
}
|
|
767
|
+
case WATCH_CONDITION.RETRY_LIMIT_REACHED: {
|
|
768
|
+
// Compound state assertion — registered as absolute so it auto-expires
|
|
769
|
+
// after the first fire when stopAfter=0 (otherwise it would re-fire
|
|
770
|
+
// every tick while the work item sits at maxRetries).
|
|
771
|
+
const retries = wi._retryCount || 0;
|
|
772
|
+
const limit = shared.ENGINE_DEFAULTS.maxRetries;
|
|
773
|
+
const reached = retries >= limit;
|
|
774
|
+
return { triggered: reached, message: reached ? `Work item ${target} hit retry limit (${retries} >= ${limit})` : '' };
|
|
775
|
+
}
|
|
776
|
+
case WATCH_CONDITION.DEPENDENCY_MET: {
|
|
777
|
+
// Transition predicate: prevState had _pendingReason='dependency_unmet'
|
|
778
|
+
// and current does not. Restrict to pending/dispatched statuses so
|
|
779
|
+
// we don't confuse "dependency met" with "item terminated for other
|
|
780
|
+
// reasons" (e.g. cancelled while waiting on a dep).
|
|
781
|
+
const wasUnmet = prevState._pendingReason === 'dependency_unmet';
|
|
782
|
+
const nowMet = (wi._pendingReason || null) !== 'dependency_unmet';
|
|
783
|
+
const validStatus = wi.status === shared.WI_STATUS.PENDING
|
|
784
|
+
|| wi.status === shared.WI_STATUS.DISPATCHED;
|
|
785
|
+
const fired = wasUnmet && nowMet && validStatus;
|
|
786
|
+
return { triggered: fired, message: fired ? `Work item ${target} dependency met (status=${wi.status}, reason=${wi._pendingReason || 'none'})` : '' };
|
|
787
|
+
}
|
|
476
788
|
default:
|
|
477
789
|
return { triggered: false, message: `Unknown condition: ${condition}` };
|
|
478
790
|
}
|
|
@@ -518,15 +830,56 @@ function _findPlan(target, state) {
|
|
|
518
830
|
return false;
|
|
519
831
|
}) || null;
|
|
520
832
|
}
|
|
833
|
+
// P-w2c8d1e7 — Phase 1.2: derive item progress from the PRD's
|
|
834
|
+
// missing_features array. Returns 0 when the array is missing/empty so
|
|
835
|
+
// guards can compare numerically without null-checks.
|
|
836
|
+
function _planItemsTotal(p) {
|
|
837
|
+
return Array.isArray(p?.missing_features) ? p.missing_features.length : 0;
|
|
838
|
+
}
|
|
839
|
+
function _planItemsDone(p) {
|
|
840
|
+
if (!Array.isArray(p?.missing_features)) return 0;
|
|
841
|
+
let n = 0;
|
|
842
|
+
for (const item of p.missing_features) {
|
|
843
|
+
if (item && shared.DONE_STATUSES.has(item.status)) n += 1;
|
|
844
|
+
}
|
|
845
|
+
return n;
|
|
846
|
+
}
|
|
847
|
+
// P-w5b8d2c9 — Phase 2.2: max retry count across plan items, drives the
|
|
848
|
+
// `item-failed-n-times` predicate. Treats null/undefined retries as 0 so
|
|
849
|
+
// the predicate stays dormant for plans that have no retry data.
|
|
850
|
+
function _planMaxItemRetries(p) {
|
|
851
|
+
if (!Array.isArray(p?.missing_features)) return 0;
|
|
852
|
+
let max = 0;
|
|
853
|
+
for (const item of p.missing_features) {
|
|
854
|
+
const rc = (item && item._retryCount) || 0;
|
|
855
|
+
if (rc > max) max = rc;
|
|
856
|
+
}
|
|
857
|
+
return max;
|
|
858
|
+
}
|
|
521
859
|
registerTargetType(WATCH_TARGET_TYPE.PLAN, {
|
|
522
860
|
label: 'Plan / PRD',
|
|
523
861
|
description: 'Watch a plan/PRD for approval, rejection, completion, or status changes',
|
|
524
862
|
conditions: [
|
|
525
863
|
WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED, WATCH_CONDITION.COMPLETED,
|
|
526
864
|
WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
|
|
865
|
+
// P-w5b8d2c9 — Phase 2.2: progress / failure-rate predicates derived
|
|
866
|
+
// from the PRD's missing_features array.
|
|
867
|
+
WATCH_CONDITION.ALL_ITEMS_DONE, WATCH_CONDITION.ITEM_FAILED_N_TIMES,
|
|
527
868
|
],
|
|
528
869
|
fetchEntity: _findPlan,
|
|
529
|
-
|
|
870
|
+
// P-w2c8d1e7 — Phase 1.2: surface item progress derived from the PRD's
|
|
871
|
+
// missing_features array so plan-level predicates (e.g. all-items-done)
|
|
872
|
+
// can read it directly from _lastState.
|
|
873
|
+
// P-w5b8d2c9 — Phase 2.2: also surface max_item_retries for the
|
|
874
|
+
// item-failed-n-times predicate. All values default to 0 so legacy
|
|
875
|
+
// `_lastState` shapes don't break.
|
|
876
|
+
captureState: (p) => ({
|
|
877
|
+
status: p.status,
|
|
878
|
+
planStale: !!p.planStale,
|
|
879
|
+
items_done: _planItemsDone(p),
|
|
880
|
+
items_total: _planItemsTotal(p),
|
|
881
|
+
max_item_retries: _planMaxItemRetries(p),
|
|
882
|
+
}),
|
|
530
883
|
evaluate: (condition, p, prevState, target) => {
|
|
531
884
|
const status = String(p.status || '').toLowerCase();
|
|
532
885
|
switch (condition) {
|
|
@@ -545,6 +898,26 @@ registerTargetType(WATCH_TARGET_TYPE.PLAN, {
|
|
|
545
898
|
(prevState.status !== p.status || prevState.planStale !== !!p.planStale);
|
|
546
899
|
return { triggered: anyChanged, message: anyChanged ? `Plan ${target} changed (${p.status})` : '' };
|
|
547
900
|
}
|
|
901
|
+
// ── P-w5b8d2c9 — Phase 2.2 plan predicates ───────────────────────
|
|
902
|
+
case WATCH_CONDITION.ALL_ITEMS_DONE: {
|
|
903
|
+
// Compound state assertion — registered as absolute so it auto-expires
|
|
904
|
+
// after first fire when stopAfter=0 (otherwise a fully-complete
|
|
905
|
+
// plan would re-fire every tick). Requires items_total > 0 so an
|
|
906
|
+
// empty PRD doesn't trigger the predicate vacuously.
|
|
907
|
+
const total = _planItemsTotal(p);
|
|
908
|
+
const done = _planItemsDone(p);
|
|
909
|
+
const allDone = total > 0 && done === total;
|
|
910
|
+
return { triggered: allDone, message: allDone ? `Plan ${target} all items done (${done}/${total})` : '' };
|
|
911
|
+
}
|
|
912
|
+
case WATCH_CONDITION.ITEM_FAILED_N_TIMES: {
|
|
913
|
+
// Compound state assertion — fires when ANY missing_feature has a
|
|
914
|
+
// _retryCount at or beyond ENGINE_DEFAULTS.maxRetries. Absolute so
|
|
915
|
+
// the alert fires once when the threshold is reached.
|
|
916
|
+
const maxRc = _planMaxItemRetries(p);
|
|
917
|
+
const limit = shared.ENGINE_DEFAULTS.maxRetries;
|
|
918
|
+
const fired = maxRc >= limit;
|
|
919
|
+
return { triggered: fired, message: fired ? `Plan ${target} has item retried ${maxRc} times (>= ${limit})` : '' };
|
|
920
|
+
}
|
|
548
921
|
default:
|
|
549
922
|
return { triggered: false, message: `Unknown condition: ${condition}` };
|
|
550
923
|
}
|
|
@@ -569,7 +942,15 @@ registerTargetType(WATCH_TARGET_TYPE.SCHEDULE, {
|
|
|
569
942
|
WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
|
|
570
943
|
],
|
|
571
944
|
fetchEntity: _findSchedule,
|
|
572
|
-
|
|
945
|
+
// P-w2c8d1e7 — Phase 1.2: surface next_run as null. Cron parsing in
|
|
946
|
+
// engine/scheduler.js exposes parseCronExpr/.matches() but not a
|
|
947
|
+
// next-fire calculator, so the snapshot field is reserved (always null
|
|
948
|
+
// today) for forward-compat with future predicates that gain a helper.
|
|
949
|
+
captureState: (s) => ({
|
|
950
|
+
lastRun: s._lastRun || null,
|
|
951
|
+
enabled: s.enabled !== false,
|
|
952
|
+
next_run: null,
|
|
953
|
+
}),
|
|
573
954
|
evaluate: (condition, s, prevState, target) => {
|
|
574
955
|
const enabled = s.enabled !== false;
|
|
575
956
|
const lastRun = s._lastRun || null;
|
|
@@ -619,18 +1000,53 @@ function _completedStageCount(run) {
|
|
|
619
1000
|
}
|
|
620
1001
|
return n;
|
|
621
1002
|
}
|
|
1003
|
+
// P-w2c8d1e7 — Phase 1.2: report the id of the first non-terminal stage
|
|
1004
|
+
// in run.stages iteration order. run.stages is built from pipeline.stages
|
|
1005
|
+
// in declaration order (engine/pipeline.js startRun ~line 84) so the first
|
|
1006
|
+
// non-terminal entry is the currently-executing or next-pending stage.
|
|
1007
|
+
const _PIPELINE_TERMINAL = new Set(['completed', 'failed', 'stopped']);
|
|
1008
|
+
function _currentPipelineStageId(run) {
|
|
1009
|
+
if (!run || !run.stages || typeof run.stages !== 'object') return null;
|
|
1010
|
+
for (const [stageId, st] of Object.entries(run.stages)) {
|
|
1011
|
+
const s = String((st && st.status) || '').toLowerCase();
|
|
1012
|
+
if (!_PIPELINE_TERMINAL.has(s)) return stageId;
|
|
1013
|
+
}
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
622
1016
|
registerTargetType(WATCH_TARGET_TYPE.PIPELINE, {
|
|
623
1017
|
label: 'Pipeline',
|
|
624
1018
|
description: 'Watch the latest run of a pipeline for completion, failure, or stage progress',
|
|
625
1019
|
conditions: [
|
|
626
1020
|
WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED, WATCH_CONDITION.STAGE_COMPLETE,
|
|
627
1021
|
WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
|
|
1022
|
+
// P-w5b8d2c9 — Phase 2.2: stage-targeted predicates derived from the
|
|
1023
|
+
// current_stage_id snapshot field plus a Phase 2.2 _stuckStageTicks
|
|
1024
|
+
// counter computed inside captureState.
|
|
1025
|
+
WATCH_CONDITION.STAGE_ADVANCED, WATCH_CONDITION.STUCK_IN_STAGE,
|
|
628
1026
|
],
|
|
629
1027
|
fetchEntity: _findPipelineLatestRun,
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1028
|
+
// P-w2c8d1e7 — Phase 1.2: surface current_stage_id (first non-terminal
|
|
1029
|
+
// stage in declaration order) for stage-targeted predicates.
|
|
1030
|
+
// P-w5b8d2c9 — Phase 2.2: also carry forward `_stuckStageTicks`, an
|
|
1031
|
+
// unbounded counter that increments each tick the same non-null
|
|
1032
|
+
// current_stage_id persists. Reset to 0 when the stage advances or when
|
|
1033
|
+
// current_stage_id is null (run has no live stage). Drives the
|
|
1034
|
+
// `stuck-in-stage` predicate.
|
|
1035
|
+
captureState: (run, prev) => {
|
|
1036
|
+
const runId = run.runId || null;
|
|
1037
|
+
const status = run.status || null;
|
|
1038
|
+
const completedStages = _completedStageCount(run);
|
|
1039
|
+
const currentStageId = _currentPipelineStageId(run);
|
|
1040
|
+
const sameStage = !!prev
|
|
1041
|
+
&& currentStageId !== null
|
|
1042
|
+
&& prev.current_stage_id === currentStageId
|
|
1043
|
+
&& prev.runId === runId;
|
|
1044
|
+
const stuckStageTicks = sameStage ? ((prev._stuckStageTicks || 0) + 1) : 0;
|
|
1045
|
+
return {
|
|
1046
|
+
runId, status, completedStages, current_stage_id: currentStageId,
|
|
1047
|
+
_stuckStageTicks: stuckStageTicks,
|
|
1048
|
+
};
|
|
1049
|
+
},
|
|
634
1050
|
evaluate: (condition, run, prevState, target) => {
|
|
635
1051
|
switch (condition) {
|
|
636
1052
|
case WATCH_CONDITION.COMPLETED: {
|
|
@@ -657,6 +1073,26 @@ registerTargetType(WATCH_TARGET_TYPE.PIPELINE, {
|
|
|
657
1073
|
(prevState.completedStages || 0) !== _completedStageCount(run));
|
|
658
1074
|
return { triggered: anyChanged, message: anyChanged ? `Pipeline ${target} changed (${run.status})` : '' };
|
|
659
1075
|
}
|
|
1076
|
+
// ── P-w5b8d2c9 — Phase 2.2 pipeline predicates ───────────────────
|
|
1077
|
+
case WATCH_CONDITION.STAGE_ADVANCED: {
|
|
1078
|
+
// Triggers when current_stage_id changes within the same run.
|
|
1079
|
+
// Different runId means a new run started — handled by status-change
|
|
1080
|
+
// semantics, not by stage-advanced. Suppresses the
|
|
1081
|
+
// undefined-prev → defined-cur transition (post-upgrade phantom).
|
|
1082
|
+
const cur = _currentPipelineStageId(run);
|
|
1083
|
+
const prev = prevState.current_stage_id;
|
|
1084
|
+
const advanced = prev !== undefined && prev !== cur && prevState.runId === run.runId;
|
|
1085
|
+
return { triggered: advanced, message: advanced ? `Pipeline ${target} advanced stage: ${prev} → ${cur}` : '' };
|
|
1086
|
+
}
|
|
1087
|
+
case WATCH_CONDITION.STUCK_IN_STAGE: {
|
|
1088
|
+
// Fires when the carried-forward _stuckStageTicks counter has
|
|
1089
|
+
// crossed the threshold. Implicitly requires current_stage_id !==
|
|
1090
|
+
// null (counter resets to 0 when null per captureState).
|
|
1091
|
+
const ticks = prevState._stuckStageTicks || 0;
|
|
1092
|
+
const threshold = shared.WATCH_STUCK_STAGE_DEFAULT_TICKS;
|
|
1093
|
+
const stuck = ticks >= threshold;
|
|
1094
|
+
return { triggered: stuck, message: stuck ? `Pipeline ${target} stuck in stage ${prevState.current_stage_id} for ${ticks} checks` : '' };
|
|
1095
|
+
}
|
|
660
1096
|
default:
|
|
661
1097
|
return { triggered: false, message: `Unknown condition: ${condition}` };
|
|
662
1098
|
}
|
|
@@ -722,7 +1158,14 @@ registerTargetType(WATCH_TARGET_TYPE.AGENT, {
|
|
|
722
1158
|
WATCH_CONDITION.ACTIVITY_CHANGE, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
|
|
723
1159
|
],
|
|
724
1160
|
fetchEntity: _findAgent,
|
|
725
|
-
|
|
1161
|
+
// P-w2c8d1e7 — Phase 1.2: surface currentDispatchId so predicates can
|
|
1162
|
+
// distinguish "agent now working on a different dispatch" from a plain
|
|
1163
|
+
// status flip. Sourced from queries.getAgents() which derives it from
|
|
1164
|
+
// dispatch.json (active list) via getAgentStatus.dispatch_id.
|
|
1165
|
+
captureState: (a) => ({
|
|
1166
|
+
status: a.status || null,
|
|
1167
|
+
currentDispatchId: a.currentDispatchId || null,
|
|
1168
|
+
}),
|
|
726
1169
|
evaluate: (condition, a, prevState, target) => {
|
|
727
1170
|
const cur = a.status || null;
|
|
728
1171
|
switch (condition) {
|
|
@@ -765,6 +1208,7 @@ module.exports = {
|
|
|
765
1208
|
getActionType: watchActions.getActionType,
|
|
766
1209
|
listActionTypes: watchActions.listActionTypes,
|
|
767
1210
|
runWatchAction: watchActions.runWatchAction,
|
|
1211
|
+
runWatchActionChain: watchActions.runWatchActionChain,
|
|
768
1212
|
validateAction: watchActions.validateAction,
|
|
769
1213
|
_captureState, // exported for testing
|
|
770
1214
|
_watchesPath, // exported for testing — dynamic, respects MINIONS_TEST_DIR
|