@yemi33/minions 0.1.601 → 0.1.602

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.601 (2026-04-08)
3
+ ## 0.1.602 (2026-04-08)
4
+
5
+ ### Features
6
+ - add conditional stage and stop-when support to pipelines (#553)
4
7
 
5
8
  ### Fixes
6
9
  - rotate live-output.log on spawn to preserve previous session (closes #543) (#551)
@@ -191,13 +191,13 @@ function renderPipelines(pipelines) {
191
191
  el.innerHTML = pipelines.map(function(p) {
192
192
  const activeRun = (p.runs || []).find(function(r) { return r.status === 'running'; });
193
193
  const lastRun = (p.runs || []).slice(-1)[0];
194
- const statusColor = activeRun ? 'var(--blue)' : lastRun?.status === 'completed' ? 'var(--green)' : lastRun?.status === 'failed' ? 'var(--red)' : 'var(--muted)';
195
- const statusLabel = activeRun ? 'Running' : lastRun ? (lastRun.status === 'completed' ? 'Completed' : lastRun.status === 'failed' ? 'Failed' : lastRun.status) : 'Never run';
194
+ const statusColor = activeRun ? 'var(--blue)' : lastRun?.status === 'completed' ? 'var(--green)' : lastRun?.status === 'failed' ? 'var(--red)' : lastRun?.status === 'stopped' ? 'var(--yellow)' : 'var(--muted)';
195
+ const statusLabel = activeRun ? 'Running' : lastRun ? (lastRun.status === 'completed' ? 'Completed' : lastRun.status === 'failed' ? 'Failed' : lastRun.status === 'stopped' ? 'Stopped' : lastRun.status) : 'Never run';
196
196
  const trigger = p.trigger?.cron ? _cronToHuman(p.trigger.cron) : 'Manual';
197
197
 
198
198
  // Stage flow visualization
199
199
  var stageFlow = (p.stages || []).map(function(s) {
200
- var icon = { task: '\u2699', meeting: '\uD83D\uDCAC', plan: '\uD83D\uDCCB', 'merge-prs': '\uD83D\uDD00', api: '\uD83C\uDF10', wait: '\u23F8', parallel: '\u2693', schedule: '\u23F0' }[s.type] || '\u2022';
200
+ var icon = { task: '\u2699', meeting: '\uD83D\uDCAC', plan: '\uD83D\uDCCB', 'merge-prs': '\uD83D\uDD00', api: '\uD83C\uDF10', wait: '\u23F8', parallel: '\u2693', schedule: '\u23F0', condition: '\u2753' }[s.type] || '\u2022';
201
201
  var stageStatus = activeRun?.stages?.[s.id]?.status || 'pending';
202
202
  var color = stageStatus === 'completed' ? 'var(--green)' : stageStatus === 'running' ? 'var(--blue)' : stageStatus === 'failed' ? 'var(--red)' : stageStatus === 'waiting-human' ? 'var(--yellow)' : 'var(--muted)';
203
203
  return '<span style="color:' + color + ';font-size:11px" title="' + escHtml(s.id) + ': ' + escHtml(s.title || s.type) + ' (' + stageStatus + ')">' + icon + ' ' + escHtml(s.id) + '</span>';
@@ -220,7 +220,8 @@ function renderPipelines(pipelines) {
220
220
  '<div style="display:flex;align-items:center;gap:8px">' +
221
221
  '<span style="color:' + statusColor + ';font-size:11px;font-weight:600">' + statusLabel + '</span>' +
222
222
  '<span style="font-size:10px;color:var(--muted)">' + escHtml(trigger) + '</span>' +
223
- (p.enabled === false ? '<span style="font-size:9px;color:var(--red)">DISABLED</span>' : '') +
223
+ (p.stopWhen ? '<span style="font-size:9px;color:var(--yellow)" title="Auto-stops when condition met: ' + escHtml(typeof p.stopWhen === 'string' ? p.stopWhen : (p.stopWhen.check || 'condition')) + '">STOP-WHEN</span>' : '') +
224
+ (p.enabled === false ? '<span style="font-size:9px;color:var(--red)"' + (p._stopReason ? ' title="' + escHtml(p._stopReason) + '"' : '') + '>' + (p._stoppedBy ? 'AUTO-STOPPED' : 'DISABLED') + '</span>' : '') +
224
225
  '</div>' +
225
226
  '</div>' +
226
227
  '<div style="margin-top:6px;display:flex;gap:4px;align-items:center;flex-wrap:wrap">' + stageFlow + '</div>' +
@@ -266,6 +267,20 @@ function openPipelineDetail(id) {
266
267
  '</div>';
267
268
  }
268
269
 
270
+ // stopWhen info
271
+ if (p.stopWhen) {
272
+ var swLabel = typeof p.stopWhen === 'string' ? p.stopWhen : (p.stopWhen.check || JSON.stringify(p.stopWhen));
273
+ html += '<div style="border:1px solid color-mix(in srgb, var(--yellow) 30%, transparent);border-radius:6px;padding:4px 10px;background:color-mix(in srgb, var(--yellow) 6%, transparent);font-size:11px">' +
274
+ '<span style="color:var(--yellow);font-weight:600">Stop When:</span> <span style="color:var(--text)">' + escHtml(swLabel) + '</span>' +
275
+ (p._stoppedBy ? ' <span style="color:var(--green);font-size:10px">\u2714 triggered' + (p._stoppedAt ? ' at ' + escHtml(p._stoppedAt.slice(0, 16).replace('T', ' ')) : '') + '</span>' : '') +
276
+ '</div>';
277
+ }
278
+ if (p._stopReason && p.enabled === false) {
279
+ html += '<div style="border:1px solid color-mix(in srgb, var(--red) 30%, transparent);border-radius:6px;padding:4px 10px;background:color-mix(in srgb, var(--red) 6%, transparent);font-size:11px">' +
280
+ '<span style="color:var(--red);font-weight:600">Auto-stopped:</span> <span style="color:var(--text)">' + escHtml(p._stopReason) + '</span>' +
281
+ '</div>';
282
+ }
283
+
269
284
  html += '<h4 style="font-size:12px;color:var(--blue);margin:0">Stages</h4>';
270
285
  (p.stages || []).forEach(function(s, i) {
271
286
  var stageRun = activeRun?.stages?.[s.id] || {};
@@ -278,7 +293,9 @@ function openPipelineDetail(id) {
278
293
  '<span style="font-weight:600;font-size:12px">' + (i + 1) + '. ' + escHtml(s.title || s.id) + '</span>' +
279
294
  '<span style="color:' + statusColor + ';font-size:10px;font-weight:600">' + stageStatus.toUpperCase() + '</span>' +
280
295
  '</div>' +
281
- '<div style="font-size:10px;color:var(--muted);margin-top:4px">Type: ' + escHtml(s.type) + ' | Depends on: ' + escHtml(deps) + (s.agent ? ' | Agent: ' + escHtml(s.agent) : '') + '</div>' +
296
+ '<div style="font-size:10px;color:var(--muted);margin-top:4px">Type: ' + escHtml(s.type) + ' | Depends on: ' + escHtml(deps) + (s.agent ? ' | Agent: ' + escHtml(s.agent) : '') +
297
+ (s.type === 'condition' ? ' | Check: ' + escHtml(typeof (s.check || s.condition) === 'string' ? (s.check || s.condition) : ((s.check || s.condition || {}).check || '?')) + (s.onMet ? ' | onMet: ' + escHtml(s.onMet) : '') + (s.onUnmet ? ' | onUnmet: ' + escHtml(s.onUnmet) : '') : '') +
298
+ '</div>' +
282
299
  _renderMonitoredResources(s.monitoredResources || []) +
283
300
  _renderArtifactLinks(stageRun.artifacts) +
284
301
  (stageRun.output ? '<div style="margin-top:6px;font-size:11px;max-height:150px;overflow-y:auto">' + renderMd(stageRun.output.slice(0, 500)) + '</div>' : '') +
@@ -291,7 +308,7 @@ function openPipelineDetail(id) {
291
308
  if (runs.length > 0) {
292
309
  html += '<h4 style="font-size:12px;color:var(--blue);margin:0">Recent Runs</h4>';
293
310
  runs.forEach(function(r, ri) {
294
- var color = r.status === 'completed' ? 'var(--green)' : r.status === 'failed' ? 'var(--red)' : r.status === 'running' ? 'var(--blue)' : 'var(--muted)';
311
+ var color = r.status === 'completed' ? 'var(--green)' : r.status === 'failed' ? 'var(--red)' : r.status === 'running' ? 'var(--blue)' : r.status === 'stopped' ? 'var(--yellow)' : 'var(--muted)';
295
312
  // Collect all artifacts across stages for this run
296
313
  var runArtifacts = _collectRunArtifacts(r);
297
314
  var artifactCount = runArtifacts.total;
package/dashboard.js CHANGED
@@ -4020,18 +4020,19 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4020
4020
  const result = pipelines.map(p => ({ ...p, runs: (runs[p.id] || []).slice(-5) }));
4021
4021
  return jsonReply(res, 200, result);
4022
4022
  }},
4023
- { method: 'POST', path: '/api/pipelines', desc: 'Create a pipeline', params: 'id, title, stages[], trigger?, monitoredResources?', handler: async (req, res) => {
4023
+ { method: 'POST', path: '/api/pipelines', desc: 'Create a pipeline', params: 'id, title, stages[], trigger?, stopWhen?, monitoredResources?', handler: async (req, res) => {
4024
4024
  const body = await readBody(req);
4025
4025
  if (!body.id || !body.title || !body.stages) return jsonReply(res, 400, { error: 'id, title, and stages required' });
4026
4026
  const { savePipeline, getPipeline } = require('./engine/pipeline');
4027
4027
  if (getPipeline(body.id)) return jsonReply(res, 409, { error: 'Pipeline already exists' });
4028
4028
  const pipeline = { id: body.id, title: body.title, stages: body.stages, trigger: body.trigger || {}, enabled: body.enabled !== false };
4029
+ if (body.stopWhen) pipeline.stopWhen = body.stopWhen;
4029
4030
  if (Array.isArray(body.monitoredResources) && body.monitoredResources.length > 0) pipeline.monitoredResources = body.monitoredResources;
4030
4031
  savePipeline(pipeline);
4031
4032
  invalidateStatusCache();
4032
4033
  return jsonReply(res, 200, { ok: true, id: pipeline.id });
4033
4034
  }},
4034
- { method: 'POST', path: '/api/pipelines/update', desc: 'Update a pipeline', params: 'id, title?, stages?, trigger?, enabled?, monitoredResources?', handler: async (req, res) => {
4035
+ { method: 'POST', path: '/api/pipelines/update', desc: 'Update a pipeline', params: 'id, title?, stages?, trigger?, enabled?, stopWhen?, monitoredResources?', handler: async (req, res) => {
4035
4036
  const body = await readBody(req);
4036
4037
  if (!body.id) return jsonReply(res, 400, { error: 'id required' });
4037
4038
  const { getPipeline, savePipeline } = require('./engine/pipeline');
@@ -4042,6 +4043,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4042
4043
  if (body.trigger !== undefined) pipeline.trigger = body.trigger;
4043
4044
  if (body.enabled !== undefined) pipeline.enabled = body.enabled;
4044
4045
  if (body.monitoredResources !== undefined) pipeline.monitoredResources = body.monitoredResources;
4046
+ if (body.stopWhen !== undefined) pipeline.stopWhen = body.stopWhen;
4045
4047
  savePipeline(pipeline);
4046
4048
  invalidateStatusCache();
4047
4049
  return jsonReply(res, 200, { ok: true });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * engine/pipeline.js — Multi-stage pipeline orchestration.
3
- * Pipelines chain stages (task, meeting, plan, merge-prs, api, wait, parallel)
4
- * with dependency tracking and artifact discovery.
3
+ * Pipelines chain stages (task, meeting, plan, merge-prs, api, wait, parallel, condition)
4
+ * with dependency tracking, artifact discovery, and conditional stop/termination.
5
5
  */
6
6
 
7
7
  const fs = require('fs');
@@ -124,6 +124,79 @@ function resolveStageConfig(stage, run) {
124
124
  return resolved;
125
125
  }
126
126
 
127
+ // ── Condition Evaluation ─────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Evaluate a pipeline condition. Used by both `stopWhen` and `condition` stages.
131
+ * @param {string|object} condition — condition name (string shorthand) or { check, ... } object
132
+ * @param {{ run, pipeline, config }} ctx — evaluation context
133
+ * @returns {boolean} whether the condition is met
134
+ */
135
+ function evaluateCondition(condition, ctx) {
136
+ const { run, pipeline, config } = ctx;
137
+ // Normalize: string shorthand → { check: string }
138
+ const cond = typeof condition === 'string' ? { check: condition } : (condition || {});
139
+
140
+ switch (cond.check) {
141
+ case 'runSucceeded': {
142
+ // True when the current/last run completed with all stages succeeded (none failed)
143
+ if (!run) return false;
144
+ return Object.values(run.stages || {}).every(
145
+ s => s.status === PIPELINE_STATUS.COMPLETED || s.status === PIPELINE_STATUS.PENDING
146
+ );
147
+ }
148
+ case 'noFailedItems': {
149
+ // True when all work items created by the pipeline are done (not failed)
150
+ if (!run) return false;
151
+ const wiPath = path.join(__dirname, '..', 'work-items.json');
152
+ const workItems = safeJson(wiPath) || [];
153
+ const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
154
+ return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
155
+ }, []);
156
+ const all = [...workItems, ...allProjectWi];
157
+ const pipelineWis = all.filter(w => w._pipelineRun === run.runId);
158
+ if (pipelineWis.length === 0) return true; // no items = nothing failed
159
+ return pipelineWis.every(w => w.status !== WI_STATUS.FAILED);
160
+ }
161
+ case 'maxRuns': {
162
+ // True when total run count for this pipeline >= threshold
163
+ const threshold = cond.value || 1;
164
+ const runs = getPipelineRuns();
165
+ const pipelineRuns = runs[pipeline?.id] || [];
166
+ return pipelineRuns.length >= threshold;
167
+ }
168
+ case 'allBuildsGreen': {
169
+ // True when all PRs in monitoredResources (or run artifacts) have buildStatus 'passing'
170
+ const projects = shared.getProjects(config);
171
+ const allPrs = projects.reduce((acc, p) => {
172
+ return acc.concat(safeJson(shared.projectPrPath(p)) || []);
173
+ }, []);
174
+
175
+ // Collect PR IDs to check: from monitoredResources (type: 'pr') or run artifact PRs
176
+ const prIds = new Set();
177
+ for (const res of (pipeline?.monitoredResources || [])) {
178
+ const r = typeof res === 'string' ? { label: res } : res;
179
+ if (r.type === 'pr' && r.label) prIds.add(r.label);
180
+ }
181
+ // Also check PRs from run artifacts
182
+ if (run) {
183
+ for (const [, s] of Object.entries(run.stages || {})) {
184
+ for (const prId of (s.artifacts?.prs || [])) prIds.add(prId);
185
+ }
186
+ }
187
+ if (prIds.size === 0) return false; // no PRs to check = can't confirm green
188
+ for (const prId of prIds) {
189
+ const pr = allPrs.find(p => p.id === prId);
190
+ if (!pr || pr.buildStatus !== 'passing') return false;
191
+ }
192
+ return true;
193
+ }
194
+ default:
195
+ log('warn', `Pipeline condition: unknown check '${cond.check}'`);
196
+ return false;
197
+ }
198
+ }
199
+
127
200
  // ── Stage Execution ──────────────────────────────────────────────────────────
128
201
 
129
202
  async function executeStage(stage, run, pipeline, config) {
@@ -143,6 +216,8 @@ async function executeStage(stage, run, pipeline, config) {
143
216
  return executeMergePrsStage(resolved, stageState, run, config);
144
217
  case STAGE_TYPE.SCHEDULE:
145
218
  return executeScheduleStage(resolved, stageState, config);
219
+ case STAGE_TYPE.CONDITION:
220
+ return executeConditionStage(resolved, stageState, run, pipeline, config);
146
221
  case STAGE_TYPE.WAIT:
147
222
  // wait stages just sit in waiting-human status until continued via API
148
223
  return { status: PIPELINE_STATUS.WAITING_HUMAN };
@@ -375,6 +450,38 @@ function executeScheduleStage(stage, stageState, config) {
375
450
  return { status: PIPELINE_STATUS.COMPLETED, completedAt: ts() };
376
451
  }
377
452
 
453
+ function executeConditionStage(stage, stageState, run, pipeline, config) {
454
+ const check = stage.check || stage.condition;
455
+ if (!check) {
456
+ log('warn', `Pipeline ${pipeline.id}: condition stage ${stage.id} has no check defined`);
457
+ return { status: PIPELINE_STATUS.FAILED, error: 'no check defined' };
458
+ }
459
+
460
+ const met = evaluateCondition(check, { run, pipeline, config });
461
+ const action = met ? (stage.onMet || 'complete') : (stage.onUnmet || 'fail');
462
+ log('info', `Pipeline ${pipeline.id}: condition '${typeof check === 'string' ? check : check.check}' → ${met ? 'met' : 'unmet'}, action: ${action}`);
463
+
464
+ switch (action) {
465
+ case 'complete':
466
+ case 'continue':
467
+ return { status: PIPELINE_STATUS.COMPLETED, completedAt: ts(), output: `Condition ${met ? 'met' : 'unmet'}` };
468
+ case 'fail':
469
+ return { status: PIPELINE_STATUS.FAILED, error: `Condition ${met ? 'met' : 'unmet'}` };
470
+ case 'stop-pipeline':
471
+ // Disable the pipeline and complete the run
472
+ pipeline.enabled = false;
473
+ pipeline._stoppedBy = stage.id;
474
+ pipeline._stoppedAt = ts();
475
+ pipeline._stopReason = `Condition '${typeof check === 'string' ? check : check.check}' ${met ? 'met' : 'unmet'}`;
476
+ savePipeline(pipeline);
477
+ log('info', `Pipeline ${pipeline.id}: auto-disabled by condition stage ${stage.id}`);
478
+ return { status: PIPELINE_STATUS.COMPLETED, completedAt: ts(), output: `Pipeline stopped: condition ${met ? 'met' : 'unmet'}`, _stopPipeline: true };
479
+ default:
480
+ log('warn', `Pipeline ${pipeline.id}: unknown condition action '${action}'`);
481
+ return { status: PIPELINE_STATUS.FAILED, error: `unknown action '${action}'` };
482
+ }
483
+ }
484
+
378
485
  async function executeParallelStage(stage, stageState, run, pipeline, config) {
379
486
  const subStages = stage.stages || [];
380
487
  const subResults = {};
@@ -496,7 +603,8 @@ function isStageComplete(stage, stageState, run, config) {
496
603
  }
497
604
  case STAGE_TYPE.API:
498
605
  case STAGE_TYPE.SCHEDULE:
499
- return true; // fire-and-forget
606
+ case STAGE_TYPE.CONDITION:
607
+ return true; // immediate — resolved synchronously on execute
500
608
  case STAGE_TYPE.WAIT:
501
609
  return stageState.status === PIPELINE_STATUS.COMPLETED;
502
610
  case STAGE_TYPE.PARALLEL: {
@@ -626,6 +734,12 @@ async function discoverPipelineWork(config) {
626
734
  updateRunStage(pipeline.id, activeRun.runId, stage.id, { ...result, startedAt: ts() });
627
735
  Object.assign(stageState, result, { startedAt: ts() });
628
736
  if (result.status === PIPELINE_STATUS.RUNNING) anyRunning = true;
737
+ // Condition stage signaled pipeline stop — complete the run immediately
738
+ if (result._stopPipeline) {
739
+ completeRun(pipeline.id, activeRun.runId, PIPELINE_STATUS.STOPPED);
740
+ allComplete = true;
741
+ break;
742
+ }
629
743
  log('info', `Pipeline ${pipeline.id}: started stage ${stage.id} (${stage.type})`);
630
744
  }
631
745
  }
@@ -636,7 +750,25 @@ async function discoverPipelineWork(config) {
636
750
 
637
751
  // Check if run is done
638
752
  if (allComplete) {
639
- completeRun(pipeline.id, activeRun.runId, PIPELINE_STATUS.COMPLETED);
753
+ // Only complete if not already stopped by a condition stage
754
+ if (activeRun.status !== PIPELINE_STATUS.STOPPED) {
755
+ completeRun(pipeline.id, activeRun.runId, PIPELINE_STATUS.COMPLETED);
756
+ }
757
+
758
+ // Evaluate top-level stopWhen after successful run completion
759
+ if (pipeline.stopWhen && pipeline.enabled !== false) {
760
+ try {
761
+ const stopMet = evaluateCondition(pipeline.stopWhen, { run: activeRun, pipeline, config });
762
+ if (stopMet) {
763
+ pipeline.enabled = false;
764
+ pipeline._stoppedBy = 'stopWhen';
765
+ pipeline._stoppedAt = ts();
766
+ pipeline._stopReason = `stopWhen condition '${typeof pipeline.stopWhen === 'string' ? pipeline.stopWhen : (pipeline.stopWhen.check || 'unknown')}' met`;
767
+ savePipeline(pipeline);
768
+ log('info', `Pipeline ${pipeline.id}: auto-disabled by stopWhen condition`);
769
+ }
770
+ } catch (e) { log('warn', `Pipeline ${pipeline.id}: stopWhen evaluation failed: ${e.message}`); }
771
+ }
640
772
  } else if (anyFailed && !anyRunning) {
641
773
  completeRun(pipeline.id, activeRun.runId, PIPELINE_STATUS.FAILED);
642
774
  }
@@ -648,4 +780,5 @@ module.exports = {
648
780
  getPipelines, getPipeline, savePipeline, deletePipeline,
649
781
  getPipelineRuns, getActiveRun, startRun, updateRunStage, completeRun,
650
782
  discoverPipelineWork,
783
+ evaluateCondition, // exported for testing
651
784
  };
package/engine/shared.js CHANGED
@@ -558,10 +558,12 @@ const DISPATCH_RESULT = { SUCCESS: 'success', ERROR: 'error', TIMEOUT: 'timeout'
558
558
  const PIPELINE_STATUS = {
559
559
  PENDING: 'pending', RUNNING: 'running', COMPLETED: 'completed',
560
560
  FAILED: 'failed', PAUSED: 'paused', WAITING_HUMAN: 'waiting-human',
561
+ STOPPED: 'stopped', // pipeline auto-disabled by stopWhen or condition stage
561
562
  };
562
563
  const STAGE_TYPE = {
563
564
  TASK: 'task', MEETING: 'meeting', PLAN: 'plan', API: 'api',
564
565
  MERGE_PRS: 'merge-prs', SCHEDULE: 'schedule', WAIT: 'wait', PARALLEL: 'parallel',
566
+ CONDITION: 'condition',
565
567
  };
566
568
  const MEETING_STATUS = {
567
569
  INVESTIGATING: 'investigating', DEBATING: 'debating', CONCLUDING: 'concluding',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.601",
3
+ "version": "0.1.602",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"