@yemi33/minions 0.1.1553 → 0.1.1555
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 +10 -0
- package/dashboard/js/render-plans.js +16 -11
- package/dashboard/js/settings.js +31 -2
- package/dashboard.js +56 -70
- package/engine/dispatch.js +81 -0
- package/engine/projects.js +158 -0
- package/minions.js +35 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1555 (2026-04-27)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- comprehensive minions remove (CLI + dashboard)
|
|
7
|
+
|
|
8
|
+
## 0.1.1554 (2026-04-27)
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
- archive bugs — undefined resetBtn, .backup re-trigger, dispatch leak
|
|
12
|
+
|
|
3
13
|
## 0.1.1553 (2026-04-27)
|
|
4
14
|
|
|
5
15
|
### Other
|
|
@@ -601,13 +601,12 @@ async function planArchive(file, btn) {
|
|
|
601
601
|
if (!confirm(confirmMsg)) return;
|
|
602
602
|
_stopPlanPoll();
|
|
603
603
|
markDeleted('plan:' + file);
|
|
604
|
-
// Also optimistically hide the linked source plan (server archives both)
|
|
605
604
|
if (isPrd) {
|
|
606
605
|
var linkedPlan = (window._lastPlans || []).find(function(p) { return p.file === file && p.sourcePlan; });
|
|
607
606
|
if (linkedPlan) markDeleted('plan:' + linkedPlan.sourcePlan);
|
|
608
607
|
}
|
|
609
608
|
try { closeModal(); } catch { /* may not be open */ }
|
|
610
|
-
showToast('cmd-toast', '
|
|
609
|
+
showToast('cmd-toast', 'Archived', true);
|
|
611
610
|
try {
|
|
612
611
|
const res = await fetch('/api/plans/archive', {
|
|
613
612
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
@@ -617,16 +616,18 @@ async function planArchive(file, btn) {
|
|
|
617
616
|
if (!ct.includes('json')) { refresh(); return; }
|
|
618
617
|
const d = await res.json().catch(() => ({}));
|
|
619
618
|
if (res.ok && d.ok) {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
619
|
+
if (d.archivedSource || d.cancelledItems) {
|
|
620
|
+
var msg = 'Archived';
|
|
621
|
+
if (d.archivedSource) msg += ' PRD + source plan';
|
|
622
|
+
if (d.cancelledItems) msg += ', cancelled ' + d.cancelledItems + ' pending item(s)';
|
|
623
|
+
showToast('cmd-toast', msg, true);
|
|
624
|
+
}
|
|
624
625
|
refresh();
|
|
625
626
|
} else {
|
|
626
|
-
|
|
627
|
-
|
|
627
|
+
showToast('cmd-toast', 'Archive failed: ' + (d.error || 'unknown'), false);
|
|
628
|
+
refresh();
|
|
628
629
|
}
|
|
629
|
-
} catch (e) {
|
|
630
|
+
} catch (e) { showToast('cmd-toast', 'Error: ' + e.message, false); refresh(); }
|
|
630
631
|
}
|
|
631
632
|
|
|
632
633
|
async function planPause(file, btn) {
|
|
@@ -793,8 +794,12 @@ async function planUnarchive(file, btn) {
|
|
|
793
794
|
body: JSON.stringify({ file })
|
|
794
795
|
});
|
|
795
796
|
if (res.ok) { refreshPlans(); refresh(); }
|
|
796
|
-
else {
|
|
797
|
-
|
|
797
|
+
else {
|
|
798
|
+
const d = await res.json().catch(() => ({}));
|
|
799
|
+
showToast('cmd-toast', 'Unarchive failed: ' + (d.error || 'unknown'), false);
|
|
800
|
+
refresh();
|
|
801
|
+
}
|
|
802
|
+
} catch (e) { showToast('cmd-toast', 'Error: ' + e.message, false); refresh(); }
|
|
798
803
|
}
|
|
799
804
|
|
|
800
805
|
window.MinionsPlans = { openCreatePlanModal, refreshPlans, derivePlanStatus, renderPlans, openArchivedPlansModal, planExecute, planSubmitRevise, planShowRevise, planHideRevise, planView, planApprove, planArchive, planUnarchive, planDelete, planPause, planReject, planDiscuss, planOpenInDocChat, planRegeneratePRD, openVerifyGuide, triggerVerify };
|
package/dashboard/js/settings.js
CHANGED
|
@@ -87,7 +87,10 @@ async function openSettings() {
|
|
|
87
87
|
'<div style="display:flex;flex-direction:column;gap:12px;margin-bottom:16px">' +
|
|
88
88
|
(data.projects || []).map(function(p) {
|
|
89
89
|
return '<div style="border:1px solid var(--border);border-radius:6px;padding:10px 12px">' +
|
|
90
|
-
'<div style="
|
|
90
|
+
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
|
|
91
|
+
'<div style="font-size:12px;font-weight:600">' + escHtml(p.name) + '</div>' +
|
|
92
|
+
'<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:9px;padding:2px 8px;background:transparent;color:var(--red);border:1px solid var(--red);border-radius:3px;cursor:pointer">Remove</button>' +
|
|
93
|
+
'</div>' +
|
|
91
94
|
'<div style="display:flex;flex-direction:column;gap:6px">' +
|
|
92
95
|
settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
|
|
93
96
|
settingsToggle('Discover from Work Items', 'set-ws-wi-' + p.name, p.workSources.workItems.enabled, 'Auto-discover work from ADO/GitHub work items') +
|
|
@@ -404,4 +407,30 @@ async function resetSettingsToDefaults() {
|
|
|
404
407
|
}
|
|
405
408
|
}
|
|
406
409
|
|
|
407
|
-
|
|
410
|
+
async function removeProject(name) {
|
|
411
|
+
if (!confirm('Remove project "' + name + '"? Pending work cancels, active agents are killed, data dir is archived to projects/.archived/.')) return;
|
|
412
|
+
showToast('cmd-toast', 'Removing project "' + name + '"...', true);
|
|
413
|
+
try {
|
|
414
|
+
const res = await fetch('/api/projects/remove', {
|
|
415
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
416
|
+
body: JSON.stringify({ name })
|
|
417
|
+
});
|
|
418
|
+
const d = await res.json().catch(() => ({}));
|
|
419
|
+
if (!res.ok || !d.ok) {
|
|
420
|
+
showToast('cmd-toast', 'Remove failed: ' + (d.error || 'unknown'), false);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
var parts = ['Removed "' + name + '"'];
|
|
424
|
+
if (d.cancelledItems) parts.push(d.cancelledItems + ' WI cancelled');
|
|
425
|
+
if (d.drainedDispatches) parts.push(d.drainedDispatches + ' dispatch drained');
|
|
426
|
+
if (d.cleanedWorktrees) parts.push(d.cleanedWorktrees + ' worktree(s) cleaned');
|
|
427
|
+
if (d.archivedTo) parts.push('archived to ' + d.archivedTo);
|
|
428
|
+
if (d.pipelineRefs?.length) parts.push('! pipelines still reference: ' + d.pipelineRefs.join(', '));
|
|
429
|
+
showToast('cmd-toast', parts.join(' — '), true);
|
|
430
|
+
setTimeout(() => openSettings(), 600);
|
|
431
|
+
} catch (e) {
|
|
432
|
+
showToast('cmd-toast', 'Error: ' + e.message, false);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
window.MinionsSettings = { openSettings, saveSettings, addProject, removeProject, resetSettingsToDefaults };
|
package/dashboard.js
CHANGED
|
@@ -1346,59 +1346,7 @@ function jsonReply(res, code, data, req) {
|
|
|
1346
1346
|
* @param {(entry) => boolean} matchFn - return true for entries to remove
|
|
1347
1347
|
* @returns {number} count of removed entries
|
|
1348
1348
|
*/
|
|
1349
|
-
|
|
1350
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
1351
|
-
const engineDir = path.join(MINIONS_DIR, 'engine');
|
|
1352
|
-
try {
|
|
1353
|
-
let removed = 0;
|
|
1354
|
-
// Collect PIDs and file paths inside the lock, execute kills outside
|
|
1355
|
-
const pidsToKill = [];
|
|
1356
|
-
const filesToDelete = [];
|
|
1357
|
-
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
1358
|
-
dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
|
|
1359
|
-
dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
1360
|
-
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
1361
|
-
for (const queue of ['pending', 'active', 'completed']) {
|
|
1362
|
-
const before = dispatch[queue].length;
|
|
1363
|
-
if (queue === 'active') {
|
|
1364
|
-
for (const d of dispatch[queue]) {
|
|
1365
|
-
if (!matchFn(d)) continue;
|
|
1366
|
-
// Collect PID and cleanup paths — actual I/O happens after lock release
|
|
1367
|
-
const pidFile = path.join(engineDir, `pid-${d.id}.pid`);
|
|
1368
|
-
try {
|
|
1369
|
-
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim());
|
|
1370
|
-
if (pid) pidsToKill.push(pid);
|
|
1371
|
-
} catch { /* PID file may not exist */ }
|
|
1372
|
-
filesToDelete.push(pidFile);
|
|
1373
|
-
filesToDelete.push(path.join(engineDir, 'tmp', `prompt-${d.id}.md`));
|
|
1374
|
-
filesToDelete.push(path.join(engineDir, 'tmp', `sysprompt-${d.id}.md`));
|
|
1375
|
-
filesToDelete.push(path.join(engineDir, 'tmp', `sysprompt-${d.id}.md.tmp`));
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
dispatch[queue] = dispatch[queue].filter(d => !matchFn(d));
|
|
1379
|
-
removed += before - dispatch[queue].length;
|
|
1380
|
-
}
|
|
1381
|
-
return dispatch;
|
|
1382
|
-
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
1383
|
-
// Kill processes outside the lock — these can take hundreds of ms on Windows
|
|
1384
|
-
for (const pid of pidsToKill) {
|
|
1385
|
-
try {
|
|
1386
|
-
const safePid = shared.validatePid(pid);
|
|
1387
|
-
if (process.platform === 'win32') {
|
|
1388
|
-
const { execFileSync } = require('child_process');
|
|
1389
|
-
execFileSync('taskkill', ['/PID', String(safePid), '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
|
|
1390
|
-
} else {
|
|
1391
|
-
process.kill(safePid, 'SIGTERM');
|
|
1392
|
-
}
|
|
1393
|
-
} catch { /* process may already be dead */ }
|
|
1394
|
-
}
|
|
1395
|
-
// Clean up files outside the lock
|
|
1396
|
-
for (const fp of filesToDelete) {
|
|
1397
|
-
try { fs.unlinkSync(fp); } catch { /* file may not exist */ }
|
|
1398
|
-
}
|
|
1399
|
-
return removed;
|
|
1400
|
-
} catch { return 0; }
|
|
1401
|
-
}
|
|
1349
|
+
const { cleanDispatchEntries } = require('./engine/dispatch');
|
|
1402
1350
|
|
|
1403
1351
|
// ── Engine Restart Helpers (used by watchdog + API) ─────────────────────────
|
|
1404
1352
|
|
|
@@ -3341,46 +3289,69 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
3341
3289
|
try {
|
|
3342
3290
|
const body = await readBody(req);
|
|
3343
3291
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
3344
|
-
|
|
3292
|
+
const isPrd = body.file.endsWith('.json');
|
|
3293
|
+
shared.sanitizePath(body.file, isPrd ? PRD_DIR : PLANS_DIR);
|
|
3345
3294
|
const planPath = resolvePlanPath(body.file);
|
|
3346
3295
|
if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
3347
3296
|
|
|
3348
|
-
|
|
3349
|
-
const archiveDir = body.file.endsWith('.json') ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
|
|
3297
|
+
const archiveDir = isPrd ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
|
|
3350
3298
|
if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
|
|
3351
3299
|
const archivePath = path.join(archiveDir, body.file);
|
|
3352
3300
|
fs.renameSync(planPath, archivePath);
|
|
3353
3301
|
|
|
3354
|
-
// Mark archived in JSON if PRD
|
|
3355
3302
|
let archivedSource = null;
|
|
3356
|
-
|
|
3303
|
+
let plan = {};
|
|
3304
|
+
if (isPrd) {
|
|
3357
3305
|
try {
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
safeWrite(archivePath,
|
|
3362
|
-
//
|
|
3363
|
-
|
|
3364
|
-
|
|
3306
|
+
plan = safeJsonObj(archivePath) || {};
|
|
3307
|
+
plan.status = 'archived';
|
|
3308
|
+
plan.archivedAt = new Date().toISOString();
|
|
3309
|
+
safeWrite(archivePath, plan);
|
|
3310
|
+
// Without removing the .backup sidecar, safeJson would auto-restore the
|
|
3311
|
+
// pre-completion snapshot on engine restart, re-triggering plan completion
|
|
3312
|
+
// and spawning duplicate verify tasks (regression of #f28162b0).
|
|
3313
|
+
const backupPath = planPath + '.backup';
|
|
3314
|
+
try { fs.unlinkSync(backupPath); } catch {
|
|
3315
|
+
try { fs.writeFileSync(backupPath, JSON.stringify({ status: 'archived' })); } catch { /* best-effort */ }
|
|
3316
|
+
}
|
|
3317
|
+
if (plan.source_plan) {
|
|
3318
|
+
const mdPath = path.join(PLANS_DIR, plan.source_plan);
|
|
3365
3319
|
if (fs.existsSync(mdPath)) {
|
|
3366
3320
|
const planArchive = path.join(PLANS_DIR, 'archive');
|
|
3367
3321
|
if (!fs.existsSync(planArchive)) fs.mkdirSync(planArchive, { recursive: true });
|
|
3368
|
-
fs.renameSync(mdPath, path.join(planArchive,
|
|
3369
|
-
archivedSource =
|
|
3322
|
+
fs.renameSync(mdPath, path.join(planArchive, plan.source_plan));
|
|
3323
|
+
archivedSource = plan.source_plan;
|
|
3370
3324
|
}
|
|
3371
3325
|
}
|
|
3372
3326
|
} catch { /* optional */ }
|
|
3373
3327
|
}
|
|
3374
3328
|
|
|
3375
|
-
//
|
|
3329
|
+
// Cancel pending work items linked to this plan so the engine stops
|
|
3330
|
+
// dispatching for an archived plan. Done items are preserved as history.
|
|
3331
|
+
let cancelledItems = 0;
|
|
3332
|
+
const wiPaths = [path.join(MINIONS_DIR, 'work-items.json'), ...PROJECTS.map(p => shared.projectWorkItemsPath(p))];
|
|
3333
|
+
for (const wiPath of wiPaths) {
|
|
3334
|
+
try {
|
|
3335
|
+
mutateWorkItems(wiPath, items => {
|
|
3336
|
+
for (const w of items) {
|
|
3337
|
+
if (w.sourcePlan !== body.file) continue;
|
|
3338
|
+
if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.QUEUED) {
|
|
3339
|
+
w.status = WI_STATUS.CANCELLED;
|
|
3340
|
+
w._cancelledBy = 'plan-archived';
|
|
3341
|
+
cancelledItems++;
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
});
|
|
3345
|
+
} catch (e) { console.error('plan archive cancel:', e.message); }
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3376
3348
|
try {
|
|
3377
|
-
const plan = body.file.endsWith('.json') ? (safeJsonObj(archivePath) || {}) : {};
|
|
3378
3349
|
const { cleanupPlanWorktrees } = require('./engine/lifecycle');
|
|
3379
3350
|
cleanupPlanWorktrees(body.file, plan, PROJECTS, getConfig());
|
|
3380
3351
|
} catch (e) { console.error('plan worktree cleanup:', e.message); }
|
|
3381
3352
|
|
|
3382
3353
|
invalidateStatusCache();
|
|
3383
|
-
return jsonReply(res, 200, { ok: true, archived: body.file, archivedSource });
|
|
3354
|
+
return jsonReply(res, 200, { ok: true, archived: body.file, archivedSource, cancelledItems });
|
|
3384
3355
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
3385
3356
|
}
|
|
3386
3357
|
|
|
@@ -3915,6 +3886,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3915
3886
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
3916
3887
|
}
|
|
3917
3888
|
|
|
3889
|
+
async function handleProjectsRemove(req, res) {
|
|
3890
|
+
try {
|
|
3891
|
+
const body = await readBody(req);
|
|
3892
|
+
const target = body.name || body.path;
|
|
3893
|
+
if (!target) return jsonReply(res, 400, { error: 'name or path required' });
|
|
3894
|
+
const { removeProject } = require('./engine/projects');
|
|
3895
|
+
const result = removeProject(target, { keepData: body.keepData === true, purge: body.purge === true });
|
|
3896
|
+
if (!result.ok) return jsonReply(res, result.error?.includes('No project') ? 404 : 400, result);
|
|
3897
|
+
reloadConfig();
|
|
3898
|
+
invalidateStatusCache();
|
|
3899
|
+
return jsonReply(res, 200, result);
|
|
3900
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3918
3903
|
async function handleProjectsScan(req, res) {
|
|
3919
3904
|
try {
|
|
3920
3905
|
const body = await readBody(req);
|
|
@@ -5204,6 +5189,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
5204
5189
|
{ method: 'POST', path: '/api/projects/scan', desc: 'Scan a directory for git repos', params: 'path?, depth?', handler: handleProjectsScan },
|
|
5205
5190
|
{ method: 'POST', path: '/api/projects/confirm-token', desc: 'Mint a single-use UUID token required to add a non-repo path (SEC-05)', handler: handleProjectsConfirmToken },
|
|
5206
5191
|
{ method: 'POST', path: '/api/projects/add', desc: 'Auto-discover and add a project to config (name validated SEC-04; path validated SEC-05)', params: 'path, name?, allowNonRepo?, confirmToken?', handler: handleProjectsAdd },
|
|
5192
|
+
{ method: 'POST', path: '/api/projects/remove', desc: 'Unlink a project: cancels WIs, drains dispatch, kills agents, cleans worktrees, archives data dir', params: 'name or path, keepData?, purge?', handler: handleProjectsRemove },
|
|
5207
5193
|
|
|
5208
5194
|
// Bug Filing
|
|
5209
5195
|
{ method: 'POST', path: '/api/issues/create', desc: 'File a bug on the Minions repo (yemi33/minions)', params: 'title, description?, labels?', handler: handleFileBug },
|
package/engine/dispatch.js
CHANGED
|
@@ -330,6 +330,85 @@ function cancelPendingDispatchesForPr(prId) {
|
|
|
330
330
|
return cancelled;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Remove dispatch entries matching a predicate from pending/active/completed.
|
|
335
|
+
* For matched active entries, kills the agent process and deletes its
|
|
336
|
+
* pid file + prompt sidecars in engine/tmp/. Lock callback only mutates state;
|
|
337
|
+
* kills and unlinks happen after release.
|
|
338
|
+
*
|
|
339
|
+
* @param {(entry) => boolean} matchFn
|
|
340
|
+
* @returns {number} count of removed entries
|
|
341
|
+
*/
|
|
342
|
+
function cleanDispatchEntries(matchFn) {
|
|
343
|
+
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
344
|
+
const engineDir = path.join(MINIONS_DIR, 'engine');
|
|
345
|
+
let removed = 0;
|
|
346
|
+
const pidsToKill = [];
|
|
347
|
+
const filesToDelete = [];
|
|
348
|
+
try {
|
|
349
|
+
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
350
|
+
for (const queue of ['pending', 'active', 'completed']) {
|
|
351
|
+
dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
|
|
352
|
+
const before = dispatch[queue].length;
|
|
353
|
+
if (queue === 'active') {
|
|
354
|
+
for (const d of dispatch[queue]) {
|
|
355
|
+
if (!matchFn(d)) continue;
|
|
356
|
+
const pidFile = path.join(engineDir, `pid-${d.id}.pid`);
|
|
357
|
+
try {
|
|
358
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim());
|
|
359
|
+
if (pid) pidsToKill.push(pid);
|
|
360
|
+
} catch { /* PID file may not exist */ }
|
|
361
|
+
filesToDelete.push(pidFile);
|
|
362
|
+
filesToDelete.push(path.join(engineDir, 'tmp', `prompt-${d.id}.md`));
|
|
363
|
+
filesToDelete.push(path.join(engineDir, 'tmp', `sysprompt-${d.id}.md`));
|
|
364
|
+
filesToDelete.push(path.join(engineDir, 'tmp', `sysprompt-${d.id}.md.tmp`));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
dispatch[queue] = dispatch[queue].filter(d => !matchFn(d));
|
|
368
|
+
removed += before - dispatch[queue].length;
|
|
369
|
+
}
|
|
370
|
+
return dispatch;
|
|
371
|
+
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
372
|
+
} catch { return 0; }
|
|
373
|
+
// Kill processes outside the lock — taskkill on Windows can take hundreds of ms
|
|
374
|
+
for (const pid of pidsToKill) {
|
|
375
|
+
try {
|
|
376
|
+
const safePid = shared.validatePid(pid);
|
|
377
|
+
if (process.platform === 'win32') {
|
|
378
|
+
const { execFileSync } = require('child_process');
|
|
379
|
+
execFileSync('taskkill', ['/PID', String(safePid), '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
|
|
380
|
+
} else {
|
|
381
|
+
process.kill(safePid, 'SIGTERM');
|
|
382
|
+
}
|
|
383
|
+
} catch { /* may already be dead */ }
|
|
384
|
+
}
|
|
385
|
+
for (const fp of filesToDelete) {
|
|
386
|
+
try { fs.unlinkSync(fp); } catch { /* may not exist */ }
|
|
387
|
+
}
|
|
388
|
+
return removed;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Cancel pending/queued work items matching a predicate. Done items pass through.
|
|
393
|
+
* Sets status=CANCELLED + _cancelledBy=reason. Returns count cancelled.
|
|
394
|
+
*/
|
|
395
|
+
function cancelPendingWorkItems(wiPath, matchFn, reason) {
|
|
396
|
+
if (!fs.existsSync(wiPath)) return 0;
|
|
397
|
+
let cancelled = 0;
|
|
398
|
+
try {
|
|
399
|
+
mutateWorkItems(wiPath, items => {
|
|
400
|
+
for (const w of items) {
|
|
401
|
+
if (!matchFn(w)) continue;
|
|
402
|
+
if (w.status !== WI_STATUS.PENDING && w.status !== WI_STATUS.QUEUED) continue;
|
|
403
|
+
w.status = WI_STATUS.CANCELLED;
|
|
404
|
+
if (reason) w._cancelledBy = reason;
|
|
405
|
+
cancelled++;
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
} catch { /* file unwritable */ }
|
|
409
|
+
return cancelled;
|
|
410
|
+
}
|
|
411
|
+
|
|
333
412
|
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
334
413
|
|
|
335
414
|
module.exports = {
|
|
@@ -340,4 +419,6 @@ module.exports = {
|
|
|
340
419
|
writeInboxAlert,
|
|
341
420
|
updateAgentStatus,
|
|
342
421
|
cancelPendingDispatchesForPr,
|
|
422
|
+
cleanDispatchEntries,
|
|
423
|
+
cancelPendingWorkItems,
|
|
343
424
|
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/projects.js — Project lifecycle (currently: comprehensive remove).
|
|
3
|
+
*
|
|
4
|
+
* Used by both the CLI (minions.js) and the dashboard (handleProjectsRemove)
|
|
5
|
+
* so removal semantics stay identical regardless of entry point.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const shared = require('./shared');
|
|
11
|
+
const dispatch = require('./dispatch');
|
|
12
|
+
const { MINIONS_DIR } = shared;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} d - dispatch entry
|
|
16
|
+
* @returns {string} project name from any of the three meta shapes the engine
|
|
17
|
+
* uses (item.project, project.name, project string)
|
|
18
|
+
*/
|
|
19
|
+
function _dispatchProjectName(d) {
|
|
20
|
+
return d?.meta?.item?.project || d?.meta?.project?.name || d?.meta?.project || '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Remove a project: cancel pending work items, drain dispatch + kill agents,
|
|
25
|
+
* clean worktrees, disable project-targeted schedules, archive (or purge) the
|
|
26
|
+
* data directory, then unlink from config.json.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} target - Project name or localPath
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @param {'archive'|'keep'|'purge'} [options.dataMode='archive']
|
|
31
|
+
* archive: move projects/<name>/ to projects/.archived/<name>-YYYYMMDD/
|
|
32
|
+
* keep: leave projects/<name>/ in place
|
|
33
|
+
* purge: rm -rf projects/<name>/
|
|
34
|
+
* @returns {object} summary { ok, project, cancelledItems, killedAgents,
|
|
35
|
+
* drainedDispatches, cleanedWorktrees, disabledSchedules, archivedTo,
|
|
36
|
+
* purgedDataDir, pipelineRefs[], warnings[] } or { ok:false, error }
|
|
37
|
+
*/
|
|
38
|
+
function removeProject(target, options = {}) {
|
|
39
|
+
const dataMode = options.dataMode || (options.purge ? 'purge' : (options.keepData ? 'keep' : 'archive'));
|
|
40
|
+
const summary = {
|
|
41
|
+
ok: false,
|
|
42
|
+
project: null,
|
|
43
|
+
cancelledItems: 0,
|
|
44
|
+
drainedDispatches: 0, // includes active dispatches whose agent processes were killed
|
|
45
|
+
cleanedWorktrees: 0,
|
|
46
|
+
disabledSchedules: 0,
|
|
47
|
+
pipelineRefs: [],
|
|
48
|
+
archivedTo: null,
|
|
49
|
+
purgedDataDir: false,
|
|
50
|
+
warnings: [],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const configPath = path.join(MINIONS_DIR, 'config.json');
|
|
54
|
+
let config;
|
|
55
|
+
try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
|
|
56
|
+
catch (e) { return { ...summary, error: 'Failed to read config: ' + e.message }; }
|
|
57
|
+
|
|
58
|
+
const project = (config.projects || []).find(p =>
|
|
59
|
+
p.name === target || path.resolve(p.localPath || '') === path.resolve(target));
|
|
60
|
+
if (!project) {
|
|
61
|
+
const available = (config.projects || []).map(p => p.name).join(', ') || '(none)';
|
|
62
|
+
return { ...summary, error: `No project linked matching: ${target}. Available: ${available}` };
|
|
63
|
+
}
|
|
64
|
+
summary.project = { name: project.name, localPath: project.localPath };
|
|
65
|
+
|
|
66
|
+
// 1. Cancel pending/queued work items linked to this project (project-local
|
|
67
|
+
// file + central). Done items are preserved as history.
|
|
68
|
+
for (const wiPath of [
|
|
69
|
+
path.join(MINIONS_DIR, 'projects', project.name, 'work-items.json'),
|
|
70
|
+
path.join(MINIONS_DIR, 'work-items.json'),
|
|
71
|
+
]) {
|
|
72
|
+
summary.cancelledItems += dispatch.cancelPendingWorkItems(
|
|
73
|
+
wiPath,
|
|
74
|
+
w => !w.project || w.project === project.name,
|
|
75
|
+
'project-removed',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. Drain dispatch — also kills active agent processes and unlinks pid +
|
|
80
|
+
// prompt sidecars in engine/tmp/, matching what plan delete does.
|
|
81
|
+
summary.drainedDispatches = dispatch.cleanDispatchEntries(
|
|
82
|
+
d => _dispatchProjectName(d) === project.name,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// 3. Clean up worktrees under this project's worktree root, honoring
|
|
86
|
+
// config.engine.worktreeRoot (mirrors lifecycle.js cleanupPlanWorktrees).
|
|
87
|
+
if (project.localPath) {
|
|
88
|
+
try {
|
|
89
|
+
const wtRoot = path.resolve(project.localPath, config.engine?.worktreeRoot || '../worktrees');
|
|
90
|
+
if (fs.existsSync(wtRoot)) {
|
|
91
|
+
for (const dir of fs.readdirSync(wtRoot)) {
|
|
92
|
+
try {
|
|
93
|
+
if (shared.removeWorktree(path.join(wtRoot, dir), project.localPath, wtRoot)) {
|
|
94
|
+
summary.cleanedWorktrees++;
|
|
95
|
+
}
|
|
96
|
+
} catch { /* best effort */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (e) { summary.warnings.push('worktree cleanup: ' + e.message); }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 4. Disable schedules whose `project` field targets this project
|
|
103
|
+
// specifically. Don't touch schedules with project='any' or unset.
|
|
104
|
+
if (Array.isArray(config.schedules)) {
|
|
105
|
+
for (const s of config.schedules) {
|
|
106
|
+
if (s.project === project.name && s.enabled !== false) {
|
|
107
|
+
s.enabled = false;
|
|
108
|
+
summary.disabledSchedules++;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 5. Surface pipelines that reference this project so the user can review
|
|
114
|
+
// them. Don't auto-modify — user intent there is unclear.
|
|
115
|
+
try {
|
|
116
|
+
const { getPipelines } = require('./pipeline');
|
|
117
|
+
for (const p of getPipelines() || []) {
|
|
118
|
+
const refs = [
|
|
119
|
+
...(p.monitoredResources || []),
|
|
120
|
+
...((p.stages || []).flatMap(s => s.monitoredResources || [])),
|
|
121
|
+
];
|
|
122
|
+
if (refs.some(r => r && (r.project === project.name || r._project === project.name))) {
|
|
123
|
+
summary.pipelineRefs.push(p.id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch { /* pipelines optional */ }
|
|
127
|
+
|
|
128
|
+
// 6. Remove from config.json (and persist any schedule disables)
|
|
129
|
+
config.projects = (config.projects || []).filter(p => p.name !== project.name);
|
|
130
|
+
try { fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); }
|
|
131
|
+
catch (e) { return { ...summary, error: 'Failed to write config: ' + e.message }; }
|
|
132
|
+
|
|
133
|
+
// 7. Move (or purge) projects/<name>/ — preserves PR/work-item history by
|
|
134
|
+
// default so a re-add can pick up where it left off.
|
|
135
|
+
const dataDir = path.join(MINIONS_DIR, 'projects', project.name);
|
|
136
|
+
if (fs.existsSync(dataDir)) {
|
|
137
|
+
if (dataMode === 'purge') {
|
|
138
|
+
try { fs.rmSync(dataDir, { recursive: true, force: true }); summary.purgedDataDir = true; }
|
|
139
|
+
catch (e) { summary.warnings.push('purge data dir: ' + e.message); }
|
|
140
|
+
} else if (dataMode === 'archive') {
|
|
141
|
+
try {
|
|
142
|
+
const archiveRoot = path.join(MINIONS_DIR, 'projects', '.archived');
|
|
143
|
+
fs.mkdirSync(archiveRoot, { recursive: true });
|
|
144
|
+
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
145
|
+
let archived = path.join(archiveRoot, project.name + '-' + stamp);
|
|
146
|
+
let n = 1;
|
|
147
|
+
while (fs.existsSync(archived)) archived = path.join(archiveRoot, project.name + '-' + stamp + '-' + (++n));
|
|
148
|
+
fs.renameSync(dataDir, archived);
|
|
149
|
+
summary.archivedTo = path.relative(MINIONS_DIR, archived).replace(/\\/g, '/');
|
|
150
|
+
} catch (e) { summary.warnings.push('archive data dir: ' + e.message); }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
summary.ok = true;
|
|
155
|
+
return summary;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { removeProject };
|
package/minions.js
CHANGED
|
@@ -206,20 +206,27 @@ async function addProject(targetDir) {
|
|
|
206
206
|
console.log(` node ${MINIONS_HOME}/engine.js status # Status\n`);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
function removeProject(
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
} else {
|
|
219
|
-
saveConfig(config);
|
|
220
|
-
console.log(` Removed project at ${target}`);
|
|
221
|
-
console.log(` Remaining projects: ${config.projects.length}`);
|
|
209
|
+
function removeProject(target, options = {}) {
|
|
210
|
+
const { removeProject: doRemove } = require('./engine/projects');
|
|
211
|
+
// CLI accepts a path; project lookup also matches by name as a convenience
|
|
212
|
+
const result = doRemove(path.resolve(target), options);
|
|
213
|
+
if (!result.ok) {
|
|
214
|
+
console.log(' ' + (result.error || 'Removal failed'));
|
|
215
|
+
rl.close();
|
|
216
|
+
process.exitCode = 1;
|
|
217
|
+
return;
|
|
222
218
|
}
|
|
219
|
+
|
|
220
|
+
const lines = [` Removed project: ${result.project.name} (${result.project.localPath})`];
|
|
221
|
+
if (result.cancelledItems) lines.push(` cancelled ${result.cancelledItems} pending work item(s)`);
|
|
222
|
+
if (result.drainedDispatches) lines.push(` drained ${result.drainedDispatches} dispatch entr${result.drainedDispatches === 1 ? 'y' : 'ies'} (active agents killed)`);
|
|
223
|
+
if (result.cleanedWorktrees) lines.push(` cleaned ${result.cleanedWorktrees} worktree(s)`);
|
|
224
|
+
if (result.disabledSchedules) lines.push(` disabled ${result.disabledSchedules} schedule(s)`);
|
|
225
|
+
if (result.archivedTo) lines.push(` archived data → ${result.archivedTo}`);
|
|
226
|
+
if (result.purgedDataDir) lines.push(` purged data directory`);
|
|
227
|
+
if (result.pipelineRefs.length) lines.push(` ! pipeline(s) still reference this project: ${result.pipelineRefs.join(', ')}`);
|
|
228
|
+
for (const w of result.warnings) lines.push(` warn: ${w}`);
|
|
229
|
+
console.log(lines.join('\n'));
|
|
223
230
|
rl.close();
|
|
224
231
|
}
|
|
225
232
|
|
|
@@ -498,9 +505,18 @@ const commands = {
|
|
|
498
505
|
addProject(dir).catch(e => { console.error(e); process.exit(1); });
|
|
499
506
|
},
|
|
500
507
|
remove: () => {
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
508
|
+
const positional = rest.filter(a => !a.startsWith('--'));
|
|
509
|
+
const dir = positional[0];
|
|
510
|
+
if (!dir) {
|
|
511
|
+
console.log('Usage: node minions remove <project-dir-or-name> [--keep-data | --purge]');
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
const options = { keepData: rest.includes('--keep-data'), purge: rest.includes('--purge') };
|
|
515
|
+
if (options.purge && !rest.includes('--force')) {
|
|
516
|
+
console.log(` --purge will permanently delete projects/<name>/. Re-run with --force to confirm.`);
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
removeProject(dir, options);
|
|
504
520
|
},
|
|
505
521
|
list: () => listProjects(),
|
|
506
522
|
scan: () => scanAndAdd({ root: rest[0], depth: rest[1] })
|
|
@@ -516,7 +532,9 @@ if (cmd && commands[cmd]) {
|
|
|
516
532
|
console.log(' init Initialize minions and scan for repos');
|
|
517
533
|
console.log(' scan [dir] [depth] Scan for git repos and multi-select to add');
|
|
518
534
|
console.log(' add <project-dir> Link a single project');
|
|
519
|
-
console.log(' remove <project-dir>
|
|
535
|
+
console.log(' remove <project-dir-or-name> [--keep-data | --purge --force]');
|
|
536
|
+
console.log(' Unlink: cancels WIs, drains dispatch, kills agents,');
|
|
537
|
+
console.log(' cleans worktrees, archives data dir to projects/.archived/');
|
|
520
538
|
console.log(' list List linked projects\n');
|
|
521
539
|
}
|
|
522
540
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1555",
|
|
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"
|