@yemi33/minions 0.1.130 → 0.1.131
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 +14 -0
- package/dashboard/js/refresh.js +1 -0
- package/dashboard/js/render-pipelines.js +182 -0
- package/dashboard/layout.html +1 -0
- package/dashboard/pages/pipelines.html +6 -0
- package/dashboard.js +66 -2
- package/engine/ado.js +1 -1
- package/engine/pipeline.js +569 -0
- package/engine.js +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.131 (2026-04-01)
|
|
4
|
+
|
|
5
|
+
### Engine
|
|
6
|
+
- engine.js
|
|
7
|
+
- engine/ado.js
|
|
8
|
+
- engine/pipeline.js
|
|
9
|
+
|
|
10
|
+
### Dashboard
|
|
11
|
+
- dashboard.js
|
|
12
|
+
- dashboard/js/refresh.js
|
|
13
|
+
- dashboard/js/render-pipelines.js
|
|
14
|
+
- dashboard/layout.html
|
|
15
|
+
- dashboard/pages/pipelines.html
|
|
16
|
+
|
|
3
17
|
## 0.1.130 (2026-04-01)
|
|
4
18
|
|
|
5
19
|
### Engine
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -67,6 +67,7 @@ function _processStatusUpdate(data) {
|
|
|
67
67
|
renderMcpServers(data.mcpServers || []);
|
|
68
68
|
renderSchedules(data.schedules || []);
|
|
69
69
|
renderMeetings(data.meetings || []);
|
|
70
|
+
if (typeof renderPipelines === 'function') renderPipelines(data.pipelines || []);
|
|
70
71
|
renderPinned(data.pinned || []);
|
|
71
72
|
// Update sidebar counts
|
|
72
73
|
const swi = document.getElementById('sidebar-wi');
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// render-pipelines.js — Pipeline list, run detail, and create modal
|
|
2
|
+
|
|
3
|
+
let _pipelinesData = [];
|
|
4
|
+
|
|
5
|
+
function renderPipelines(pipelines) {
|
|
6
|
+
_pipelinesData = pipelines || [];
|
|
7
|
+
const el = document.getElementById('pipelines-content');
|
|
8
|
+
const countEl = document.getElementById('pipelines-count');
|
|
9
|
+
if (!el) return;
|
|
10
|
+
if (!pipelines || pipelines.length === 0) {
|
|
11
|
+
countEl.textContent = '0';
|
|
12
|
+
el.innerHTML = '<p class="empty">No pipelines yet. Create one to chain stages like audit \u2192 meeting \u2192 plan \u2192 merge.</p>';
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
countEl.textContent = pipelines.length;
|
|
16
|
+
|
|
17
|
+
el.innerHTML = pipelines.map(function(p) {
|
|
18
|
+
const activeRun = (p.runs || []).find(function(r) { return r.status === 'running'; });
|
|
19
|
+
const lastRun = (p.runs || []).slice(-1)[0];
|
|
20
|
+
const statusColor = activeRun ? 'var(--blue)' : lastRun?.status === 'completed' ? 'var(--green)' : lastRun?.status === 'failed' ? 'var(--red)' : 'var(--muted)';
|
|
21
|
+
const statusLabel = activeRun ? 'Running' : lastRun ? (lastRun.status === 'completed' ? 'Completed' : lastRun.status === 'failed' ? 'Failed' : lastRun.status) : 'Never run';
|
|
22
|
+
const trigger = p.trigger?.cron ? 'Cron: ' + p.trigger.cron : 'Manual';
|
|
23
|
+
|
|
24
|
+
// Stage flow visualization
|
|
25
|
+
var stageFlow = (p.stages || []).map(function(s) {
|
|
26
|
+
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';
|
|
27
|
+
var stageStatus = activeRun?.stages?.[s.id]?.status || 'pending';
|
|
28
|
+
var color = stageStatus === 'completed' ? 'var(--green)' : stageStatus === 'running' ? 'var(--blue)' : stageStatus === 'failed' ? 'var(--red)' : stageStatus === 'waiting-human' ? 'var(--yellow)' : 'var(--muted)';
|
|
29
|
+
return '<span style="color:' + color + ';font-size:11px" title="' + escHtml(s.id) + ': ' + escHtml(s.title || s.type) + ' (' + stageStatus + ')">' + icon + ' ' + escHtml(s.id) + '</span>';
|
|
30
|
+
}).join(' <span style="color:var(--border)">\u2192</span> ');
|
|
31
|
+
|
|
32
|
+
return '<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:8px;cursor:pointer" onclick="openPipelineDetail(\'' + escHtml(p.id) + '\')">' +
|
|
33
|
+
'<div style="display:flex;justify-content:space-between;align-items:center">' +
|
|
34
|
+
'<strong style="font-size:13px">' + escHtml(p.title) + '</strong>' +
|
|
35
|
+
'<div style="display:flex;align-items:center;gap:8px">' +
|
|
36
|
+
'<span style="color:' + statusColor + ';font-size:11px;font-weight:600">' + statusLabel + '</span>' +
|
|
37
|
+
'<span style="font-size:10px;color:var(--muted)">' + escHtml(trigger) + '</span>' +
|
|
38
|
+
(p.enabled === false ? '<span style="font-size:9px;color:var(--red)">DISABLED</span>' : '') +
|
|
39
|
+
'</div>' +
|
|
40
|
+
'</div>' +
|
|
41
|
+
'<div style="margin-top:6px;display:flex;gap:4px;align-items:center;flex-wrap:wrap">' + stageFlow + '</div>' +
|
|
42
|
+
'</div>';
|
|
43
|
+
}).join('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function openPipelineDetail(id) {
|
|
47
|
+
var p = _pipelinesData.find(function(x) { return x.id === id; });
|
|
48
|
+
if (!p) { alert('Pipeline not found'); return; }
|
|
49
|
+
|
|
50
|
+
var html = '<div style="display:flex;flex-direction:column;gap:12px">';
|
|
51
|
+
|
|
52
|
+
// Status + actions
|
|
53
|
+
var activeRun = (p.runs || []).find(function(r) { return r.status === 'running'; });
|
|
54
|
+
html += '<div style="display:flex;justify-content:space-between;align-items:center">' +
|
|
55
|
+
'<span style="font-size:10px;color:var(--muted)">' + (p.trigger?.cron ? 'Cron: ' + p.trigger.cron : 'Manual trigger') + '</span>' +
|
|
56
|
+
'<div style="display:flex;gap:6px">' +
|
|
57
|
+
(activeRun ? '' : '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);border-color:var(--green)" onclick="_triggerPipeline(\'' + escHtml(id) + '\',this)">Run Now</button>') +
|
|
58
|
+
'<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" onclick="_togglePipelineEnabled(\'' + escHtml(id) + '\',' + !p.enabled + ',this)">' + (p.enabled !== false ? 'Disable' : 'Enable') + '</button>' +
|
|
59
|
+
'<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red);border-color:var(--red)" onclick="_deletePipelineConfirm(\'' + escHtml(id) + '\')">Delete</button>' +
|
|
60
|
+
'</div>' +
|
|
61
|
+
'</div>';
|
|
62
|
+
|
|
63
|
+
// Stage detail
|
|
64
|
+
html += '<h4 style="font-size:12px;color:var(--blue);margin:0">Stages</h4>';
|
|
65
|
+
(p.stages || []).forEach(function(s, i) {
|
|
66
|
+
var stageRun = activeRun?.stages?.[s.id] || {};
|
|
67
|
+
var stageStatus = stageRun.status || 'pending';
|
|
68
|
+
var statusColor = stageStatus === 'completed' ? 'var(--green)' : stageStatus === 'running' ? 'var(--blue)' : stageStatus === 'failed' ? 'var(--red)' : stageStatus === 'waiting-human' ? 'var(--yellow)' : 'var(--muted)';
|
|
69
|
+
var deps = (s.dependsOn || []).join(', ') || 'none';
|
|
70
|
+
|
|
71
|
+
html += '<div style="border:1px solid var(--border);border-radius:6px;padding:8px 12px;background:var(--surface2)">' +
|
|
72
|
+
'<div style="display:flex;justify-content:space-between;align-items:center">' +
|
|
73
|
+
'<span style="font-weight:600;font-size:12px">' + (i + 1) + '. ' + escHtml(s.title || s.id) + '</span>' +
|
|
74
|
+
'<span style="color:' + statusColor + ';font-size:10px;font-weight:600">' + stageStatus.toUpperCase() + '</span>' +
|
|
75
|
+
'</div>' +
|
|
76
|
+
'<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>' +
|
|
77
|
+
(stageRun.output ? '<div style="margin-top:6px;font-size:11px;max-height:150px;overflow-y:auto">' + renderMd(stageRun.output.slice(0, 500)) + '</div>' : '') +
|
|
78
|
+
(stageStatus === 'waiting-human' ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);border-color:var(--green);margin-top:6px" onclick="_continuePipeline(\'' + escHtml(id) + '\',\'' + escHtml(s.id) + '\',this)">Continue</button>' : '') +
|
|
79
|
+
'</div>';
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Run history
|
|
83
|
+
var runs = (p.runs || []).slice(-5).reverse();
|
|
84
|
+
if (runs.length > 0) {
|
|
85
|
+
html += '<h4 style="font-size:12px;color:var(--blue);margin:0">Recent Runs</h4>';
|
|
86
|
+
runs.forEach(function(r) {
|
|
87
|
+
var color = r.status === 'completed' ? 'var(--green)' : r.status === 'failed' ? 'var(--red)' : r.status === 'running' ? 'var(--blue)' : 'var(--muted)';
|
|
88
|
+
html += '<div style="font-size:10px;display:flex;gap:8px;align-items:center">' +
|
|
89
|
+
'<span style="color:' + color + ';font-weight:600">' + r.status + '</span>' +
|
|
90
|
+
'<span style="color:var(--muted)">' + (r.startedAt ? new Date(r.startedAt).toLocaleString() : '') + '</span>' +
|
|
91
|
+
(r.completedAt ? '<span style="color:var(--muted)">\u2192 ' + new Date(r.completedAt).toLocaleString() + '</span>' : '') +
|
|
92
|
+
'</div>';
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
html += '</div>';
|
|
97
|
+
|
|
98
|
+
document.getElementById('modal-title').textContent = 'Pipeline: ' + p.title;
|
|
99
|
+
document.getElementById('modal-body').innerHTML = html;
|
|
100
|
+
document.getElementById('modal').classList.add('open');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function _triggerPipeline(id, btn) {
|
|
104
|
+
if (btn) { btn.textContent = 'Starting...'; btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6'; }
|
|
105
|
+
try {
|
|
106
|
+
var res = await fetch('/api/pipelines/trigger', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id }) });
|
|
107
|
+
var d = await res.json();
|
|
108
|
+
if (res.ok) { showToast('cmd-toast', 'Pipeline triggered: ' + (d.runId || ''), true); try { closeModal(); } catch {} refresh(); }
|
|
109
|
+
else { if (btn) { btn.textContent = 'Run Now'; btn.style.pointerEvents = ''; btn.style.opacity = ''; } alert('Failed: ' + (d.error || 'unknown')); }
|
|
110
|
+
} catch (e) { if (btn) { btn.textContent = 'Run Now'; btn.style.pointerEvents = ''; btn.style.opacity = ''; } alert('Error: ' + e.message); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function _togglePipelineEnabled(id, enabled, btn) {
|
|
114
|
+
if (btn) { btn.textContent = enabled ? 'Enabling...' : 'Disabling...'; btn.style.pointerEvents = 'none'; }
|
|
115
|
+
try {
|
|
116
|
+
var res = await fetch('/api/pipelines/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, enabled: enabled }) });
|
|
117
|
+
if (res.ok) { showToast('cmd-toast', enabled ? 'Pipeline enabled' : 'Pipeline disabled', true); refresh(); }
|
|
118
|
+
else { alert('Failed'); }
|
|
119
|
+
} catch (e) { alert('Error: ' + e.message); }
|
|
120
|
+
if (btn) { btn.textContent = enabled ? 'Disable' : 'Enable'; btn.style.pointerEvents = ''; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function _continuePipeline(id, stageId, btn) {
|
|
124
|
+
if (btn) { btn.textContent = 'Continuing...'; btn.style.pointerEvents = 'none'; }
|
|
125
|
+
try {
|
|
126
|
+
var res = await fetch('/api/pipelines/continue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, stageId: stageId }) });
|
|
127
|
+
if (res.ok) { showToast('cmd-toast', 'Stage continued', true); openPipelineDetail(id); }
|
|
128
|
+
else { var d = await res.json().catch(function() { return {}; }); alert('Failed: ' + (d.error || 'unknown')); }
|
|
129
|
+
} catch (e) { alert('Error: ' + e.message); }
|
|
130
|
+
if (btn) { btn.textContent = 'Continue'; btn.style.pointerEvents = ''; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function _deletePipelineConfirm(id) {
|
|
134
|
+
if (!confirm('Delete pipeline "' + id + '"?')) return;
|
|
135
|
+
markDeleted('pipeline:' + id);
|
|
136
|
+
try { closeModal(); } catch {}
|
|
137
|
+
try {
|
|
138
|
+
var res = await fetch('/api/pipelines/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id }) });
|
|
139
|
+
if (!res.ok) { alert('Delete failed'); refresh(); }
|
|
140
|
+
} catch (e) { alert('Error: ' + e.message); refresh(); }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function openCreatePipelineModal() {
|
|
144
|
+
var inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
|
|
145
|
+
|
|
146
|
+
document.getElementById('modal-title').textContent = 'New Pipeline';
|
|
147
|
+
document.getElementById('modal-body').innerHTML =
|
|
148
|
+
'<div style="display:flex;flex-direction:column;gap:10px">' +
|
|
149
|
+
'<label style="color:var(--text);font-size:var(--text-md)">ID<input id="pl-id" style="' + inputStyle + '" placeholder="e.g. daily-audit-cycle"></label>' +
|
|
150
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Title<input id="pl-title" style="' + inputStyle + '" placeholder="e.g. Daily audit and improvement cycle"></label>' +
|
|
151
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Trigger (cron, optional)<input id="pl-cron" style="' + inputStyle + '" placeholder="e.g. 0 9 * (9am daily) — leave empty for manual"></label>' +
|
|
152
|
+
'<label style="color:var(--text);font-size:var(--text-md)">Stages (JSON array)<textarea id="pl-stages" rows="10" style="' + inputStyle + ';resize:vertical;font-family:Consolas,monospace" placeholder=\'[{"id":"audit","type":"task","title":"Audit codebase","taskType":"explore"},{"id":"discuss","type":"meeting","title":"Discuss findings","dependsOn":["audit"],"participants":["all"]}]\'></textarea></label>' +
|
|
153
|
+
'<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:4px">' +
|
|
154
|
+
'<button onclick="closeModal()" class="pr-pager-btn">Cancel</button>' +
|
|
155
|
+
'<button onclick="_submitCreatePipeline()" style="padding:6px 16px;background:var(--blue);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer">Create Pipeline</button>' +
|
|
156
|
+
'</div>' +
|
|
157
|
+
'</div>';
|
|
158
|
+
document.getElementById('modal').classList.add('open');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function _submitCreatePipeline() {
|
|
162
|
+
var id = document.getElementById('pl-id')?.value?.trim();
|
|
163
|
+
var title = document.getElementById('pl-title')?.value?.trim();
|
|
164
|
+
var cron = document.getElementById('pl-cron')?.value?.trim();
|
|
165
|
+
var stagesRaw = document.getElementById('pl-stages')?.value?.trim();
|
|
166
|
+
if (!id || !title) { alert('ID and title required'); return; }
|
|
167
|
+
var stages;
|
|
168
|
+
try { stages = JSON.parse(stagesRaw); } catch (e) { alert('Invalid JSON in stages: ' + e.message); return; }
|
|
169
|
+
if (!Array.isArray(stages) || stages.length === 0) { alert('Stages must be a non-empty array'); return; }
|
|
170
|
+
|
|
171
|
+
var body = { id: id, title: title, stages: stages };
|
|
172
|
+
if (cron) body.trigger = { cron: cron };
|
|
173
|
+
|
|
174
|
+
try { closeModal(); } catch {}
|
|
175
|
+
showToast('cmd-toast', 'Pipeline created', true);
|
|
176
|
+
try {
|
|
177
|
+
var res = await fetch('/api/pipelines', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
178
|
+
if (res.ok) { refresh(); } else { var d = await res.json().catch(function() { return {}; }); alert('Failed: ' + (d.error || 'unknown')); openCreatePipelineModal(); }
|
|
179
|
+
} catch (e) { alert('Error: ' + e.message); openCreatePipelineModal(); }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
window.MinionsPipelines = { renderPipelines, openPipelineDetail, openCreatePipelineModal };
|
package/dashboard/layout.html
CHANGED
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
<a class="sidebar-link" data-page="inbox" href="/inbox">Notes & KB</a>
|
|
65
65
|
<a class="sidebar-link" data-page="tools" href="/tools">Skills & MCP</a>
|
|
66
66
|
<a class="sidebar-link" data-page="schedule" href="/schedule">Schedules</a>
|
|
67
|
+
<a class="sidebar-link" data-page="pipelines" href="/pipelines">Pipelines</a>
|
|
67
68
|
<a class="sidebar-link" data-page="meetings" href="/meetings">Meetings</a>
|
|
68
69
|
<a class="sidebar-link" data-page="engine" href="/engine">Engine</a>
|
|
69
70
|
</nav>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<section>
|
|
2
|
+
<h2>Pipelines <span class="count" id="pipelines-count">0</span>
|
|
3
|
+
<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreatePipelineModal()">+ New Pipeline</button>
|
|
4
|
+
</h2>
|
|
5
|
+
<div id="pipelines-content"><p class="empty">No pipelines yet. Create one to chain stages like audit → meeting → plan → merge.</p></div>
|
|
6
|
+
</section>
|
package/dashboard.js
CHANGED
|
@@ -68,7 +68,7 @@ function buildDashboardHtml() {
|
|
|
68
68
|
const css = safeRead(path.join(dashDir, 'styles.css'));
|
|
69
69
|
|
|
70
70
|
// Assemble page fragments
|
|
71
|
-
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'meetings', 'engine'];
|
|
71
|
+
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'pipelines', 'meetings', 'engine'];
|
|
72
72
|
let pageHtml = '';
|
|
73
73
|
for (const p of pages) {
|
|
74
74
|
const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
|
|
@@ -81,7 +81,7 @@ function buildDashboardHtml() {
|
|
|
81
81
|
'utils', 'state', 'detail-panel', 'live-stream',
|
|
82
82
|
'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
|
|
83
83
|
'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
|
|
84
|
-
'render-other', 'render-schedules', 'render-meetings', 'render-pinned',
|
|
84
|
+
'render-other', 'render-schedules', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
85
85
|
'command-parser', 'command-input', 'command-center', 'command-history',
|
|
86
86
|
'modal', 'modal-qa', 'settings', 'refresh'
|
|
87
87
|
];
|
|
@@ -237,6 +237,7 @@ function getStatus() {
|
|
|
237
237
|
return scheds.map(s => ({ ...s, _lastRun: runs[s.id] || null }));
|
|
238
238
|
})(),
|
|
239
239
|
meetings: (() => { try { return require('./engine/meeting').getMeetings(); } catch { return []; } })(),
|
|
240
|
+
pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
|
|
240
241
|
pinned: (() => { try { return parsePinnedEntries(safeRead(path.join(MINIONS_DIR, 'pinned.md'))); } catch { return []; } })(),
|
|
241
242
|
projects: PROJECTS.map(p => ({ name: p.name, path: p.localPath, description: p.description || '' })),
|
|
242
243
|
autoMode: {
|
|
@@ -3421,6 +3422,69 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3421
3422
|
{ method: 'POST', path: '/api/schedules/update', desc: 'Update an existing schedule', params: 'id, cron?, title?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesUpdate },
|
|
3422
3423
|
{ method: 'POST', path: '/api/schedules/delete', desc: 'Delete a schedule', params: 'id', handler: handleSchedulesDelete },
|
|
3423
3424
|
|
|
3425
|
+
// Pipelines
|
|
3426
|
+
{ method: 'GET', path: '/api/pipelines', desc: 'List all pipelines with runs', handler: async (req, res) => {
|
|
3427
|
+
const { getPipelines, getPipelineRuns } = require('./engine/pipeline');
|
|
3428
|
+
const pipelines = getPipelines();
|
|
3429
|
+
const runs = getPipelineRuns();
|
|
3430
|
+
const result = pipelines.map(p => ({ ...p, runs: (runs[p.id] || []).slice(-5) }));
|
|
3431
|
+
return jsonReply(res, 200, result);
|
|
3432
|
+
}},
|
|
3433
|
+
{ method: 'POST', path: '/api/pipelines', desc: 'Create a pipeline', params: 'id, title, stages[], trigger?', handler: async (req, res) => {
|
|
3434
|
+
const body = await readBody(req);
|
|
3435
|
+
if (!body.id || !body.title || !body.stages) return jsonReply(res, 400, { error: 'id, title, and stages required' });
|
|
3436
|
+
const { savePipeline, getPipeline } = require('./engine/pipeline');
|
|
3437
|
+
if (getPipeline(body.id)) return jsonReply(res, 409, { error: 'Pipeline already exists' });
|
|
3438
|
+
const pipeline = { id: body.id, title: body.title, stages: body.stages, trigger: body.trigger || {}, enabled: body.enabled !== false };
|
|
3439
|
+
savePipeline(pipeline);
|
|
3440
|
+
invalidateStatusCache();
|
|
3441
|
+
return jsonReply(res, 200, { ok: true, id: pipeline.id });
|
|
3442
|
+
}},
|
|
3443
|
+
{ method: 'POST', path: '/api/pipelines/update', desc: 'Update a pipeline', params: 'id, title?, stages?, trigger?, enabled?', handler: async (req, res) => {
|
|
3444
|
+
const body = await readBody(req);
|
|
3445
|
+
if (!body.id) return jsonReply(res, 400, { error: 'id required' });
|
|
3446
|
+
const { getPipeline, savePipeline } = require('./engine/pipeline');
|
|
3447
|
+
const pipeline = getPipeline(body.id);
|
|
3448
|
+
if (!pipeline) return jsonReply(res, 404, { error: 'Pipeline not found' });
|
|
3449
|
+
if (body.title !== undefined) pipeline.title = body.title;
|
|
3450
|
+
if (body.stages !== undefined) pipeline.stages = body.stages;
|
|
3451
|
+
if (body.trigger !== undefined) pipeline.trigger = body.trigger;
|
|
3452
|
+
if (body.enabled !== undefined) pipeline.enabled = body.enabled;
|
|
3453
|
+
savePipeline(pipeline);
|
|
3454
|
+
invalidateStatusCache();
|
|
3455
|
+
return jsonReply(res, 200, { ok: true });
|
|
3456
|
+
}},
|
|
3457
|
+
{ method: 'POST', path: '/api/pipelines/delete', desc: 'Delete a pipeline', params: 'id', handler: async (req, res) => {
|
|
3458
|
+
const body = await readBody(req);
|
|
3459
|
+
if (!body.id) return jsonReply(res, 400, { error: 'id required' });
|
|
3460
|
+
const { deletePipeline } = require('./engine/pipeline');
|
|
3461
|
+
if (!deletePipeline(body.id)) return jsonReply(res, 404, { error: 'Pipeline not found' });
|
|
3462
|
+
invalidateStatusCache();
|
|
3463
|
+
return jsonReply(res, 200, { ok: true });
|
|
3464
|
+
}},
|
|
3465
|
+
{ method: 'POST', path: '/api/pipelines/trigger', desc: 'Manually trigger a pipeline run', params: 'id', handler: async (req, res) => {
|
|
3466
|
+
const body = await readBody(req);
|
|
3467
|
+
if (!body.id) return jsonReply(res, 400, { error: 'id required' });
|
|
3468
|
+
const { getPipeline, getActiveRun, startRun } = require('./engine/pipeline');
|
|
3469
|
+
const pipeline = getPipeline(body.id);
|
|
3470
|
+
if (!pipeline) return jsonReply(res, 404, { error: 'Pipeline not found' });
|
|
3471
|
+
if (getActiveRun(body.id)) return jsonReply(res, 409, { error: 'Pipeline already has an active run' });
|
|
3472
|
+
const run = startRun(body.id, pipeline);
|
|
3473
|
+
invalidateStatusCache();
|
|
3474
|
+
return jsonReply(res, 200, { ok: true, runId: run.runId });
|
|
3475
|
+
}},
|
|
3476
|
+
{ method: 'POST', path: '/api/pipelines/continue', desc: 'Continue a pipeline past a wait stage', params: 'id, stageId', handler: async (req, res) => {
|
|
3477
|
+
const body = await readBody(req);
|
|
3478
|
+
if (!body.id || !body.stageId) return jsonReply(res, 400, { error: 'id and stageId required' });
|
|
3479
|
+
const { updateRunStage, getActiveRun } = require('./engine/pipeline');
|
|
3480
|
+
const run = getActiveRun(body.id);
|
|
3481
|
+
if (!run) return jsonReply(res, 404, { error: 'No active run' });
|
|
3482
|
+
if (run.stages[body.stageId]?.status !== 'waiting-human') return jsonReply(res, 400, { error: 'Stage is not waiting for human' });
|
|
3483
|
+
updateRunStage(body.id, run.runId, body.stageId, { status: 'completed', completedAt: new Date().toISOString() });
|
|
3484
|
+
invalidateStatusCache();
|
|
3485
|
+
return jsonReply(res, 200, { ok: true });
|
|
3486
|
+
}},
|
|
3487
|
+
|
|
3424
3488
|
// Meetings
|
|
3425
3489
|
{ method: 'POST', path: '/api/meetings', desc: 'Create a team meeting', params: 'title, agenda, participants[]', handler: async (req, res) => {
|
|
3426
3490
|
const body = await readBody(req);
|
package/engine/ado.js
CHANGED
|
@@ -29,7 +29,7 @@ function getAdoToken() {
|
|
|
29
29
|
try {
|
|
30
30
|
// azureauth supports multiple --mode flags as an ordered fallback chain:
|
|
31
31
|
// tries IWA (Integrated Windows Auth) first, falls back to broker if unavailable.
|
|
32
|
-
const token = exec('azureauth ado token --mode
|
|
32
|
+
const token = exec('azureauth ado token --mode broker --mode iwa --output token --timeout 5', {
|
|
33
33
|
timeout: 15000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim();
|
|
34
34
|
if (token && token.startsWith('eyJ')) {
|
|
35
35
|
_adoTokenCache = { token, expiresAt: Date.now() + 30 * 60 * 1000 };
|
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const shared = require('./shared');
|
|
10
|
+
const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked } = shared;
|
|
11
|
+
const { parseCronExpr, shouldRunNow } = require('./scheduler');
|
|
12
|
+
|
|
13
|
+
const PIPELINES_DIR = path.join(__dirname, '..', 'pipelines');
|
|
14
|
+
const PIPELINE_RUNS_PATH = path.join(__dirname, 'pipeline-runs.json');
|
|
15
|
+
|
|
16
|
+
// ── Pipeline CRUD ────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function getPipelines() {
|
|
19
|
+
if (!fs.existsSync(PIPELINES_DIR)) return [];
|
|
20
|
+
return safeReadDir(PIPELINES_DIR)
|
|
21
|
+
.filter(f => f.endsWith('.json'))
|
|
22
|
+
.map(f => safeJson(path.join(PIPELINES_DIR, f)))
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getPipeline(id) {
|
|
27
|
+
const filePath = path.join(PIPELINES_DIR, id + '.json');
|
|
28
|
+
return safeJson(filePath);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function savePipeline(pipeline) {
|
|
32
|
+
if (!fs.existsSync(PIPELINES_DIR)) fs.mkdirSync(PIPELINES_DIR, { recursive: true });
|
|
33
|
+
safeWrite(path.join(PIPELINES_DIR, pipeline.id + '.json'), pipeline);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function deletePipeline(id) {
|
|
37
|
+
const filePath = path.join(PIPELINES_DIR, id + '.json');
|
|
38
|
+
if (!fs.existsSync(filePath)) return false;
|
|
39
|
+
fs.unlinkSync(filePath);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Run State ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function getPipelineRuns() {
|
|
46
|
+
return safeJson(PIPELINE_RUNS_PATH) || {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function savePipelineRuns(runs) {
|
|
50
|
+
safeWrite(PIPELINE_RUNS_PATH, runs);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getActiveRun(pipelineId) {
|
|
54
|
+
const runs = getPipelineRuns();
|
|
55
|
+
const pipelineRuns = runs[pipelineId] || [];
|
|
56
|
+
return pipelineRuns.find(r => r.status === 'running' || r.status === 'paused');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function startRun(pipelineId, pipeline) {
|
|
60
|
+
const runId = `run-${uid()}`;
|
|
61
|
+
const stages = {};
|
|
62
|
+
for (const stage of (pipeline.stages || [])) {
|
|
63
|
+
stages[stage.id] = { status: 'pending', artifacts: {} };
|
|
64
|
+
}
|
|
65
|
+
const run = { runId, pipelineId, startedAt: ts(), status: 'running', stages };
|
|
66
|
+
|
|
67
|
+
mutateJsonFileLocked(PIPELINE_RUNS_PATH, (data) => {
|
|
68
|
+
if (!data[pipelineId]) data[pipelineId] = [];
|
|
69
|
+
// Keep last 10 runs per pipeline
|
|
70
|
+
if (data[pipelineId].length >= 10) data[pipelineId] = data[pipelineId].slice(-9);
|
|
71
|
+
data[pipelineId].push(run);
|
|
72
|
+
return data;
|
|
73
|
+
}, { defaultValue: {} });
|
|
74
|
+
|
|
75
|
+
log('info', `Pipeline ${pipelineId}: started run ${runId}`);
|
|
76
|
+
return run;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function updateRunStage(pipelineId, runId, stageId, updates) {
|
|
80
|
+
mutateJsonFileLocked(PIPELINE_RUNS_PATH, (data) => {
|
|
81
|
+
const runs = data[pipelineId] || [];
|
|
82
|
+
const run = runs.find(r => r.runId === runId);
|
|
83
|
+
if (run && run.stages[stageId]) {
|
|
84
|
+
Object.assign(run.stages[stageId], updates);
|
|
85
|
+
}
|
|
86
|
+
return data;
|
|
87
|
+
}, { defaultValue: {} });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function completeRun(pipelineId, runId, status) {
|
|
91
|
+
mutateJsonFileLocked(PIPELINE_RUNS_PATH, (data) => {
|
|
92
|
+
const runs = data[pipelineId] || [];
|
|
93
|
+
const run = runs.find(r => r.runId === runId);
|
|
94
|
+
if (run) { run.status = status; run.completedAt = ts(); }
|
|
95
|
+
return data;
|
|
96
|
+
}, { defaultValue: {} });
|
|
97
|
+
log('info', `Pipeline ${pipelineId}: run ${runId} → ${status}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Template Resolution ──────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function resolveTemplate(str, run) {
|
|
103
|
+
if (!str || typeof str !== 'string') return str;
|
|
104
|
+
return str.replace(/\{\{stages\.(\w+)\.(\w+)\}\}/g, (_, stageId, field) => {
|
|
105
|
+
const stage = run?.stages?.[stageId];
|
|
106
|
+
if (!stage) return '';
|
|
107
|
+
if (field === 'output') return stage.output || '';
|
|
108
|
+
if (field === 'artifacts') return JSON.stringify(stage.artifacts || {});
|
|
109
|
+
return stage[field] || '';
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveStageConfig(stage, run) {
|
|
114
|
+
const resolved = { ...stage };
|
|
115
|
+
for (const key of ['title', 'description', 'agenda', 'body']) {
|
|
116
|
+
if (typeof resolved[key] === 'string') resolved[key] = resolveTemplate(resolved[key], run);
|
|
117
|
+
if (typeof resolved[key] === 'object' && resolved[key]) {
|
|
118
|
+
for (const k of Object.keys(resolved[key])) {
|
|
119
|
+
if (typeof resolved[key][k] === 'string') resolved[key][k] = resolveTemplate(resolved[key][k], run);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return resolved;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Stage Execution ──────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function executeStage(stage, run, pipeline, config) {
|
|
129
|
+
const resolved = resolveStageConfig(stage, run);
|
|
130
|
+
const stageState = run.stages[stage.id];
|
|
131
|
+
|
|
132
|
+
switch (resolved.type) {
|
|
133
|
+
case 'task':
|
|
134
|
+
return executeTaskStage(resolved, stageState, run, config);
|
|
135
|
+
case 'meeting':
|
|
136
|
+
return executeMeetingStage(resolved, stageState, run, config);
|
|
137
|
+
case 'plan':
|
|
138
|
+
return executePlanStage(resolved, stageState, run, config);
|
|
139
|
+
case 'api':
|
|
140
|
+
return executeApiStage(resolved, stageState, run);
|
|
141
|
+
case 'merge-prs':
|
|
142
|
+
return executeMergePrsStage(resolved, stageState, run, config);
|
|
143
|
+
case 'schedule':
|
|
144
|
+
return executeScheduleStage(resolved, stageState, config);
|
|
145
|
+
case 'wait':
|
|
146
|
+
// wait stages just sit in waiting-human status until continued via API
|
|
147
|
+
return { status: 'waiting-human' };
|
|
148
|
+
case 'parallel':
|
|
149
|
+
return executeParallelStage(resolved, stageState, run, pipeline, config);
|
|
150
|
+
default:
|
|
151
|
+
log('warn', `Pipeline: unknown stage type '${resolved.type}' in stage ${stage.id}`);
|
|
152
|
+
return { status: 'failed', error: 'unknown stage type' };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function executeTaskStage(stage, stageState, run, config) {
|
|
157
|
+
// Create work item(s) for the task
|
|
158
|
+
const items = stage.items || [{ title: stage.title, description: stage.description || '', type: stage.taskType || 'explore', agent: stage.agent }];
|
|
159
|
+
const count = stage.count || items.length;
|
|
160
|
+
const wiPath = path.join(__dirname, '..', 'work-items.json');
|
|
161
|
+
const workItems = safeJson(wiPath) || [];
|
|
162
|
+
const createdIds = [];
|
|
163
|
+
|
|
164
|
+
for (let i = 0; i < count; i++) {
|
|
165
|
+
const item = items[i % items.length];
|
|
166
|
+
const id = `PL-${run.runId.slice(4, 12)}-${stage.id}-${i}`;
|
|
167
|
+
if (workItems.some(w => w.id === id)) { createdIds.push(id); continue; }
|
|
168
|
+
workItems.push({
|
|
169
|
+
id,
|
|
170
|
+
title: item.title || stage.title,
|
|
171
|
+
description: item.description || stage.description || '',
|
|
172
|
+
type: item.type || stage.taskType || 'implement',
|
|
173
|
+
priority: item.priority || stage.priority || 'medium',
|
|
174
|
+
agent: item.agent || stage.agent || '',
|
|
175
|
+
status: 'pending',
|
|
176
|
+
created: ts(),
|
|
177
|
+
createdBy: 'pipeline:' + run.pipelineId,
|
|
178
|
+
_pipelineRun: run.runId,
|
|
179
|
+
_pipelineStage: stage.id,
|
|
180
|
+
});
|
|
181
|
+
createdIds.push(id);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
safeWrite(wiPath, workItems);
|
|
185
|
+
return { status: 'running', artifacts: { workItems: createdIds } };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function executeMeetingStage(stage, stageState, run, config) {
|
|
189
|
+
const { createMeeting } = require('./meeting');
|
|
190
|
+
const agents = config.agents || {};
|
|
191
|
+
const participants = stage.participants?.[0] === 'all'
|
|
192
|
+
? Object.keys(agents)
|
|
193
|
+
: (stage.participants || Object.keys(agents));
|
|
194
|
+
|
|
195
|
+
const meetings = stage.meetings || [{ title: stage.title, agenda: stage.agenda || stage.title }];
|
|
196
|
+
const createdIds = [];
|
|
197
|
+
|
|
198
|
+
for (const mtg of meetings) {
|
|
199
|
+
const meeting = createMeeting({
|
|
200
|
+
title: resolveTemplate(mtg.title, run),
|
|
201
|
+
agenda: resolveTemplate(mtg.agenda || mtg.title, run),
|
|
202
|
+
participants,
|
|
203
|
+
});
|
|
204
|
+
createdIds.push(meeting.id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { status: 'running', artifacts: { meetings: createdIds } };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function executePlanStage(stage, stageState, run, config) {
|
|
211
|
+
// Create a plan .md file from the stage config + previous stage output
|
|
212
|
+
const plansDir = path.join(__dirname, '..', 'plans');
|
|
213
|
+
if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
|
|
214
|
+
|
|
215
|
+
const slug = (stage.title || 'pipeline-plan').toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50);
|
|
216
|
+
const filename = `${slug}-${dateStamp()}.md`;
|
|
217
|
+
const filePath = shared.uniquePath(path.join(plansDir, filename));
|
|
218
|
+
|
|
219
|
+
let content = `# ${stage.title}\n\n`;
|
|
220
|
+
content += `**Created by:** Pipeline ${run.pipelineId}\n`;
|
|
221
|
+
content += `**Date:** ${dateStamp()}\n\n---\n\n`;
|
|
222
|
+
|
|
223
|
+
// Include output from dependency stages
|
|
224
|
+
if (stage.dependsOn) {
|
|
225
|
+
for (const depId of stage.dependsOn) {
|
|
226
|
+
const depStage = run.stages[depId];
|
|
227
|
+
if (depStage?.output) {
|
|
228
|
+
content += `## From: ${depId}\n\n${depStage.output}\n\n`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (stage.description) content += stage.description + '\n';
|
|
234
|
+
|
|
235
|
+
safeWrite(filePath, content);
|
|
236
|
+
|
|
237
|
+
// Create plan-to-prd work item
|
|
238
|
+
const wiPath = path.join(__dirname, '..', 'work-items.json');
|
|
239
|
+
const workItems = safeJson(wiPath) || [];
|
|
240
|
+
const wiId = `PL-${run.runId.slice(4, 12)}-${stage.id}-prd`;
|
|
241
|
+
if (!workItems.some(w => w.id === wiId)) {
|
|
242
|
+
workItems.push({
|
|
243
|
+
id: wiId,
|
|
244
|
+
title: `Convert plan to PRD: ${path.basename(filePath)}`,
|
|
245
|
+
type: 'plan-to-prd',
|
|
246
|
+
priority: 'high',
|
|
247
|
+
status: 'pending',
|
|
248
|
+
planFile: path.basename(filePath),
|
|
249
|
+
created: ts(),
|
|
250
|
+
createdBy: 'pipeline:' + run.pipelineId,
|
|
251
|
+
_pipelineRun: run.runId,
|
|
252
|
+
_pipelineStage: stage.id,
|
|
253
|
+
});
|
|
254
|
+
safeWrite(wiPath, workItems);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
status: 'running',
|
|
259
|
+
artifacts: {
|
|
260
|
+
plans: [path.basename(filePath)],
|
|
261
|
+
workItems: [wiId],
|
|
262
|
+
prds: [], // discovered later when PRD materializes
|
|
263
|
+
prs: [], // discovered later when agents create PRs
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function executeApiStage(stage, stageState, run) {
|
|
269
|
+
const calls = stage.calls || [{ endpoint: stage.endpoint, method: stage.method || 'POST', body: stage.body }];
|
|
270
|
+
for (const call of calls) {
|
|
271
|
+
const url = `http://localhost:${process.env.MINIONS_PORT || 7331}${call.endpoint}`;
|
|
272
|
+
const body = typeof call.body === 'string' ? call.body : JSON.stringify(call.body || {});
|
|
273
|
+
// Fire and forget — use Node's http module
|
|
274
|
+
try {
|
|
275
|
+
const http = require('http');
|
|
276
|
+
const parsed = new URL(url);
|
|
277
|
+
const req = http.request({
|
|
278
|
+
hostname: parsed.hostname, port: parsed.port, path: parsed.pathname,
|
|
279
|
+
method: call.method || 'POST',
|
|
280
|
+
headers: { 'Content-Type': 'application/json' },
|
|
281
|
+
});
|
|
282
|
+
req.write(body);
|
|
283
|
+
req.end();
|
|
284
|
+
} catch (e) { log('warn', `Pipeline API call failed: ${e.message}`); }
|
|
285
|
+
}
|
|
286
|
+
return { status: 'completed', completedAt: ts() };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function executeMergePrsStage(stage, stageState, run, config) {
|
|
290
|
+
// Collect all PR IDs from all previous stages in this run
|
|
291
|
+
const prIds = [];
|
|
292
|
+
for (const [, s] of Object.entries(run.stages)) {
|
|
293
|
+
if (s.artifacts?.prs) prIds.push(...s.artifacts.prs);
|
|
294
|
+
}
|
|
295
|
+
if (prIds.length === 0) {
|
|
296
|
+
return { status: 'completed', completedAt: ts(), output: 'No PRs to merge' };
|
|
297
|
+
}
|
|
298
|
+
// The actual merge will be handled by the PR polling/merge logic
|
|
299
|
+
// We just need to track which PRs to watch
|
|
300
|
+
return { status: 'running', artifacts: { prs: prIds } };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function executeScheduleStage(stage, stageState, config) {
|
|
304
|
+
// Create/update schedules in config
|
|
305
|
+
const schedules = stage.schedules || [{ id: stage.id + '-sched', cron: stage.cron, title: stage.title, type: stage.taskType || 'implement' }];
|
|
306
|
+
// Write to config via shared
|
|
307
|
+
for (const sched of schedules) {
|
|
308
|
+
const existing = (config.schedules || []).find(s => s.id === sched.id);
|
|
309
|
+
if (!existing) {
|
|
310
|
+
config.schedules = config.schedules || [];
|
|
311
|
+
config.schedules.push({ ...sched, enabled: true });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
safeWrite(path.join(__dirname, '..', 'config.json'), config);
|
|
315
|
+
return { status: 'completed', completedAt: ts() };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function executeParallelStage(stage, stageState, run, pipeline, config) {
|
|
319
|
+
const subStages = stage.stages || [];
|
|
320
|
+
const subResults = {};
|
|
321
|
+
for (const sub of subStages) {
|
|
322
|
+
if (!run.stages[sub.id] || run.stages[sub.id].status === 'pending') {
|
|
323
|
+
const result = executeStage(sub, run, pipeline, config);
|
|
324
|
+
subResults[sub.id] = result;
|
|
325
|
+
run.stages[sub.id] = { ...run.stages[sub.id] || {}, ...result, startedAt: ts() };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Parent is running until all subs complete
|
|
329
|
+
return { status: 'running', artifacts: { subStages: subStages.map(s => s.id) } };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Stage Completion Checks ──────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
function isStageComplete(stage, stageState, run, config) {
|
|
335
|
+
if (stageState.status === 'completed' || stageState.status === 'failed') return true;
|
|
336
|
+
if (stageState.status === 'pending' || stageState.status === 'waiting-human') return false;
|
|
337
|
+
|
|
338
|
+
const artifacts = stageState.artifacts || {};
|
|
339
|
+
|
|
340
|
+
switch (stage.type) {
|
|
341
|
+
case 'task': {
|
|
342
|
+
const wiPath = path.join(__dirname, '..', 'work-items.json');
|
|
343
|
+
const workItems = safeJson(wiPath) || [];
|
|
344
|
+
const ids = artifacts.workItems || [];
|
|
345
|
+
if (ids.length === 0) return false;
|
|
346
|
+
return ids.every(id => {
|
|
347
|
+
const wi = workItems.find(w => w.id === id);
|
|
348
|
+
return wi && (wi.status === 'done' || wi.status === 'failed');
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
case 'meeting': {
|
|
352
|
+
const { getMeeting } = require('./meeting');
|
|
353
|
+
const ids = artifacts.meetings || [];
|
|
354
|
+
if (ids.length === 0) return false;
|
|
355
|
+
return ids.every(id => {
|
|
356
|
+
const m = getMeeting(id);
|
|
357
|
+
return m && (m.status === 'completed' || m.status === 'archived');
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
case 'plan': {
|
|
361
|
+
// Plan stage completion: PRD conversion done + all materialized work items done
|
|
362
|
+
const wiPath = path.join(__dirname, '..', 'work-items.json');
|
|
363
|
+
const workItems = safeJson(wiPath) || [];
|
|
364
|
+
const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
|
|
365
|
+
return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
|
|
366
|
+
}, []);
|
|
367
|
+
const all = [...workItems, ...allProjectWi];
|
|
368
|
+
|
|
369
|
+
// Check if plan-to-prd work item is done
|
|
370
|
+
const prdWiIds = artifacts.workItems || [];
|
|
371
|
+
const prdDone = prdWiIds.every(id => {
|
|
372
|
+
const wi = all.find(w => w.id === id);
|
|
373
|
+
return wi && wi.status === 'done';
|
|
374
|
+
});
|
|
375
|
+
if (!prdDone) return false;
|
|
376
|
+
|
|
377
|
+
// Discover PRDs and their work items
|
|
378
|
+
const prdDir = path.join(__dirname, '..', 'prd');
|
|
379
|
+
const plans = artifacts.plans || [];
|
|
380
|
+
for (const planFile of plans) {
|
|
381
|
+
const prdFiles = fs.existsSync(prdDir) ? safeReadDir(prdDir).filter(f => f.endsWith('.json')) : [];
|
|
382
|
+
for (const pf of prdFiles) {
|
|
383
|
+
const prd = safeJson(path.join(prdDir, pf));
|
|
384
|
+
if (prd?.source_plan === planFile && !(artifacts.prds || []).includes(pf)) {
|
|
385
|
+
artifacts.prds = artifacts.prds || [];
|
|
386
|
+
artifacts.prds.push(pf);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Find materialized work items for discovered PRDs
|
|
390
|
+
for (const prdFile of (artifacts.prds || [])) {
|
|
391
|
+
const prdItems = all.filter(w => w.sourcePlan === prdFile && w.type !== 'plan-to-prd');
|
|
392
|
+
for (const wi of prdItems) {
|
|
393
|
+
if (!(artifacts.workItems || []).includes(wi.id)) {
|
|
394
|
+
artifacts.workItems = artifacts.workItems || [];
|
|
395
|
+
artifacts.workItems.push(wi.id);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Auto-approve if configured
|
|
402
|
+
if (stage.autoApprove && artifacts.prds?.length > 0) {
|
|
403
|
+
for (const prdFile of artifacts.prds) {
|
|
404
|
+
const prdPath = path.join(prdDir, prdFile);
|
|
405
|
+
const prd = safeJson(prdPath);
|
|
406
|
+
if (prd && prd.status === 'awaiting-approval') {
|
|
407
|
+
prd.status = 'approved';
|
|
408
|
+
prd.approvedAt = ts();
|
|
409
|
+
prd.approvedBy = 'pipeline:' + run.pipelineId;
|
|
410
|
+
safeWrite(prdPath, prd);
|
|
411
|
+
log('info', `Pipeline ${run.pipelineId}: auto-approved PRD ${prdFile}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Check all materialized implement items are done
|
|
417
|
+
const implementIds = (artifacts.workItems || []).filter(id => !prdWiIds.includes(id));
|
|
418
|
+
if (implementIds.length === 0 && artifacts.prds?.length > 0) return false; // items not materialized yet
|
|
419
|
+
return implementIds.every(id => {
|
|
420
|
+
const wi = all.find(w => w.id === id);
|
|
421
|
+
return wi && (wi.status === 'done' || wi.status === 'failed');
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
case 'merge-prs': {
|
|
425
|
+
const prIds = artifacts.prs || [];
|
|
426
|
+
if (prIds.length === 0) return true; // nothing to merge
|
|
427
|
+
const projects = shared.getProjects(config);
|
|
428
|
+
for (const project of projects) {
|
|
429
|
+
const prs = safeJson(shared.projectPrPath(project)) || [];
|
|
430
|
+
for (const prId of prIds) {
|
|
431
|
+
const pr = prs.find(p => p.id === prId);
|
|
432
|
+
if (pr && pr.status !== 'merged' && pr.status !== 'abandoned') return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
case 'api':
|
|
438
|
+
case 'schedule':
|
|
439
|
+
return true; // fire-and-forget
|
|
440
|
+
case 'wait':
|
|
441
|
+
return stageState.status === 'completed';
|
|
442
|
+
case 'parallel': {
|
|
443
|
+
const subIds = artifacts.subStages || [];
|
|
444
|
+
return subIds.every(id => {
|
|
445
|
+
const sub = run.stages[id];
|
|
446
|
+
return sub && (sub.status === 'completed' || sub.status === 'failed');
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
default:
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Discovery (called per tick) ──────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
function discoverPipelineWork(config) {
|
|
457
|
+
const pipelines = getPipelines();
|
|
458
|
+
if (pipelines.length === 0) return;
|
|
459
|
+
|
|
460
|
+
const now = new Date();
|
|
461
|
+
|
|
462
|
+
for (const pipeline of pipelines) {
|
|
463
|
+
if (pipeline.enabled === false) continue;
|
|
464
|
+
|
|
465
|
+
// Check for active run
|
|
466
|
+
let activeRun = getActiveRun(pipeline.id);
|
|
467
|
+
|
|
468
|
+
// Cron trigger: start new run if no active run
|
|
469
|
+
if (!activeRun && pipeline.trigger?.cron) {
|
|
470
|
+
try {
|
|
471
|
+
const lastRuns = (getPipelineRuns()[pipeline.id] || []);
|
|
472
|
+
const lastRun = lastRuns[lastRuns.length - 1];
|
|
473
|
+
const lastRunAt = lastRun?.startedAt ? new Date(lastRun.startedAt) : null;
|
|
474
|
+
if (shouldRunNow({ cron: pipeline.trigger.cron }, lastRunAt)) {
|
|
475
|
+
activeRun = startRun(pipeline.id, pipeline);
|
|
476
|
+
}
|
|
477
|
+
} catch (e) { log('warn', `Pipeline cron check failed for ${pipeline.id}: ${e.message}`); }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!activeRun) continue;
|
|
481
|
+
|
|
482
|
+
// Process active run — check stage completions and start ready stages
|
|
483
|
+
let anyRunning = false;
|
|
484
|
+
let anyFailed = false;
|
|
485
|
+
let allComplete = true;
|
|
486
|
+
const stages = pipeline.stages || [];
|
|
487
|
+
|
|
488
|
+
for (const stage of stages) {
|
|
489
|
+
const stageState = activeRun.stages[stage.id];
|
|
490
|
+
if (!stageState) continue;
|
|
491
|
+
|
|
492
|
+
// Check if running stage completed
|
|
493
|
+
if (stageState.status === 'running') {
|
|
494
|
+
if (isStageComplete(stage, stageState, activeRun, config)) {
|
|
495
|
+
// Collect output
|
|
496
|
+
let output = '';
|
|
497
|
+
if (stage.type === 'task') {
|
|
498
|
+
const wiPath = path.join(__dirname, '..', 'work-items.json');
|
|
499
|
+
const workItems = safeJson(wiPath) || [];
|
|
500
|
+
output = (stageState.artifacts?.workItems || []).map(id => {
|
|
501
|
+
const wi = workItems.find(w => w.id === id);
|
|
502
|
+
return wi?.resultSummary || wi?.title || id;
|
|
503
|
+
}).join('\n');
|
|
504
|
+
} else if (stage.type === 'meeting') {
|
|
505
|
+
const { getMeeting } = require('./meeting');
|
|
506
|
+
output = (stageState.artifacts?.meetings || []).map(id => {
|
|
507
|
+
const m = getMeeting(id);
|
|
508
|
+
return m?.conclusion?.content || '';
|
|
509
|
+
}).join('\n\n');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
updateRunStage(pipeline.id, activeRun.runId, stage.id, {
|
|
513
|
+
status: 'completed', completedAt: ts(), output
|
|
514
|
+
});
|
|
515
|
+
stageState.status = 'completed';
|
|
516
|
+
stageState.output = output;
|
|
517
|
+
log('info', `Pipeline ${pipeline.id}: stage ${stage.id} completed`);
|
|
518
|
+
} else {
|
|
519
|
+
anyRunning = true;
|
|
520
|
+
allComplete = false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (stageState.status === 'waiting-human') { allComplete = false; continue; }
|
|
525
|
+
|
|
526
|
+
// Check if pending stage is ready to start
|
|
527
|
+
if (stageState.status === 'pending') {
|
|
528
|
+
allComplete = false;
|
|
529
|
+
const depsReady = (stage.dependsOn || []).every(depId => {
|
|
530
|
+
const dep = activeRun.stages[depId];
|
|
531
|
+
return dep && dep.status === 'completed';
|
|
532
|
+
});
|
|
533
|
+
const depsFailed = (stage.dependsOn || []).some(depId => {
|
|
534
|
+
const dep = activeRun.stages[depId];
|
|
535
|
+
return dep && dep.status === 'failed';
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
if (depsFailed) {
|
|
539
|
+
updateRunStage(pipeline.id, activeRun.runId, stage.id, { status: 'failed', error: 'dependency failed' });
|
|
540
|
+
stageState.status = 'failed';
|
|
541
|
+
anyFailed = true;
|
|
542
|
+
} else if (depsReady) {
|
|
543
|
+
const result = executeStage(stage, activeRun, pipeline, config);
|
|
544
|
+
updateRunStage(pipeline.id, activeRun.runId, stage.id, { ...result, startedAt: ts() });
|
|
545
|
+
Object.assign(stageState, result, { startedAt: ts() });
|
|
546
|
+
if (result.status === 'running') anyRunning = true;
|
|
547
|
+
log('info', `Pipeline ${pipeline.id}: started stage ${stage.id} (${stage.type})`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (stageState.status === 'failed') anyFailed = true;
|
|
552
|
+
if (stageState.status !== 'completed') allComplete = false;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Check if run is done
|
|
556
|
+
if (allComplete) {
|
|
557
|
+
completeRun(pipeline.id, activeRun.runId, 'completed');
|
|
558
|
+
} else if (anyFailed && !anyRunning) {
|
|
559
|
+
completeRun(pipeline.id, activeRun.runId, 'failed');
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
module.exports = {
|
|
565
|
+
PIPELINES_DIR,
|
|
566
|
+
getPipelines, getPipeline, savePipeline, deletePipeline,
|
|
567
|
+
getPipelineRuns, getActiveRun, startRun, updateRunStage, completeRun,
|
|
568
|
+
discoverPipelineWork,
|
|
569
|
+
};
|
package/engine.js
CHANGED
|
@@ -1985,6 +1985,12 @@ function discoverWork(config) {
|
|
|
1985
1985
|
allWorkItems.push(...meetingWork);
|
|
1986
1986
|
} catch (e) { log('warn', 'discover meeting work: ' + e.message); }
|
|
1987
1987
|
|
|
1988
|
+
// Pipeline orchestration — check stage completions and start ready stages
|
|
1989
|
+
try {
|
|
1990
|
+
const { discoverPipelineWork } = require('./engine/pipeline');
|
|
1991
|
+
discoverPipelineWork(config);
|
|
1992
|
+
} catch (e) { log('warn', 'discover pipeline work: ' + e.message); }
|
|
1993
|
+
|
|
1988
1994
|
// Periodic plan completion sweep — catch PRDs that completed while engine was down
|
|
1989
1995
|
// or where checkPlanCompletion missed the completion event
|
|
1990
1996
|
// Throttled to every 10 ticks (~5 min) to reduce call volume (P3 decision)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.131",
|
|
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"
|