@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/github.js
CHANGED
|
@@ -496,6 +496,74 @@ async function pollPrStatus(config) {
|
|
|
496
496
|
updated = true;
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
+
// P-w1a3f9b2 — Phase 1.1: plumb mergeable / isDraft / mergeStateStatus /
|
|
500
|
+
// headRefOid onto the PR object so watches captureState (engine/watches.js)
|
|
501
|
+
// and future predicates (Phase 2.1: head-commit-change, mergeable-flipped,
|
|
502
|
+
// ready-for-merge, draft-flipped) can read them. We mirror GitHub's
|
|
503
|
+
// tri-state contract for `mergeable` (true|false|null — see
|
|
504
|
+
// checkLiveBuildAndConflict comments above):
|
|
505
|
+
// - true → GitHub computed: mergeable
|
|
506
|
+
// - false → GitHub computed: conflicts
|
|
507
|
+
// - null → GitHub still computing — preserve null verbatim, do NOT
|
|
508
|
+
// substitute the cached value (predicates treat null as "no
|
|
509
|
+
// signal" and won't fire transitions involving null).
|
|
510
|
+
// `headRefOid` is an alias of `headSha` for spec parity (GitHub's GraphQL
|
|
511
|
+
// schema and many tools name it `headRefOid`); both stay in sync.
|
|
512
|
+
if (prData.head?.sha && pr.headRefOid !== prData.head.sha) {
|
|
513
|
+
pr.headRefOid = prData.head.sha;
|
|
514
|
+
updated = true;
|
|
515
|
+
}
|
|
516
|
+
const newMergeable = prData.mergeable === true ? true
|
|
517
|
+
: prData.mergeable === false ? false
|
|
518
|
+
: null;
|
|
519
|
+
if (pr.mergeable !== newMergeable) {
|
|
520
|
+
pr.mergeable = newMergeable;
|
|
521
|
+
updated = true;
|
|
522
|
+
}
|
|
523
|
+
const newIsDraft = prData.draft === true;
|
|
524
|
+
if (pr.isDraft !== newIsDraft) {
|
|
525
|
+
pr.isDraft = newIsDraft;
|
|
526
|
+
updated = true;
|
|
527
|
+
}
|
|
528
|
+
const newMergeStateStatus = typeof prData.mergeable_state === 'string' && prData.mergeable_state
|
|
529
|
+
? prData.mergeable_state
|
|
530
|
+
: null;
|
|
531
|
+
if ((pr.mergeStateStatus || null) !== newMergeStateStatus) {
|
|
532
|
+
pr.mergeStateStatus = newMergeStateStatus;
|
|
533
|
+
updated = true;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// P-w3a7b9c4 — Phase 1.3: optional gated `behindBy` field via GitHub
|
|
537
|
+
// /compare/{base}...{head} API. Each call costs one extra GitHub
|
|
538
|
+
// request, so it's gated behind the opt-in
|
|
539
|
+
// `config.engine.watchesIncludeBehindBy` flag. The natural per-tick
|
|
540
|
+
// budget IS the prPollStatusEvery cadence (default ~12 ticks) — by
|
|
541
|
+
// living inside pollPrStatus this fetch inherits that gate without
|
|
542
|
+
// needing a separate counter. When skipped (flag off, PR closed,
|
|
543
|
+
// missing refs, cross-repo fork, or compare-API error), behindBy is
|
|
544
|
+
// explicitly set to null so the `behind-master` predicate
|
|
545
|
+
// (engine/watches.js BEHIND_MASTER) treats it as "no signal" and does
|
|
546
|
+
// not over-fire on legacy / stale state. ADO PRs are handled in
|
|
547
|
+
// engine/ado.js with the same null contract.
|
|
548
|
+
const includeBehindBy = config.engine?.watchesIncludeBehindBy === true;
|
|
549
|
+
let newBehindBy = null;
|
|
550
|
+
if (includeBehindBy && prData.state === 'open' && prData.base?.ref && prData.head?.sha) {
|
|
551
|
+
// Same-repo PRs only — cross-repo (forked) compare needs `owner:branch`
|
|
552
|
+
// syntax which we don't normalize here. Treat fork PRs as "no signal".
|
|
553
|
+
const baseRepo = prData.base?.repo?.full_name || '';
|
|
554
|
+
const headRepo = prData.head?.repo?.full_name || '';
|
|
555
|
+
if (baseRepo && headRepo && baseRepo === headRepo) {
|
|
556
|
+
const cmp = await ghApi(`/compare/${encodeURIComponent(prData.base.ref)}...${encodeURIComponent(prData.head.sha)}`, slug);
|
|
557
|
+
if (cmp && cmp !== GH_NOT_FOUND && typeof cmp.behind_by === 'number') {
|
|
558
|
+
newBehindBy = cmp.behind_by;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if ((pr.behindBy ?? null) !== newBehindBy) {
|
|
563
|
+
pr.behindBy = newBehindBy;
|
|
564
|
+
updated = true;
|
|
565
|
+
}
|
|
566
|
+
|
|
499
567
|
if (pr.status !== newStatus) {
|
|
500
568
|
log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
|
|
501
569
|
pr.status = newStatus;
|
package/engine/queries.js
CHANGED
|
@@ -553,6 +553,9 @@ function getAgents(config) {
|
|
|
553
553
|
return {
|
|
554
554
|
...a, runtime, status: s.status, lastAction,
|
|
555
555
|
currentTask: (s.task || '').slice(0, 200),
|
|
556
|
+
// P-w2c8d1e7 — Phase 1.2: surface the active dispatch id so watch
|
|
557
|
+
// captureState can snapshot it for currentDispatchId-aware predicates.
|
|
558
|
+
currentDispatchId: s.dispatch_id || null,
|
|
556
559
|
resultSummary: (s.resultSummary || '').slice(0, 500),
|
|
557
560
|
started_at: s.started_at || null,
|
|
558
561
|
completed_at: s.completed_at || null,
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/safe-expr.js — Hand-rolled recursive-descent expression evaluator.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3.1 of feat/plan-resilient-watches (P-w6f4a3e8). Used by watch action
|
|
5
|
+
* guards (P-w7c5d8b3) and predicate-based watches (Phase 2.x) to evaluate
|
|
6
|
+
* user-supplied boolean expressions against a runtime context such as
|
|
7
|
+
* `{ target, newState, previousState, condition, ... }`.
|
|
8
|
+
*
|
|
9
|
+
* Hard rules — DO NOT REGRESS:
|
|
10
|
+
* 1. Never use `eval()` or `new Function()`. Both are RCE vectors. The unit
|
|
11
|
+
* test suite greps the source of this module to enforce this.
|
|
12
|
+
* 2. Never throw out of `evaluate()`. Watches must keep ticking even if a
|
|
13
|
+
* user types a malformed predicate. On any parse or evaluation error,
|
|
14
|
+
* log a warn tagged `[safe-expr]` and return `false`.
|
|
15
|
+
* 3. Identifier resolution does an own-property walk only. Reaching
|
|
16
|
+
* `Object.prototype` (`toString`, `constructor`, `__proto__`, ...) is a
|
|
17
|
+
* sandbox-escape vector for an attacker who controls a watch expression.
|
|
18
|
+
*
|
|
19
|
+
* Grammar (lowest to highest precedence):
|
|
20
|
+
* expression -> or
|
|
21
|
+
* or -> and ('||' and)*
|
|
22
|
+
* and -> equality ('&&' equality)*
|
|
23
|
+
* equality -> comparison (('==='|'=='|'!=='|'!=') comparison)*
|
|
24
|
+
* comparison -> unary (('<='|'<'|'>='|'>') unary)*
|
|
25
|
+
* unary -> '!' unary | primary
|
|
26
|
+
* primary -> NUMBER | STRING | true | false | null | undefined
|
|
27
|
+
* | IDENT ('.' IDENT)* | '(' expression ')'
|
|
28
|
+
*
|
|
29
|
+
* Top-level result is coerced via `Boolean(...)` so callers always see a
|
|
30
|
+
* boolean (the canonical guard contract: truthy = run, falsy = skip).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
// ── Lexer ────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const TOK = {
|
|
38
|
+
NUMBER: 'NUMBER',
|
|
39
|
+
STRING: 'STRING',
|
|
40
|
+
IDENT: 'IDENT',
|
|
41
|
+
BOOLEAN: 'BOOLEAN',
|
|
42
|
+
NULL: 'NULL',
|
|
43
|
+
UNDEFINED: 'UNDEFINED',
|
|
44
|
+
DOT: 'DOT',
|
|
45
|
+
LPAREN: 'LPAREN',
|
|
46
|
+
RPAREN: 'RPAREN',
|
|
47
|
+
EQ_STRICT: 'EQ_STRICT',
|
|
48
|
+
NEQ_STRICT: 'NEQ_STRICT',
|
|
49
|
+
EQ_LOOSE: 'EQ_LOOSE',
|
|
50
|
+
NEQ_LOOSE: 'NEQ_LOOSE',
|
|
51
|
+
LT: 'LT',
|
|
52
|
+
LTE: 'LTE',
|
|
53
|
+
GT: 'GT',
|
|
54
|
+
GTE: 'GTE',
|
|
55
|
+
AND: 'AND',
|
|
56
|
+
OR: 'OR',
|
|
57
|
+
NOT: 'NOT',
|
|
58
|
+
MINUS: 'MINUS',
|
|
59
|
+
EOF: 'EOF',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function _isDigit(c) { return c >= '0' && c <= '9'; }
|
|
63
|
+
function _isIdentStart(c) {
|
|
64
|
+
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_' || c === '$';
|
|
65
|
+
}
|
|
66
|
+
function _isIdentBody(c) { return _isIdentStart(c) || _isDigit(c); }
|
|
67
|
+
|
|
68
|
+
function tokenize(src) {
|
|
69
|
+
const tokens = [];
|
|
70
|
+
const len = src.length;
|
|
71
|
+
let i = 0;
|
|
72
|
+
|
|
73
|
+
while (i < len) {
|
|
74
|
+
const c = src[i];
|
|
75
|
+
|
|
76
|
+
// Whitespace
|
|
77
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
|
|
78
|
+
|
|
79
|
+
// String literals (single or double quoted, with basic backslash escapes)
|
|
80
|
+
if (c === '"' || c === "'") {
|
|
81
|
+
const quote = c;
|
|
82
|
+
let j = i + 1;
|
|
83
|
+
let val = '';
|
|
84
|
+
while (j < len && src[j] !== quote) {
|
|
85
|
+
if (src[j] === '\\' && j + 1 < len) {
|
|
86
|
+
const next = src[j + 1];
|
|
87
|
+
if (next === 'n') val += '\n';
|
|
88
|
+
else if (next === 't') val += '\t';
|
|
89
|
+
else if (next === 'r') val += '\r';
|
|
90
|
+
else if (next === '\\') val += '\\';
|
|
91
|
+
else if (next === quote) val += quote;
|
|
92
|
+
else val += next;
|
|
93
|
+
j += 2;
|
|
94
|
+
} else {
|
|
95
|
+
val += src[j];
|
|
96
|
+
j++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (j >= len) throw new Error(`Unterminated string literal starting at ${i}`);
|
|
100
|
+
tokens.push({ type: TOK.STRING, value: val });
|
|
101
|
+
i = j + 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Number literals (positive only — unary minus handled in parsePrimary)
|
|
106
|
+
if (_isDigit(c)) {
|
|
107
|
+
let j = i;
|
|
108
|
+
let sawDot = false;
|
|
109
|
+
while (j < len) {
|
|
110
|
+
const ch = src[j];
|
|
111
|
+
if (_isDigit(ch)) { j++; continue; }
|
|
112
|
+
if (ch === '.' && !sawDot) { sawDot = true; j++; continue; }
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
const numStr = src.slice(i, j);
|
|
116
|
+
const num = Number(numStr);
|
|
117
|
+
if (Number.isNaN(num)) throw new Error(`Invalid number "${numStr}" at ${i}`);
|
|
118
|
+
tokens.push({ type: TOK.NUMBER, value: num });
|
|
119
|
+
i = j;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Identifiers and keywords
|
|
124
|
+
if (_isIdentStart(c)) {
|
|
125
|
+
let j = i;
|
|
126
|
+
while (j < len && _isIdentBody(src[j])) j++;
|
|
127
|
+
const word = src.slice(i, j);
|
|
128
|
+
if (word === 'true') tokens.push({ type: TOK.BOOLEAN, value: true });
|
|
129
|
+
else if (word === 'false') tokens.push({ type: TOK.BOOLEAN, value: false });
|
|
130
|
+
else if (word === 'null') tokens.push({ type: TOK.NULL, value: null });
|
|
131
|
+
else if (word === 'undefined') tokens.push({ type: TOK.UNDEFINED, value: undefined });
|
|
132
|
+
else tokens.push({ type: TOK.IDENT, value: word });
|
|
133
|
+
i = j;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Multi-char operators (longest match first)
|
|
138
|
+
if (c === '=' && src[i + 1] === '=' && src[i + 2] === '=') { tokens.push({ type: TOK.EQ_STRICT }); i += 3; continue; }
|
|
139
|
+
if (c === '!' && src[i + 1] === '=' && src[i + 2] === '=') { tokens.push({ type: TOK.NEQ_STRICT }); i += 3; continue; }
|
|
140
|
+
if (c === '=' && src[i + 1] === '=') { tokens.push({ type: TOK.EQ_LOOSE }); i += 2; continue; }
|
|
141
|
+
if (c === '!' && src[i + 1] === '=') { tokens.push({ type: TOK.NEQ_LOOSE }); i += 2; continue; }
|
|
142
|
+
if (c === '<' && src[i + 1] === '=') { tokens.push({ type: TOK.LTE }); i += 2; continue; }
|
|
143
|
+
if (c === '>' && src[i + 1] === '=') { tokens.push({ type: TOK.GTE }); i += 2; continue; }
|
|
144
|
+
if (c === '&' && src[i + 1] === '&') { tokens.push({ type: TOK.AND }); i += 2; continue; }
|
|
145
|
+
if (c === '|' && src[i + 1] === '|') { tokens.push({ type: TOK.OR }); i += 2; continue; }
|
|
146
|
+
|
|
147
|
+
// Single-char operators
|
|
148
|
+
if (c === '<') { tokens.push({ type: TOK.LT }); i++; continue; }
|
|
149
|
+
if (c === '>') { tokens.push({ type: TOK.GT }); i++; continue; }
|
|
150
|
+
if (c === '!') { tokens.push({ type: TOK.NOT }); i++; continue; }
|
|
151
|
+
if (c === '(') { tokens.push({ type: TOK.LPAREN }); i++; continue; }
|
|
152
|
+
if (c === ')') { tokens.push({ type: TOK.RPAREN }); i++; continue; }
|
|
153
|
+
if (c === '.') { tokens.push({ type: TOK.DOT }); i++; continue; }
|
|
154
|
+
if (c === '-') { tokens.push({ type: TOK.MINUS }); i++; continue; }
|
|
155
|
+
|
|
156
|
+
throw new Error(`Unexpected character '${c}' at ${i}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
tokens.push({ type: TOK.EOF });
|
|
160
|
+
return tokens;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Parser (recursive descent) ───────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function parse(tokens) {
|
|
166
|
+
let pos = 0;
|
|
167
|
+
const peek = () => tokens[pos];
|
|
168
|
+
const match = (...types) => types.includes(tokens[pos].type);
|
|
169
|
+
|
|
170
|
+
function parseOr() {
|
|
171
|
+
let left = parseAnd();
|
|
172
|
+
while (match(TOK.OR)) {
|
|
173
|
+
pos++;
|
|
174
|
+
const right = parseAnd();
|
|
175
|
+
left = { kind: 'logical', op: '||', left, right };
|
|
176
|
+
}
|
|
177
|
+
return left;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseAnd() {
|
|
181
|
+
let left = parseEquality();
|
|
182
|
+
while (match(TOK.AND)) {
|
|
183
|
+
pos++;
|
|
184
|
+
const right = parseEquality();
|
|
185
|
+
left = { kind: 'logical', op: '&&', left, right };
|
|
186
|
+
}
|
|
187
|
+
return left;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseEquality() {
|
|
191
|
+
let left = parseComparison();
|
|
192
|
+
while (match(TOK.EQ_STRICT, TOK.NEQ_STRICT, TOK.EQ_LOOSE, TOK.NEQ_LOOSE)) {
|
|
193
|
+
const op = tokens[pos++].type;
|
|
194
|
+
const right = parseComparison();
|
|
195
|
+
left = { kind: 'binary', op, left, right };
|
|
196
|
+
}
|
|
197
|
+
return left;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseComparison() {
|
|
201
|
+
let left = parseUnary();
|
|
202
|
+
while (match(TOK.LT, TOK.LTE, TOK.GT, TOK.GTE)) {
|
|
203
|
+
const op = tokens[pos++].type;
|
|
204
|
+
const right = parseUnary();
|
|
205
|
+
left = { kind: 'binary', op, left, right };
|
|
206
|
+
}
|
|
207
|
+
return left;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseUnary() {
|
|
211
|
+
if (match(TOK.NOT)) {
|
|
212
|
+
pos++;
|
|
213
|
+
return { kind: 'not', expr: parseUnary() };
|
|
214
|
+
}
|
|
215
|
+
return parsePrimary();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parsePrimary() {
|
|
219
|
+
const tok = peek();
|
|
220
|
+
|
|
221
|
+
// Unary minus is only supported as a prefix on a numeric literal — we have
|
|
222
|
+
// no arithmetic operators, so '-' anywhere else is a parse error.
|
|
223
|
+
if (tok.type === TOK.MINUS) {
|
|
224
|
+
pos++;
|
|
225
|
+
const next = peek();
|
|
226
|
+
if (next.type !== TOK.NUMBER) throw new Error(`Expected number after '-'`);
|
|
227
|
+
pos++;
|
|
228
|
+
return { kind: 'literal', value: -next.value };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (
|
|
232
|
+
tok.type === TOK.NUMBER ||
|
|
233
|
+
tok.type === TOK.STRING ||
|
|
234
|
+
tok.type === TOK.BOOLEAN ||
|
|
235
|
+
tok.type === TOK.NULL ||
|
|
236
|
+
tok.type === TOK.UNDEFINED
|
|
237
|
+
) {
|
|
238
|
+
pos++;
|
|
239
|
+
return { kind: 'literal', value: tok.value };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (tok.type === TOK.IDENT) {
|
|
243
|
+
pos++;
|
|
244
|
+
const segments = [tok.value];
|
|
245
|
+
while (match(TOK.DOT)) {
|
|
246
|
+
pos++;
|
|
247
|
+
const next = peek();
|
|
248
|
+
if (next.type !== TOK.IDENT) throw new Error(`Expected identifier after '.'`);
|
|
249
|
+
pos++;
|
|
250
|
+
segments.push(next.value);
|
|
251
|
+
}
|
|
252
|
+
return { kind: 'path', segments };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (tok.type === TOK.LPAREN) {
|
|
256
|
+
pos++;
|
|
257
|
+
const expr = parseOr();
|
|
258
|
+
if (peek().type !== TOK.RPAREN) throw new Error(`Expected ')'`);
|
|
259
|
+
pos++;
|
|
260
|
+
return expr;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
throw new Error(`Unexpected token ${tok.type}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const ast = parseOr();
|
|
267
|
+
if (peek().type !== TOK.EOF) {
|
|
268
|
+
throw new Error(`Unexpected trailing token ${peek().type}`);
|
|
269
|
+
}
|
|
270
|
+
return ast;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Evaluator ────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
function _resolvePath(segments, ctx) {
|
|
276
|
+
let cur = ctx;
|
|
277
|
+
for (const seg of segments) {
|
|
278
|
+
if (cur == null) return undefined;
|
|
279
|
+
// Own-property walk only. Reaching Object.prototype (toString,
|
|
280
|
+
// constructor, __proto__, ...) is a sandbox-escape vector for any
|
|
281
|
+
// attacker who controls the expression string.
|
|
282
|
+
if (!Object.prototype.hasOwnProperty.call(cur, seg)) return undefined;
|
|
283
|
+
cur = cur[seg];
|
|
284
|
+
}
|
|
285
|
+
return cur;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function _evalNode(node, ctx) {
|
|
289
|
+
switch (node.kind) {
|
|
290
|
+
case 'literal':
|
|
291
|
+
return node.value;
|
|
292
|
+
case 'path':
|
|
293
|
+
return _resolvePath(node.segments, ctx);
|
|
294
|
+
case 'not':
|
|
295
|
+
return !_evalNode(node.expr, ctx);
|
|
296
|
+
case 'logical': {
|
|
297
|
+
const left = _evalNode(node.left, ctx);
|
|
298
|
+
if (node.op === '&&') return left ? _evalNode(node.right, ctx) : left;
|
|
299
|
+
if (node.op === '||') return left ? left : _evalNode(node.right, ctx);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
case 'binary': {
|
|
303
|
+
const l = _evalNode(node.left, ctx);
|
|
304
|
+
const r = _evalNode(node.right, ctx);
|
|
305
|
+
switch (node.op) {
|
|
306
|
+
case TOK.EQ_STRICT: return l === r;
|
|
307
|
+
case TOK.NEQ_STRICT: return l !== r;
|
|
308
|
+
case TOK.EQ_LOOSE: return l == r; // intentional loose equality
|
|
309
|
+
case TOK.NEQ_LOOSE: return l != r; // intentional loose equality
|
|
310
|
+
case TOK.LT: return l < r;
|
|
311
|
+
case TOK.LTE: return l <= r;
|
|
312
|
+
case TOK.GT: return l > r;
|
|
313
|
+
case TOK.GTE: return l >= r;
|
|
314
|
+
default: return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
default:
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
function evaluate(exprString, context) {
|
|
325
|
+
if (typeof exprString !== 'string' || exprString.trim() === '') {
|
|
326
|
+
console.warn(`[safe-expr] invalid expression (expected non-empty string): ${JSON.stringify(exprString)}`);
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let ast;
|
|
331
|
+
try {
|
|
332
|
+
const tokens = tokenize(exprString);
|
|
333
|
+
ast = parse(tokens);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
console.warn(`[safe-expr] parse error in "${exprString}": ${err.message}`);
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let result;
|
|
340
|
+
try {
|
|
341
|
+
result = _evalNode(ast, context == null ? {} : context);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
console.warn(`[safe-expr] evaluation error in "${exprString}": ${err.message}`);
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return Boolean(result);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
module.exports = { evaluate };
|
package/engine/shared.js
CHANGED
|
@@ -1095,6 +1095,7 @@ const ENGINE_DEFAULTS = {
|
|
|
1095
1095
|
ghPollEnabled: true, // poll GitHub PR status, comments, and reconciliation on each tick cycle
|
|
1096
1096
|
prPollStatusEvery: 12, // poll PR build/review/merge status every N ticks for both ADO and GitHub (~12 min at default interval)
|
|
1097
1097
|
prPollCommentsEvery: 12, // poll PR human comments every N ticks for both ADO and GitHub (~12 min at default interval)
|
|
1098
|
+
watchesIncludeBehindBy: false, // opt-in: when true, GitHub PR poll calls /compare/{base}...{head} once per pr per pollPrStatusEvery cadence to populate pr.behindBy (powers the `behind-master` watch predicate). Off by default to avoid the extra API call. ADO PRs always get null (no commit-graph walk yet).
|
|
1098
1099
|
autoCompletePrs: false, // auto-merge PRs when builds green + review approved (opt-in)
|
|
1099
1100
|
prMergeMethod: 'squash', // merge method: squash, merge, rebase
|
|
1100
1101
|
ignoredCommentAuthors: [], // comments from these authors are auto-closed and never trigger fixes
|
|
@@ -1589,13 +1590,50 @@ const WATCH_CONDITION = {
|
|
|
1589
1590
|
ENABLED: 'enabled', // schedule enabled
|
|
1590
1591
|
DISABLED: 'disabled', // schedule disabled
|
|
1591
1592
|
ACTIVITY_CHANGE: 'activity-change', // agent transitioned status (e.g. idle → working)
|
|
1593
|
+
// ── P-w4e2f6a1 — Phase 2.1: PR predicate conditions ──────────────────────
|
|
1594
|
+
// See engine/watches.js PR target type for trigger semantics.
|
|
1595
|
+
HEAD_COMMIT_CHANGE: 'head-commit-change', // PR headRefOid advanced (new push)
|
|
1596
|
+
MERGEABLE_FLIPPED: 'mergeable-flipped', // mergeable transitioned between true↔false (NOT involving null)
|
|
1597
|
+
READY_FOR_MERGE: 'ready-for-merge', // canonical compound: active+approved+passing+mergeable+!draft
|
|
1598
|
+
BEHIND_MASTER: 'behind-master', // pr.behindBy > 0 (treats null/undefined as not-behind)
|
|
1599
|
+
DRAFT_FLIPPED: 'draft-flipped', // isDraft transitioned between true↔false
|
|
1600
|
+
// ── P-w5b8d2c9 — Phase 2.2: work-item / plan / pipeline predicates ───────
|
|
1601
|
+
// See engine/watches.js for trigger semantics. Counters (_unchangedTicks,
|
|
1602
|
+
// _stuckStageTicks) are computed inside captureState by comparing the
|
|
1603
|
+
// freshly-captured snapshot against prevState — see _captureState which
|
|
1604
|
+
// now passes prevState as the 2nd arg.
|
|
1605
|
+
STALLED: 'stalled', // work-item: no captureState change for N ticks (default WATCH_STALLED_DEFAULT_TICKS)
|
|
1606
|
+
RETRY_LIMIT_REACHED: 'retry-limit-reached', // work-item: _retryCount >= ENGINE_DEFAULTS.maxRetries
|
|
1607
|
+
DEPENDENCY_MET: 'dependency-met', // work-item: _pendingReason transitioned away from 'dependency_unmet'
|
|
1608
|
+
ALL_ITEMS_DONE: 'all-items-done', // plan: items_done === items_total && items_total > 0
|
|
1609
|
+
ITEM_FAILED_N_TIMES: 'item-failed-n-times', // plan: any missing_features[*]._retryCount >= ENGINE_DEFAULTS.maxRetries
|
|
1610
|
+
STAGE_ADVANCED: 'stage-advanced', // pipeline: current_stage_id changed within same runId
|
|
1611
|
+
STUCK_IN_STAGE: 'stuck-in-stage', // pipeline: current_stage_id unchanged for N ticks (default WATCH_STUCK_STAGE_DEFAULT_TICKS)
|
|
1592
1612
|
};
|
|
1613
|
+
// ── P-w5b8d2c9 — Phase 2.2: tick thresholds ────────────────────────────────
|
|
1614
|
+
// Default check interval is 5min (DEFAULT_WATCH_INTERVAL); 12 unchanged checks
|
|
1615
|
+
// ≈ 60 minutes. Spec: Math.ceil(60 / 5) = 12 tick-equivalents. Hard-coded
|
|
1616
|
+
// today; per-watch override may follow in a later phase.
|
|
1617
|
+
const WATCH_STALLED_DEFAULT_TICKS = 12;
|
|
1618
|
+
const WATCH_STUCK_STAGE_DEFAULT_TICKS = 12;
|
|
1593
1619
|
// Absolute conditions auto-expire on first trigger when stopAfter=0 (fire-once semantics).
|
|
1594
1620
|
// Change-based conditions (status-change, any, *-change) run forever when stopAfter=0.
|
|
1595
1621
|
const WATCH_ABSOLUTE_CONDITIONS = new Set([
|
|
1596
1622
|
WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
|
|
1597
1623
|
WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
|
|
1598
1624
|
WATCH_CONDITION.CONCLUDED, WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED,
|
|
1625
|
+
// ready-for-merge is a compound state assertion (true at moment of check) —
|
|
1626
|
+
// fire-once when stopAfter=0 so the watch doesn't re-fire each tick while
|
|
1627
|
+
// the PR sits idle in the ready state.
|
|
1628
|
+
WATCH_CONDITION.READY_FOR_MERGE,
|
|
1629
|
+
// ── P-w5b8d2c9 — Phase 2.2: state-assertion conditions that should
|
|
1630
|
+
// fire-once when stopAfter=0. retry-limit-reached / all-items-done /
|
|
1631
|
+
// item-failed-n-times are compound state assertions ("this is true right
|
|
1632
|
+
// now"); without auto-expiry they would re-fire every tick while the
|
|
1633
|
+
// condition holds.
|
|
1634
|
+
WATCH_CONDITION.RETRY_LIMIT_REACHED,
|
|
1635
|
+
WATCH_CONDITION.ALL_ITEMS_DONE,
|
|
1636
|
+
WATCH_CONDITION.ITEM_FAILED_N_TIMES,
|
|
1599
1637
|
]);
|
|
1600
1638
|
// Built-in follow-up action types invoked by the engine when a watch fires.
|
|
1601
1639
|
// The action registry in engine/watch-actions.js is the source of truth; these
|
|
@@ -1607,6 +1645,7 @@ const WATCH_ACTION_TYPE = {
|
|
|
1607
1645
|
DISPATCH_WORK_ITEM: 'dispatch-work-item',
|
|
1608
1646
|
RUN_SKILL: 'run-skill',
|
|
1609
1647
|
WEBHOOK: 'webhook',
|
|
1648
|
+
MINIONS_API: 'minions-api',
|
|
1610
1649
|
CANCEL_WORK_ITEM: 'cancel-work-item',
|
|
1611
1650
|
TRIGGER_PIPELINE: 'trigger-pipeline',
|
|
1612
1651
|
ARCHIVE_PLAN: 'archive-plan',
|
|
@@ -3561,6 +3600,7 @@ module.exports = {
|
|
|
3561
3600
|
backfillProjectWorkSourceDefaults,
|
|
3562
3601
|
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,
|
|
3563
3602
|
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
|
|
3603
|
+
WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
|
|
3564
3604
|
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
3565
3605
|
FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
|
|
3566
3606
|
DEFAULT_AGENT_METRICS,
|