@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.
@@ -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
- * Unknown vars are left as `{{var}}` so callers can detect them downstream.
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
- if (ctx && Object.prototype.hasOwnProperty.call(ctx, key) && ctx[key] !== undefined && ctx[key] !== null) {
88
- return String(ctx[key]);
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. Redundant with the legacy `notify` field but useful as an explicit action.',
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}-action-${watch.triggerCount || 0}-${Date.now()}`;
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
- if (typeof action !== 'object' || Array.isArray(action)) return 'action must be an object';
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,