@yemi33/minions 0.1.129 → 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 +24 -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 +74 -9
- package/engine/ado.js +1 -1
- package/engine/lifecycle.js +11 -7
- package/engine/pipeline.js +569 -0
- package/engine/shared.js +8 -5
- package/engine/timeout.js +2 -0
- package/engine.js +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.1.130 (2026-04-01)
|
|
18
|
+
|
|
19
|
+
### Engine
|
|
20
|
+
- engine/lifecycle.js
|
|
21
|
+
- engine/shared.js
|
|
22
|
+
- engine/timeout.js
|
|
23
|
+
|
|
24
|
+
### Dashboard
|
|
25
|
+
- dashboard.js
|
|
26
|
+
|
|
3
27
|
## 0.1.129 (2026-04-01)
|
|
4
28
|
|
|
5
29
|
### 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: {
|
|
@@ -1662,7 +1663,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
1662
1663
|
const body = await readBody(req);
|
|
1663
1664
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
1664
1665
|
const file = body.file;
|
|
1665
|
-
if (file.includes('..') || file.includes('\0')) return jsonReply(res, 400, { error: 'invalid' });
|
|
1666
|
+
if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
|
|
1666
1667
|
|
|
1667
1668
|
const isJson = file.endsWith('.json');
|
|
1668
1669
|
const sourceDir = isJson ? PRD_DIR : PLANS_DIR;
|
|
@@ -1701,7 +1702,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
1701
1702
|
const body = await readBody(req);
|
|
1702
1703
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
1703
1704
|
const file = body.file;
|
|
1704
|
-
if (file.includes('..') || file.includes('\0')) return jsonReply(res, 400, { error: 'invalid' });
|
|
1705
|
+
if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
|
|
1705
1706
|
|
|
1706
1707
|
const isJson = file.endsWith('.json');
|
|
1707
1708
|
const targetDir = isJson ? PRD_DIR : PLANS_DIR;
|
|
@@ -2660,7 +2661,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
2660
2661
|
const body = await readBody(req);
|
|
2661
2662
|
const { name, category } = body;
|
|
2662
2663
|
if (!name) return jsonReply(res, 400, { error: 'name required' });
|
|
2663
|
-
if (name.includes('..') || name.includes('\0')) return jsonReply(res, 400, { error: 'Invalid file name' });
|
|
2664
|
+
if (name.includes('..') || name.includes('\0') || name.includes('/') || name.includes('\\')) return jsonReply(res, 400, { error: 'Invalid file name' });
|
|
2664
2665
|
if (!category || !shared.KB_CATEGORIES.includes(category)) {
|
|
2665
2666
|
return jsonReply(res, 400, { error: 'category required: ' + shared.KB_CATEGORIES.join(', ') });
|
|
2666
2667
|
}
|
|
@@ -2737,13 +2738,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
2737
2738
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
2738
2739
|
const file = params.get('file');
|
|
2739
2740
|
const dir = params.get('dir');
|
|
2740
|
-
if (!file || file.includes('..') || file.includes('\0')) { res.statusCode = 400; res.end('Invalid file'); return; }
|
|
2741
|
+
if (!file || file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) { res.statusCode = 400; res.end('Invalid file'); return; }
|
|
2741
2742
|
|
|
2742
2743
|
let content = '';
|
|
2743
2744
|
if (dir) {
|
|
2744
|
-
// Direct path from collectSkillFiles
|
|
2745
|
-
const
|
|
2746
|
-
|
|
2745
|
+
// Direct path from collectSkillFiles — validate resolved path stays within expected dir
|
|
2746
|
+
const resolvedDir = path.resolve(dir.replace(/\//g, path.sep));
|
|
2747
|
+
const fullPath = path.join(resolvedDir, file);
|
|
2748
|
+
if (fullPath.startsWith(resolvedDir)) content = safeRead(fullPath) || '';
|
|
2747
2749
|
}
|
|
2748
2750
|
if (!content) {
|
|
2749
2751
|
// Fallback: search Claude Code skills, then project skills
|
|
@@ -3420,6 +3422,69 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3420
3422
|
{ method: 'POST', path: '/api/schedules/update', desc: 'Update an existing schedule', params: 'id, cron?, title?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesUpdate },
|
|
3421
3423
|
{ method: 'POST', path: '/api/schedules/delete', desc: 'Delete a schedule', params: 'id', handler: handleSchedulesDelete },
|
|
3422
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
|
+
|
|
3423
3488
|
// Meetings
|
|
3424
3489
|
{ method: 'POST', path: '/api/meetings', desc: 'Create a team meeting', params: 'title, agenda, participants[]', handler: async (req, res) => {
|
|
3425
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 };
|
package/engine/lifecycle.js
CHANGED
|
@@ -500,14 +500,18 @@ function syncPrdItemStatus(itemId, status, sourcePlan) {
|
|
|
500
500
|
const files = sourcePlan ? [sourcePlan] : require('fs').readdirSync(prdDir).filter(f => f.endsWith('.json'));
|
|
501
501
|
for (const pf of files) {
|
|
502
502
|
const fpath = path.join(prdDir, pf);
|
|
503
|
+
mutateJsonFileLocked(fpath, (plan) => {
|
|
504
|
+
if (!plan?.missing_features) return plan;
|
|
505
|
+
const feature = plan.missing_features.find(f => f.id === itemId);
|
|
506
|
+
if (feature && feature.status !== status) {
|
|
507
|
+
feature.status = status;
|
|
508
|
+
}
|
|
509
|
+
return plan;
|
|
510
|
+
});
|
|
511
|
+
// Check if we found it (read back to verify)
|
|
503
512
|
const plan = safeJson(fpath);
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if (feature && feature.status !== status) {
|
|
507
|
-
feature.status = status;
|
|
508
|
-
shared.safeWrite(fpath, plan);
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
513
|
+
const feature = plan?.missing_features?.find(f => f.id === itemId);
|
|
514
|
+
if (feature && feature.status === status) return;
|
|
511
515
|
}
|
|
512
516
|
} catch (err) { log('warn', `PRD status sync: ${err.message}`); }
|
|
513
517
|
}
|
|
@@ -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/shared.js
CHANGED
|
@@ -22,11 +22,14 @@ function log(level, msg, meta = {}) {
|
|
|
22
22
|
const entry = { timestamp: ts(), level, message: msg, ...meta };
|
|
23
23
|
console.log(`[${logTs()}] [${level}] ${msg}`);
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
try {
|
|
26
|
+
mutateJsonFileLocked(LOG_PATH, (logData) => {
|
|
27
|
+
if (!Array.isArray(logData)) logData = logData?.entries || [];
|
|
28
|
+
logData.push(entry);
|
|
29
|
+
if (logData.length >= 2500) logData.splice(0, logData.length - 2000);
|
|
30
|
+
return logData;
|
|
31
|
+
}, { defaultValue: [] });
|
|
32
|
+
} catch { /* logging should never crash the caller */ }
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
// ── File I/O ─────────────────────────────────────────────────────────────────
|
package/engine/timeout.js
CHANGED
|
@@ -248,6 +248,8 @@ function checkTimeouts(config) {
|
|
|
248
248
|
const isActive = possibleKeys.some(k => activeKeys.has(k)) ||
|
|
249
249
|
(dispatchData.active || []).some(d => d.meta?.item?.id === item.id);
|
|
250
250
|
if (!isActive) {
|
|
251
|
+
// Don't revive items that were explicitly failed for non-retryable reasons
|
|
252
|
+
if (item.status === 'failed' && item.failReason && !item.failReason.includes('Agent died')) continue;
|
|
251
253
|
const retries = (item._retryCount || 0);
|
|
252
254
|
if (retries < 3) {
|
|
253
255
|
log('info', `Reconcile: work item ${item.id} agent died — auto-retry ${retries + 1}/3`);
|
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"
|