@yemi33/minions 0.1.1901 → 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 +74 -0
- package/engine/github.js +162 -1
- 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/watch-actions.js
CHANGED
|
@@ -33,6 +33,12 @@ const {
|
|
|
33
33
|
WATCH_ACTION_TYPE, WI_STATUS, WORK_TYPE, DONE_STATUSES, PLAN_STATUS,
|
|
34
34
|
log, ts, uid, mutateWorkItems, mutateJsonFileLocked, projectWorkItemsPath,
|
|
35
35
|
} = shared;
|
|
36
|
+
// P-w7c5d8b3 — Phase 3.2: optional guard expressions on action steps.
|
|
37
|
+
// safe-expr.evaluate() never throws and returns Boolean(...) on success or
|
|
38
|
+
// `false` on parse/eval errors (with a `[safe-expr]` warn). That contract
|
|
39
|
+
// is what keeps the "invalid expression skips + warns" acceptance criterion
|
|
40
|
+
// a one-liner here — we trust safe-expr to do the logging.
|
|
41
|
+
const safeExpr = require('./safe-expr');
|
|
36
42
|
|
|
37
43
|
// ── Action registry ──────────────────────────────────────────────────────────
|
|
38
44
|
|
|
@@ -78,16 +84,20 @@ function listActionTypes() {
|
|
|
78
84
|
* Substitute {{var}} placeholders in `value` with values from `ctx`.
|
|
79
85
|
* Operates on strings; recurses one level into plain objects/arrays so action
|
|
80
86
|
* params like { headers: { 'X-Foo': '{{prNumber}}' } } work transparently.
|
|
81
|
-
*
|
|
87
|
+
*
|
|
88
|
+
* Supports dotted paths so chain step N can reference earlier results, e.g.
|
|
89
|
+
* `{{steps.0.result.dispatchedItemId}}` or `{{tags.1}}`. Each segment may be
|
|
90
|
+
* a property name or a numeric array index. Lookup walks `ctx` segment-by-
|
|
91
|
+
* segment; if any segment is missing, undefined, or null, the placeholder is
|
|
92
|
+
* left intact (`{{path}}`) so callers can detect unresolved templates.
|
|
82
93
|
*/
|
|
83
94
|
function substituteTemplate(value, ctx) {
|
|
84
95
|
if (value == null) return value;
|
|
85
96
|
if (typeof value === 'string') {
|
|
86
97
|
return value.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (match, key) => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return match;
|
|
98
|
+
const resolved = _resolvePath(ctx, key);
|
|
99
|
+
if (resolved === undefined || resolved === null) return match;
|
|
100
|
+
return String(resolved);
|
|
91
101
|
});
|
|
92
102
|
}
|
|
93
103
|
if (Array.isArray(value)) return value.map(v => substituteTemplate(v, ctx));
|
|
@@ -99,6 +109,34 @@ function substituteTemplate(value, ctx) {
|
|
|
99
109
|
return value;
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Internal: resolve a dotted path (e.g. "steps.0.result.dispatchedItemId")
|
|
114
|
+
* against a context object. Bare keys (no dots) preserve the original
|
|
115
|
+
* `hasOwnProperty` semantics for back-compat with the pre-Phase-4 behavior.
|
|
116
|
+
* Returns undefined on any miss; null intermediate values short-circuit to
|
|
117
|
+
* undefined so the caller can leave the placeholder in place.
|
|
118
|
+
*/
|
|
119
|
+
function _resolvePath(ctx, key) {
|
|
120
|
+
if (!ctx || typeof ctx !== 'object') return undefined;
|
|
121
|
+
if (key.indexOf('.') === -1) {
|
|
122
|
+
return Object.prototype.hasOwnProperty.call(ctx, key) ? ctx[key] : undefined;
|
|
123
|
+
}
|
|
124
|
+
const segments = key.split('.');
|
|
125
|
+
let cursor = ctx;
|
|
126
|
+
for (const seg of segments) {
|
|
127
|
+
if (cursor === null || cursor === undefined) return undefined;
|
|
128
|
+
if (typeof cursor !== 'object') return undefined;
|
|
129
|
+
if (Array.isArray(cursor)) {
|
|
130
|
+
const idx = Number(seg);
|
|
131
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= cursor.length) return undefined;
|
|
132
|
+
cursor = cursor[idx];
|
|
133
|
+
} else {
|
|
134
|
+
cursor = Object.prototype.hasOwnProperty.call(cursor, seg) ? cursor[seg] : undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return cursor;
|
|
138
|
+
}
|
|
139
|
+
|
|
102
140
|
/**
|
|
103
141
|
* Build the trigger context passed to action handlers and used for templating.
|
|
104
142
|
* Pulls type-specific extras from the entity captured for the watch (e.g. PR
|
|
@@ -156,15 +194,53 @@ function buildTriggerContext(watch, opts = {}) {
|
|
|
156
194
|
/**
|
|
157
195
|
* Run a watch's configured action — async, error-isolated.
|
|
158
196
|
* Returns a result object the caller persists on `watch._lastActionResult`.
|
|
197
|
+
*
|
|
198
|
+
* P-w7c5d8b3 — Phase 3.2: an action may carry an optional
|
|
199
|
+
* `guard: { expr: '<safe-expr string>' }` that is evaluated against the
|
|
200
|
+
* trigger context BEFORE the handler is invoked. Falsy/invalid guards
|
|
201
|
+
* short-circuit to `{ ok: true, skipped: true, summary: 'guard false' }`
|
|
202
|
+
* — the caller treats that as a successful no-op so future action chains
|
|
203
|
+
* (Phase 4) can continue past it without aborting.
|
|
204
|
+
*/
|
|
205
|
+
/**
|
|
206
|
+
* Run a watch's configured action — async, error-isolated.
|
|
207
|
+
* Returns a result object the caller persists on `watch._lastActionResult`.
|
|
208
|
+
*
|
|
209
|
+
* P-w7c5d8b3 — Phase 3.2: an action may carry an optional
|
|
210
|
+
* `guard: { expr: '<safe-expr string>' }` that is evaluated against the
|
|
211
|
+
* trigger context BEFORE the handler is invoked. Falsy/invalid guards
|
|
212
|
+
* short-circuit to `{ ok: true, skipped: true, summary: 'guard false' }`
|
|
213
|
+
* — the caller treats that as a successful no-op so future action chains
|
|
214
|
+
* (Phase 4) can continue past it without aborting.
|
|
215
|
+
*
|
|
216
|
+
* P-w8e9b1f4 — Phase 4: when `watch.action` is an array, this delegates to
|
|
217
|
+
* `runWatchActionChain` which executes the steps sequentially, exposes
|
|
218
|
+
* progressive `ctx.steps` so step N can template against earlier results
|
|
219
|
+
* (e.g. `{{steps.0.result.dispatchedItemId}}`), aborts on the first non-
|
|
220
|
+
* skipped failure, and returns an aggregate `{ ok, summary, steps:[…],
|
|
221
|
+
* aborted }` shape.
|
|
159
222
|
*/
|
|
160
223
|
async function runWatchAction(watch, ctx) {
|
|
161
224
|
const action = watch && watch.action;
|
|
225
|
+
if (Array.isArray(action)) return runWatchActionChain(watch, ctx);
|
|
162
226
|
if (!action || typeof action !== 'object') return { ok: false, summary: 'no action configured', skipped: true };
|
|
163
227
|
const type = String(action.type || '').toLowerCase();
|
|
164
228
|
if (!type) return { ok: false, summary: 'action.type missing' };
|
|
165
229
|
const spec = ACTION_TYPES[type];
|
|
166
230
|
if (!spec) return { ok: false, summary: `unknown action type: ${type}` };
|
|
167
231
|
|
|
232
|
+
// Evaluate optional guard against the trigger context. safe-expr returns
|
|
233
|
+
// false on parse/eval errors (and logs a [safe-expr] warn), so invalid
|
|
234
|
+
// expressions naturally fall into the "skipped" branch. Empty / non-string
|
|
235
|
+
// expr is also invalid per safe-expr's contract.
|
|
236
|
+
if (action.guard && typeof action.guard === 'object' && !Array.isArray(action.guard)) {
|
|
237
|
+
const expr = action.guard.expr;
|
|
238
|
+
const passed = safeExpr.evaluate(expr, ctx || {});
|
|
239
|
+
if (!passed) {
|
|
240
|
+
return { ok: true, skipped: true, summary: 'guard false', guard: { expr: typeof expr === 'string' ? expr : null } };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
168
244
|
const params = substituteTemplate(action.params || {}, ctx);
|
|
169
245
|
try {
|
|
170
246
|
const result = await spec.handler(watch, { ...ctx, params });
|
|
@@ -177,24 +253,96 @@ async function runWatchAction(watch, ctx) {
|
|
|
177
253
|
}
|
|
178
254
|
}
|
|
179
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Run an array-form `watch.action` as an ordered chain of steps.
|
|
258
|
+
*
|
|
259
|
+
* Each step is a single-action object (`{type, params?, guard?}`). Steps
|
|
260
|
+
* execute sequentially; the per-step result is appended to `ctx.steps` BEFORE
|
|
261
|
+
* the next step is templated, so step N can reference earlier outputs via
|
|
262
|
+
* dotted paths (e.g. `{{steps.0.result.dispatchedItemId}}`). The chain
|
|
263
|
+
* aborts on the first step that returns `ok:false` AND is not `skipped`;
|
|
264
|
+
* guard-skipped steps are treated as successful no-ops (chain continues).
|
|
265
|
+
*
|
|
266
|
+
* Returns an aggregate `{ ok, summary, steps, aborted }`. Each `steps[i]`
|
|
267
|
+
* entry has shape `{type, ok, skipped, summary, result}` where `result`
|
|
268
|
+
* holds the full per-step return value so downstream templating can reach
|
|
269
|
+
* any field the handler exposed.
|
|
270
|
+
*/
|
|
271
|
+
async function runWatchActionChain(watch, ctx) {
|
|
272
|
+
const steps = Array.isArray(watch && watch.action) ? watch.action : [];
|
|
273
|
+
const stepResults = [];
|
|
274
|
+
// Mutate-in-place so substituteTemplate() sees freshly-completed steps on
|
|
275
|
+
// the next iteration without rebuilding ctx each time.
|
|
276
|
+
const baseCtx = ctx && typeof ctx === 'object' ? ctx : {};
|
|
277
|
+
const chainCtx = { ...baseCtx, steps: stepResults };
|
|
278
|
+
let chainOk = true;
|
|
279
|
+
let aborted = false;
|
|
280
|
+
let lastSummary = '';
|
|
281
|
+
for (let i = 0; i < steps.length; i++) {
|
|
282
|
+
const step = steps[i];
|
|
283
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) {
|
|
284
|
+
stepResults.push({ type: null, ok: false, skipped: false, summary: `step ${i} is not an object`, result: null });
|
|
285
|
+
chainOk = false;
|
|
286
|
+
aborted = true;
|
|
287
|
+
lastSummary = `step ${i} invalid`;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
const stepWatch = { ...watch, action: step };
|
|
291
|
+
const result = await runWatchAction(stepWatch, chainCtx);
|
|
292
|
+
const entry = {
|
|
293
|
+
type: step.type ? String(step.type).toLowerCase() : null,
|
|
294
|
+
ok: !!(result && result.ok),
|
|
295
|
+
skipped: !!(result && result.skipped),
|
|
296
|
+
summary: (result && result.summary) || '',
|
|
297
|
+
result: result || null,
|
|
298
|
+
};
|
|
299
|
+
stepResults.push(entry);
|
|
300
|
+
lastSummary = entry.summary;
|
|
301
|
+
if (!entry.ok) {
|
|
302
|
+
chainOk = false;
|
|
303
|
+
aborted = true;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
if (entry.ok && !entry.skipped) {
|
|
307
|
+
// chainOk stays true; continue
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const ranCount = stepResults.filter(s => s.ok && !s.skipped).length;
|
|
311
|
+
const skippedCount = stepResults.filter(s => s.skipped).length;
|
|
312
|
+
const summary = aborted
|
|
313
|
+
? `chain aborted at step ${stepResults.length} of ${steps.length}: ${lastSummary || 'failure'}`
|
|
314
|
+
: `chain ok (${stepResults.length} step${stepResults.length === 1 ? '' : 's'}: ${ranCount} ran, ${skippedCount} skipped)`;
|
|
315
|
+
return { ok: chainOk, summary, steps: stepResults, aborted };
|
|
316
|
+
}
|
|
317
|
+
|
|
180
318
|
// ── Built-in action handlers ─────────────────────────────────────────────────
|
|
181
319
|
|
|
182
320
|
// notify — explicit no-op redundant with the watch.notify field. Useful when a
|
|
183
321
|
// caller wants to set notify=none and still configure the alert via the action
|
|
184
322
|
// surface (e.g. to template the body), or as a placeholder.
|
|
323
|
+
//
|
|
324
|
+
// P-w11a4c9e — Phase 6.1: NOTIFY is now the single registered code path for
|
|
325
|
+
// inbox notifications. The legacy `watch.notify === 'inbox'` branch in
|
|
326
|
+
// engine/watches.js no longer writes to the inbox itself — it instead
|
|
327
|
+
// synthesizes an implicit `{type:'notify'}` step prepended to the action
|
|
328
|
+
// chain. To keep the legacy slug shape `watch-<id>-<triggerCount>` (so
|
|
329
|
+
// existing dedup / readers see no change), this handler defaults to that
|
|
330
|
+
// slug. Callers can pass `params.slug` to override (e.g. when emitting
|
|
331
|
+
// multiple notify steps in the same trigger).
|
|
185
332
|
registerActionType(WATCH_ACTION_TYPE.NOTIFY, {
|
|
186
333
|
label: 'Notify (inbox)',
|
|
187
|
-
description: 'Write a notification to the configured owner inbox.
|
|
334
|
+
description: 'Write a notification to the configured owner inbox. Routes the legacy `notify: "inbox"` field; can also be used as an explicit action to template the body or override the slug.',
|
|
188
335
|
params: [
|
|
189
336
|
{ key: 'owner', required: false, description: 'Override owner; defaults to watch.owner.' },
|
|
190
337
|
{ key: 'body', required: false, description: 'Override body; defaults to standard trigger message.' },
|
|
338
|
+
{ key: 'slug', required: false, description: 'Override inbox file slug; defaults to legacy `watch-<id>-<triggerCount>`.' },
|
|
191
339
|
],
|
|
192
340
|
handler: async (watch, ctx) => {
|
|
193
341
|
const owner = ctx.params.owner || watch.owner;
|
|
194
342
|
if (!owner) return { ok: false, summary: 'no owner — skipping notify action' };
|
|
195
343
|
const body = ctx.params.body
|
|
196
344
|
|| `## 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}
|
|
345
|
+
const slug = ctx.params.slug || `watch-${watch.id}-${watch.triggerCount || 0}`;
|
|
198
346
|
shared.writeToInbox(owner, slug, body);
|
|
199
347
|
return { ok: true, summary: `notified ${owner}` };
|
|
200
348
|
},
|
|
@@ -341,6 +489,107 @@ registerActionType(WATCH_ACTION_TYPE.WEBHOOK, {
|
|
|
341
489
|
},
|
|
342
490
|
});
|
|
343
491
|
|
|
492
|
+
// minions-api — first-class loopback API caller for the in-process dashboard.
|
|
493
|
+
// Restricts `endpoint` to paths starting with `/api/` so a watch can't reach
|
|
494
|
+
// external hosts (use `webhook` for that). Always targets
|
|
495
|
+
// `http://127.0.0.1:${MINIONS_PORT||7331}${endpoint}` and sets
|
|
496
|
+
// `X-Minions-Internal: 1` so dashboard handlers can recognize that the
|
|
497
|
+
// request originated from the engine's own action surface (Origin/Referer
|
|
498
|
+
// are intentionally absent — the dashboard's origin gate already allows
|
|
499
|
+
// header-less local tooling through).
|
|
500
|
+
//
|
|
501
|
+
// Body is rendered through substituteTemplate by runWatchAction's templating
|
|
502
|
+
// pass before the handler runs, so step refs like
|
|
503
|
+
// {{steps.0.result.dispatchedItemId}} resolve into the JSON payload.
|
|
504
|
+
//
|
|
505
|
+
// Returns `{ ok, status, summary, response }` so chain steps can template
|
|
506
|
+
// against `{{steps.N.result.response.<field>}}`.
|
|
507
|
+
const _ALLOWED_API_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
508
|
+
function _isLoopbackApiEndpoint(value) {
|
|
509
|
+
if (typeof value !== 'string' || !value) return false;
|
|
510
|
+
// Must be a path starting with /api/ — reject anything that looks like a
|
|
511
|
+
// full URL (http://, https://, //host) so an attacker can't sneak an
|
|
512
|
+
// external host in via the endpoint field.
|
|
513
|
+
if (!value.startsWith('/api/')) return false;
|
|
514
|
+
if (value.startsWith('//')) return false;
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
registerActionType(WATCH_ACTION_TYPE.MINIONS_API, {
|
|
518
|
+
label: 'Minions API',
|
|
519
|
+
description: 'Call a Minions dashboard API endpoint via loopback (always 127.0.0.1). Endpoint must start with /api/. Use webhook for external HTTP.',
|
|
520
|
+
params: [
|
|
521
|
+
{ key: 'endpoint', required: true, description: 'API path starting with /api/ (e.g. /api/work-items). Templated.' },
|
|
522
|
+
{ key: 'method', required: false, description: 'HTTP method: GET (default), POST, PUT, PATCH, DELETE.' },
|
|
523
|
+
{ key: 'body', required: false, description: 'Request body (object → JSON, string → raw). Templated through substituteTemplate.' },
|
|
524
|
+
{ key: 'headers', required: false, description: 'Object map of extra headers. X-Minions-Internal is added automatically.' },
|
|
525
|
+
],
|
|
526
|
+
handler: async (watch, ctx) => {
|
|
527
|
+
const p = ctx.params || {};
|
|
528
|
+
const endpoint = String(p.endpoint || '').trim();
|
|
529
|
+
if (!endpoint) return { ok: false, summary: 'minions-api: endpoint is required' };
|
|
530
|
+
if (!_isLoopbackApiEndpoint(endpoint)) {
|
|
531
|
+
return { ok: false, summary: `minions-api: endpoint must start with /api/ (loopback only); got "${endpoint}"` };
|
|
532
|
+
}
|
|
533
|
+
const method = String(p.method || 'GET').toUpperCase();
|
|
534
|
+
if (!_ALLOWED_API_METHODS.has(method)) {
|
|
535
|
+
return { ok: false, summary: `minions-api: unsupported method ${method} (allowed: ${[..._ALLOWED_API_METHODS].join(', ')})` };
|
|
536
|
+
}
|
|
537
|
+
const port = process.env.MINIONS_PORT || 7331;
|
|
538
|
+
const headers = {
|
|
539
|
+
'User-Agent': 'minions-watch/1.0',
|
|
540
|
+
'X-Minions-Internal': '1',
|
|
541
|
+
...(p.headers && typeof p.headers === 'object' ? p.headers : {}),
|
|
542
|
+
};
|
|
543
|
+
let body = null;
|
|
544
|
+
if (p.body !== undefined && p.body !== null && method !== 'GET' && method !== 'HEAD') {
|
|
545
|
+
if (typeof p.body === 'string') {
|
|
546
|
+
body = p.body;
|
|
547
|
+
if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'text/plain';
|
|
548
|
+
} else {
|
|
549
|
+
body = JSON.stringify(p.body);
|
|
550
|
+
if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'application/json';
|
|
551
|
+
}
|
|
552
|
+
headers['Content-Length'] = Buffer.byteLength(body);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return new Promise((resolve) => {
|
|
556
|
+
const req = http.request({
|
|
557
|
+
hostname: '127.0.0.1',
|
|
558
|
+
port,
|
|
559
|
+
path: endpoint,
|
|
560
|
+
method,
|
|
561
|
+
headers,
|
|
562
|
+
}, (res) => {
|
|
563
|
+
let chunks = '';
|
|
564
|
+
res.on('data', (c) => { chunks += c; });
|
|
565
|
+
res.on('end', () => {
|
|
566
|
+
let parsed = chunks;
|
|
567
|
+
const ct = String(res.headers['content-type'] || '').toLowerCase();
|
|
568
|
+
if (ct.includes('application/json') && chunks) {
|
|
569
|
+
try { parsed = JSON.parse(chunks); } catch { /* keep raw text on parse failure */ }
|
|
570
|
+
}
|
|
571
|
+
const ok = res.statusCode >= 200 && res.statusCode < 400;
|
|
572
|
+
resolve({
|
|
573
|
+
ok,
|
|
574
|
+
status: res.statusCode,
|
|
575
|
+
summary: `minions-api ${method} ${endpoint} → ${res.statusCode}`,
|
|
576
|
+
response: parsed,
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
req.on('error', (err) => {
|
|
581
|
+
resolve({ ok: false, summary: `minions-api ${method} ${endpoint} failed: ${err.message}` });
|
|
582
|
+
});
|
|
583
|
+
// 10s safety timeout — watches must not hang the tick.
|
|
584
|
+
req.setTimeout(10000, () => {
|
|
585
|
+
req.destroy(new Error('timeout'));
|
|
586
|
+
});
|
|
587
|
+
if (body !== null) req.write(body);
|
|
588
|
+
req.end();
|
|
589
|
+
});
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
344
593
|
// cancel-work-item — flip a WI to CANCELLED across project + central files.
|
|
345
594
|
registerActionType(WATCH_ACTION_TYPE.CANCEL_WORK_ITEM, {
|
|
346
595
|
label: 'Cancel Work Item',
|
|
@@ -472,7 +721,23 @@ registerActionType(WATCH_ACTION_TYPE.RESUME_PLAN, {
|
|
|
472
721
|
*/
|
|
473
722
|
function validateAction(action) {
|
|
474
723
|
if (action === null || action === undefined) return null; // optional field
|
|
475
|
-
|
|
724
|
+
// P-w8e9b1f4 — Phase 4: array-form action chains. Each entry must itself
|
|
725
|
+
// be a valid single-action object. Validate per-step so the error names
|
|
726
|
+
// the failing index. An empty array is rejected — a chain with no steps
|
|
727
|
+
// would silently no-op at trigger time, which is almost never intended.
|
|
728
|
+
if (Array.isArray(action)) {
|
|
729
|
+
if (action.length === 0) return 'action chain (array) must contain at least one step';
|
|
730
|
+
for (let i = 0; i < action.length; i++) {
|
|
731
|
+
const step = action[i];
|
|
732
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) {
|
|
733
|
+
return `action[${i}] must be a step object (got ${Array.isArray(step) ? 'array' : typeof step})`;
|
|
734
|
+
}
|
|
735
|
+
const err = validateAction(step);
|
|
736
|
+
if (err) return `action[${i}]: ${err}`;
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
if (typeof action !== 'object') return 'action must be an object or an array of step objects';
|
|
476
741
|
const type = String(action.type || '').toLowerCase();
|
|
477
742
|
if (!type) return 'action.type is required';
|
|
478
743
|
const spec = ACTION_TYPES[type];
|
|
@@ -486,6 +751,21 @@ function validateAction(action) {
|
|
|
486
751
|
return `action.params.${p.key} is required for action type ${type}`;
|
|
487
752
|
}
|
|
488
753
|
}
|
|
754
|
+
// P-w7c5d8b3 — Phase 3.2: optional `guard` field. Shape is locked here
|
|
755
|
+
// (object with a string `expr`), but the expression itself is NOT
|
|
756
|
+
// pre-parsed at validation time — safe-expr.evaluate is the single
|
|
757
|
+
// source of truth, and a malformed expression is handled at run time
|
|
758
|
+
// (skips the action + logs a [safe-expr] warn). Pre-parsing would
|
|
759
|
+
// duplicate the parser surface and risk false-rejecting expressions
|
|
760
|
+
// whose context vars only exist at trigger time.
|
|
761
|
+
if (action.guard !== undefined && action.guard !== null) {
|
|
762
|
+
if (typeof action.guard !== 'object' || Array.isArray(action.guard)) {
|
|
763
|
+
return 'action.guard must be an object of shape { expr: string }';
|
|
764
|
+
}
|
|
765
|
+
if (typeof action.guard.expr !== 'string' || action.guard.expr.trim() === '') {
|
|
766
|
+
return 'action.guard.expr must be a non-empty string';
|
|
767
|
+
}
|
|
768
|
+
}
|
|
489
769
|
// Extra validation for webhook URL — fail fast on unsupported schemes.
|
|
490
770
|
if (type === WATCH_ACTION_TYPE.WEBHOOK && params.url) {
|
|
491
771
|
const url = String(params.url);
|
|
@@ -499,6 +779,31 @@ function validateAction(action) {
|
|
|
499
779
|
} catch { return `webhook url is invalid: ${url}`; }
|
|
500
780
|
}
|
|
501
781
|
}
|
|
782
|
+
// Extra validation for minions-api endpoint + method. The endpoint must
|
|
783
|
+
// be a loopback /api/ path; full URLs and other prefixes are rejected so
|
|
784
|
+
// the action can never reach external hosts (use webhook for that). The
|
|
785
|
+
// method allowlist matches the runtime handler's allowlist so misspelled
|
|
786
|
+
// methods fail at createWatch time instead of at first trigger.
|
|
787
|
+
if (type === WATCH_ACTION_TYPE.MINIONS_API) {
|
|
788
|
+
const endpoint = String(params.endpoint || '');
|
|
789
|
+
// Allow templated endpoints (containing {{...}}) — re-validated at runtime.
|
|
790
|
+
if (!/\{\{/.test(endpoint)) {
|
|
791
|
+
if (!endpoint.startsWith('/api/') || endpoint.startsWith('//')) {
|
|
792
|
+
return `minions-api endpoint must start with /api/ (loopback only); got "${endpoint}"`;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (params.method !== undefined && params.method !== null && params.method !== '') {
|
|
796
|
+
const m = String(params.method).toUpperCase();
|
|
797
|
+
const allowed = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
798
|
+
if (!allowed.has(m)) {
|
|
799
|
+
return `minions-api method must be one of GET, POST, PUT, PATCH, DELETE; got ${params.method}`;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (params.headers !== undefined && params.headers !== null
|
|
803
|
+
&& (typeof params.headers !== 'object' || Array.isArray(params.headers))) {
|
|
804
|
+
return 'minions-api headers must be an object map of header → value';
|
|
805
|
+
}
|
|
806
|
+
}
|
|
502
807
|
return null;
|
|
503
808
|
}
|
|
504
809
|
|
|
@@ -507,6 +812,7 @@ module.exports = {
|
|
|
507
812
|
getActionType,
|
|
508
813
|
listActionTypes,
|
|
509
814
|
runWatchAction,
|
|
815
|
+
runWatchActionChain,
|
|
510
816
|
buildTriggerContext,
|
|
511
817
|
substituteTemplate,
|
|
512
818
|
validateAction,
|