@yemi33/minions 0.1.2142 → 0.1.2144
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/bin/cli-api-client.js +41 -4
- package/bin/minions.js +130 -25
- package/dashboard/js/refresh.js +1 -1
- package/dashboard/js/render-prs.js +9 -7
- package/dashboard.js +101 -33
- package/docs/deprecated.json +55 -0
- package/engine/cleanup.js +54 -0
- package/engine/cli.js +35 -6
- package/engine/dispatch.js +1 -1
- package/engine/lifecycle.js +266 -0
- package/engine/queries.js +75 -1
- package/engine/shared.js +246 -1
- package/engine/supervisor.js +22 -4
- package/engine/worktree-gc.js +377 -6
- package/engine.js +53 -6
- package/package.json +1 -1
package/bin/cli-api-client.js
CHANGED
|
@@ -21,8 +21,42 @@
|
|
|
21
21
|
const http = require('http');
|
|
22
22
|
const fs = require('fs');
|
|
23
23
|
const path = require('path');
|
|
24
|
+
const os = require('os');
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the dashboard base URL on EVERY call so commands invoked while the
|
|
28
|
+
* dashboard auto-fallback bound to a non-default port (W-mq5nwl9l) still
|
|
29
|
+
* reach it. Precedence:
|
|
30
|
+
* 1. MINIONS_DASHBOARD_URL env var (full URL — wins absolutely)
|
|
31
|
+
* 2. engine/dashboard-port.json runtime file (the live dashboard's actual port)
|
|
32
|
+
* 3. http://localhost:7331 (legacy default)
|
|
33
|
+
*
|
|
34
|
+
* Inlined here (rather than going through engine/shared.js) so the CLI client
|
|
35
|
+
* stays runtime-loadable even if shared.js fails to load mid-upgrade.
|
|
36
|
+
*/
|
|
37
|
+
function _readRuntimePort() {
|
|
38
|
+
try {
|
|
39
|
+
const home = process.env.MINIONS_HOME || path.join(os.homedir(), '.minions');
|
|
40
|
+
const raw = fs.readFileSync(path.join(home, 'engine', 'dashboard-port.json'), 'utf8');
|
|
41
|
+
const data = JSON.parse(raw);
|
|
42
|
+
const port = Number(data && data.port);
|
|
43
|
+
if (Number.isInteger(port) && port > 0 && port <= 65535) return port;
|
|
44
|
+
} catch {}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getDefaultBaseUrl() {
|
|
49
|
+
if (process.env.MINIONS_DASHBOARD_URL) return process.env.MINIONS_DASHBOARD_URL;
|
|
50
|
+
const port = _readRuntimePort();
|
|
51
|
+
if (port) return `http://localhost:${port}`;
|
|
52
|
+
return 'http://localhost:7331';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Backwards-compat: an evaluated-at-import snapshot for callers that just want
|
|
56
|
+
// "the value at the time the module loaded". Live consumers should call
|
|
57
|
+
// getDefaultBaseUrl() (default-parameter evaluation inside apiCall does that
|
|
58
|
+
// for them transparently).
|
|
59
|
+
const DEFAULT_BASE_URL = getDefaultBaseUrl();
|
|
26
60
|
|
|
27
61
|
// ── HTTP core ───────────────────────────────────────────────────────────────
|
|
28
62
|
|
|
@@ -60,7 +94,8 @@ function _normalizeApiPath(input) {
|
|
|
60
94
|
return s;
|
|
61
95
|
}
|
|
62
96
|
|
|
63
|
-
function apiCall(method, urlPath, bodyJson, { baseUrl
|
|
97
|
+
function apiCall(method, urlPath, bodyJson, { baseUrl, timeoutMs = 30000 } = {}) {
|
|
98
|
+
if (!baseUrl) baseUrl = getDefaultBaseUrl();
|
|
64
99
|
return new Promise((resolve, reject) => {
|
|
65
100
|
const normalized = _normalizeApiPath(urlPath);
|
|
66
101
|
let u;
|
|
@@ -165,9 +200,10 @@ function _formatPlanOneLine(p) {
|
|
|
165
200
|
// ── Generic passthrough: `minions api <METHOD> <PATH> [...]` ────────────────
|
|
166
201
|
|
|
167
202
|
async function cliApi(argv) {
|
|
203
|
+
const baseUrlDefault = getDefaultBaseUrl();
|
|
168
204
|
const help = `Usage: minions api <METHOD> <PATH> [--body JSON | --body-file FILE] [--base-url URL] [--quiet]
|
|
169
205
|
|
|
170
|
-
Direct HTTP passthrough to the dashboard API at ${
|
|
206
|
+
Direct HTTP passthrough to the dashboard API at ${baseUrlDefault}.
|
|
171
207
|
|
|
172
208
|
Examples:
|
|
173
209
|
minions api GET /api/status
|
|
@@ -187,7 +223,7 @@ Exit codes: 0 = 2xx, 1 = 4xx/5xx, 2 = connection failure / bad usage`;
|
|
|
187
223
|
lineOut(help);
|
|
188
224
|
return 2;
|
|
189
225
|
}
|
|
190
|
-
const baseUrl = takeFlag(argv, '--base-url') ||
|
|
226
|
+
const baseUrl = takeFlag(argv, '--base-url') || baseUrlDefault;
|
|
191
227
|
const bodyInline = takeFlag(argv, '--body');
|
|
192
228
|
const bodyFile = takeFlag(argv, '--body-file');
|
|
193
229
|
const quiet = hasFlag(argv, '--quiet');
|
|
@@ -711,6 +747,7 @@ module.exports = {
|
|
|
711
747
|
isHandled,
|
|
712
748
|
dispatch,
|
|
713
749
|
DEFAULT_BASE_URL,
|
|
750
|
+
getDefaultBaseUrl,
|
|
714
751
|
// Per-command handlers exposed for unit tests that want to drive them
|
|
715
752
|
// directly without going through process.argv.
|
|
716
753
|
_handlers: { cliApi, cliWi, cliPlans, cliSchedule, cliWatch, cliFeature, cliSettings },
|
package/bin/minions.js
CHANGED
|
@@ -64,7 +64,43 @@ const PKG_ROOT = path.resolve(__dirname, '..');
|
|
|
64
64
|
const shared = require(path.join(PKG_ROOT, 'engine', 'shared'));
|
|
65
65
|
const { openUrlInBrowser } = shared;
|
|
66
66
|
const { waitForRestartHealth, formatRestartHealthError } = require(path.join(PKG_ROOT, 'engine', 'restart-health'));
|
|
67
|
-
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the dashboard port for any CLI command. Precedence (W-mq5nwl9l):
|
|
70
|
+
* 1. Runtime file (engine/dashboard-port.json) — what the live dashboard
|
|
71
|
+
* ACTUALLY bound to. Wins over the chain so status/dash/restart-health
|
|
72
|
+
* probes the right port even after an EADDRINUSE auto-fallback.
|
|
73
|
+
* 2. Explicit `--port N` CLI flag if present in `argv`.
|
|
74
|
+
* 3. env MINIONS_DASHBOARD_PORT.
|
|
75
|
+
* 4. config.json#engine.dashboardPort.
|
|
76
|
+
* 5. shared.DEFAULT_DASHBOARD_PORT (7331).
|
|
77
|
+
*
|
|
78
|
+
* Returns `{port, source, fromRuntimeFile}` so log lines can stay accurate.
|
|
79
|
+
* `fromRuntimeFile=true` means a dashboard wrote it; falsy means we computed
|
|
80
|
+
* the desired port from the chain (used when SPAWNING a fresh dashboard).
|
|
81
|
+
*/
|
|
82
|
+
function resolveDashboardPort(argv) {
|
|
83
|
+
// Read MINIONS_HOME lazily via env so this helper works at CLI-init time
|
|
84
|
+
// (before the const below is bound) — the const is resolved later.
|
|
85
|
+
const home = process.env.MINIONS_HOME || (typeof MINIONS_HOME !== 'undefined' ? MINIONS_HOME : null);
|
|
86
|
+
const rt = home ? shared.readDashboardPortFile(home) : shared.readDashboardPortFile();
|
|
87
|
+
if (rt && rt.port) return { port: rt.port, source: 'runtime-file', fromRuntimeFile: true };
|
|
88
|
+
let explicit = null;
|
|
89
|
+
if (Array.isArray(argv)) {
|
|
90
|
+
const i = argv.indexOf('--port');
|
|
91
|
+
if (i >= 0 && argv[i + 1]) explicit = argv[i + 1];
|
|
92
|
+
}
|
|
93
|
+
let cfg = null;
|
|
94
|
+
try {
|
|
95
|
+
if (home) cfg = JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8'));
|
|
96
|
+
} catch { /* config may not exist yet */ }
|
|
97
|
+
const chain = shared.resolveDashboardPort({
|
|
98
|
+
explicit,
|
|
99
|
+
env: process.env.MINIONS_DASHBOARD_PORT,
|
|
100
|
+
config: cfg && cfg.engine && cfg.engine.dashboardPort,
|
|
101
|
+
});
|
|
102
|
+
return { port: chain.port, source: chain.source, fromRuntimeFile: false };
|
|
103
|
+
}
|
|
68
104
|
|
|
69
105
|
/** Returns PIDs (as strings) of processes LISTENING on `port`. Empty on no match
|
|
70
106
|
* or when the platform tool (netstat/findstr/lsof) is unavailable. */
|
|
@@ -276,12 +312,17 @@ function _sqliteSpawnFlags() {
|
|
|
276
312
|
/** Spawn a detached dashboard with self-open suppressed — the CLI decides
|
|
277
313
|
* when to open a browser based on whether a real tab reconnects post-health.
|
|
278
314
|
* stdout/stderr land in engine/dashboard-stdio.log so silent startup crashes
|
|
279
|
-
* leave a postmortem instead of vanishing.
|
|
280
|
-
|
|
315
|
+
* leave a postmortem instead of vanishing. The optional `requestedPort` is
|
|
316
|
+
* forwarded as argv[2] so the dashboard's own port resolver agrees with the
|
|
317
|
+
* CLI's intent (EADDRINUSE retries still apply inside the dashboard if a
|
|
318
|
+
* race opens between the CLI's port-free check and the actual bind). */
|
|
319
|
+
function spawnDashboard(requestedPort) {
|
|
281
320
|
const env = { ...process.env, MINIONS_NO_AUTO_OPEN: '1' };
|
|
282
321
|
const out = _openStdioLog('dashboard-stdio.log');
|
|
283
322
|
const err = _openStdioLog('dashboard-stdio.log');
|
|
284
|
-
const
|
|
323
|
+
const args = [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'dashboard.js')];
|
|
324
|
+
if (requestedPort) args.push(String(requestedPort));
|
|
325
|
+
const proc = spawn(process.execPath, args, {
|
|
285
326
|
cwd: MINIONS_HOME, stdio: ['ignore', out, err], detached: true, windowsHide: true, env
|
|
286
327
|
});
|
|
287
328
|
proc.unref();
|
|
@@ -324,17 +365,30 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
|
|
|
324
365
|
});
|
|
325
366
|
engineProc.unref();
|
|
326
367
|
console.log(`\n Engine started (PID: ${engineProc.pid})`);
|
|
327
|
-
|
|
368
|
+
// The CLI has already killed any stale dashboard + port-released; the
|
|
369
|
+
// resolved chain port is what the new dashboard will TRY first. Forward
|
|
370
|
+
// it via argv so the dashboard's own resolver agrees. EADDRINUSE auto-
|
|
371
|
+
// fallback inside dashboard.js will pick a different port if a race opens.
|
|
372
|
+
const requested = resolveDashboardPort(rest);
|
|
373
|
+
const dashProc = spawnDashboard(requested.port);
|
|
328
374
|
console.log(` Dashboard started (PID: ${dashProc.pid})`);
|
|
329
|
-
console.log(` Dashboard: http://localhost:${
|
|
375
|
+
console.log(` Dashboard: http://localhost:${requested.port}`);
|
|
330
376
|
const supProc = spawnSupervisor();
|
|
331
377
|
console.log(` Supervisor started (PID: ${supProc.pid})`);
|
|
332
378
|
console.log(' Verifying restart health...');
|
|
333
379
|
void (async () => {
|
|
380
|
+
// Poll the runtime port file so we use the ACTUAL bound port (which may
|
|
381
|
+
// differ from `requested.port` after EADDRINUSE retry). If the dashboard
|
|
382
|
+
// never writes it, fall back to the requested port — restart-health then
|
|
383
|
+
// surfaces a clear failure rather than the CLI silently mis-probing.
|
|
384
|
+
const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 12000) || requested.port;
|
|
385
|
+
if (actualPort !== requested.port) {
|
|
386
|
+
console.log(` Dashboard bound to alternate port ${actualPort} (requested ${requested.port} was busy)`);
|
|
387
|
+
}
|
|
334
388
|
const result = await waitForRestartHealth({
|
|
335
389
|
minionsHome: MINIONS_HOME,
|
|
336
390
|
dashboardPid: dashProc.pid,
|
|
337
|
-
dashboardPort:
|
|
391
|
+
dashboardPort: actualPort,
|
|
338
392
|
});
|
|
339
393
|
if (!result.ok) {
|
|
340
394
|
console.error(formatRestartHealthError(result));
|
|
@@ -346,7 +400,7 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
|
|
|
346
400
|
!(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
|
|
347
401
|
if (shouldOpen) {
|
|
348
402
|
console.log(` Opening dashboard in browser...`);
|
|
349
|
-
_openInBrowser(`http://localhost:${
|
|
403
|
+
_openInBrowser(`http://localhost:${actualPort}`);
|
|
350
404
|
}
|
|
351
405
|
console.log('');
|
|
352
406
|
})().catch(err => {
|
|
@@ -356,6 +410,36 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
|
|
|
356
410
|
});
|
|
357
411
|
}
|
|
358
412
|
|
|
413
|
+
/** Poll engine/dashboard-port.json until it appears (dashboard wrote it on
|
|
414
|
+
* listen) or the timeout elapses. Returns the bound port or null on
|
|
415
|
+
* timeout. Stale files left behind by a crashed dashboard are tolerated —
|
|
416
|
+
* if the file pre-dates the current spawn we still pick it up; the
|
|
417
|
+
* subsequent isPortListening probe in waitForRestartHealth will catch a
|
|
418
|
+
* truly dead dashboard. */
|
|
419
|
+
async function _waitForDashboardPortFile(home, timeoutMs = 12000, pollMs = 150) {
|
|
420
|
+
const start = Date.now();
|
|
421
|
+
const startedAt = start;
|
|
422
|
+
while (Date.now() - start < timeoutMs) {
|
|
423
|
+
const rt = shared.readDashboardPortFile(home);
|
|
424
|
+
if (rt && rt.port) {
|
|
425
|
+
// Prefer entries written AFTER we started polling. If the file is
|
|
426
|
+
// older (stale from a previous instance) it'll get rewritten by the
|
|
427
|
+
// new dashboard within the polling window — keep polling until then.
|
|
428
|
+
if (rt.boundAt) {
|
|
429
|
+
const ts = Date.parse(rt.boundAt);
|
|
430
|
+
if (Number.isFinite(ts) && ts >= startedAt - 1000) return rt.port;
|
|
431
|
+
} else {
|
|
432
|
+
return rt.port;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
436
|
+
}
|
|
437
|
+
// Final read — even a stale entry is better than nothing if we timed out
|
|
438
|
+
// (the chain fallback in the caller covers a fully-missing file).
|
|
439
|
+
const rtFinal = shared.readDashboardPortFile(home);
|
|
440
|
+
return rtFinal && rtFinal.port ? rtFinal.port : null;
|
|
441
|
+
}
|
|
442
|
+
|
|
359
443
|
/** Clear the stop-intent flag so the supervisor resumes guarding the engine
|
|
360
444
|
* and dashboard. Called at the top of every start/restart path.
|
|
361
445
|
* Delegates to engine/shared.js when available so engine.js, dashboard.js,
|
|
@@ -653,7 +737,8 @@ function init() {
|
|
|
653
737
|
if (isUpgrade && skipStart) return;
|
|
654
738
|
|
|
655
739
|
// Auto-start on fresh install; direct force-upgrade restarts automatically.
|
|
656
|
-
const
|
|
740
|
+
const upgradeRequested = resolveDashboardPort([]);
|
|
741
|
+
const dashWasUp = isPortListening(upgradeRequested.port);
|
|
657
742
|
const restartStartMs = Date.now();
|
|
658
743
|
if (isUpgrade) {
|
|
659
744
|
// Pre-write stop-intent so the supervisor doesn't race-respawn the
|
|
@@ -663,7 +748,10 @@ function init() {
|
|
|
663
748
|
try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000, windowsHide: true }); } catch {}
|
|
664
749
|
// Free the dashboard port too — without this the new dashboard EADDRINUSE-dies
|
|
665
750
|
// silently and the user keeps running stale code from the old dashboard process.
|
|
666
|
-
killByPort(
|
|
751
|
+
killByPort(upgradeRequested.port);
|
|
752
|
+
// Wipe the runtime port file so the new dashboard's freshly-written
|
|
753
|
+
// beacon is the source of truth (avoids polling-back a stale entry).
|
|
754
|
+
shared.clearDashboardPortFile(MINIONS_HOME);
|
|
667
755
|
// Clear AFTER kill so the old dashboard can't repopulate during shutdown.
|
|
668
756
|
_clearDashboardBrowserState(MINIONS_HOME);
|
|
669
757
|
}
|
|
@@ -679,19 +767,20 @@ function init() {
|
|
|
679
767
|
engineProc.unref();
|
|
680
768
|
console.log(` Engine started (PID: ${engineProc.pid})`);
|
|
681
769
|
|
|
682
|
-
const dashProc = spawnDashboard();
|
|
770
|
+
const dashProc = spawnDashboard(upgradeRequested.port);
|
|
683
771
|
console.log(` Dashboard started (PID: ${dashProc.pid})`);
|
|
684
|
-
console.log(` Dashboard: http://localhost:${
|
|
772
|
+
console.log(` Dashboard: http://localhost:${upgradeRequested.port}`);
|
|
685
773
|
|
|
686
774
|
const supProc = spawnSupervisor();
|
|
687
775
|
console.log(` Supervisor started (PID: ${supProc.pid})`);
|
|
688
776
|
|
|
689
777
|
void (async () => {
|
|
778
|
+
const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 8000) || upgradeRequested.port;
|
|
690
779
|
const shouldOpen = forceOpen || !dashWasUp ||
|
|
691
780
|
!(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
|
|
692
781
|
if (shouldOpen) {
|
|
693
782
|
console.log(` Opening dashboard in browser...`);
|
|
694
|
-
_openInBrowser(`http://localhost:${
|
|
783
|
+
_openInBrowser(`http://localhost:${actualPort}`);
|
|
695
784
|
}
|
|
696
785
|
})().catch(err => {
|
|
697
786
|
console.log(` Could not open dashboard: ${err.message}`);
|
|
@@ -1010,12 +1099,16 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1010
1099
|
if (enginePid) {
|
|
1011
1100
|
try { process.kill(enginePid, 0); engineAlive = true; } catch { /* dead */ }
|
|
1012
1101
|
}
|
|
1013
|
-
|
|
1102
|
+
// Probe the runtime port file first (already-bound dashboard) then fall
|
|
1103
|
+
// back to the chain. Source distinguishes 'runtime-file' vs default for
|
|
1104
|
+
// the partial-state error message.
|
|
1105
|
+
const startResolved = resolveDashboardPort(rest);
|
|
1106
|
+
const dashUp = isPortListening(startResolved.port);
|
|
1014
1107
|
if (engineAlive && dashUp) {
|
|
1015
|
-
console.log(`\n Minions is already running (engine PID ${enginePid}; dashboard http://localhost:${
|
|
1108
|
+
console.log(`\n Minions is already running (engine PID ${enginePid}; dashboard http://localhost:${startResolved.port}).`);
|
|
1016
1109
|
if (forceOpen) {
|
|
1017
1110
|
console.log(` Opening dashboard in browser...`);
|
|
1018
|
-
_openInBrowser(`http://localhost:${
|
|
1111
|
+
_openInBrowser(`http://localhost:${startResolved.port}`);
|
|
1019
1112
|
} else {
|
|
1020
1113
|
console.log(` Run \`minions dash\` to open the dashboard, or \`minions start --open\` to force a new browser tab.\n`);
|
|
1021
1114
|
}
|
|
@@ -1033,7 +1126,11 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1033
1126
|
// `--cli` / `--model` flags forward to `engine.js start` so the runtime
|
|
1034
1127
|
// fleet flips before the daemon spawns (P-6b3f9c2e AC: works on restart).
|
|
1035
1128
|
ensureInstalled();
|
|
1036
|
-
|
|
1129
|
+
// For restart we want the port the CURRENT dashboard is bound to (so we
|
|
1130
|
+
// kill it on its actual port) — runtime file wins for the kill step. The
|
|
1131
|
+
// chain falls in as fallback if no file exists yet (cold start).
|
|
1132
|
+
const restartResolved = resolveDashboardPort(rest);
|
|
1133
|
+
const dashWasUp = isPortListening(restartResolved.port);
|
|
1037
1134
|
const restartStartMs = Date.now();
|
|
1038
1135
|
// Pre-write stop-intent so the supervisor doesn't race-respawn the
|
|
1039
1136
|
// engine/dashboard we're about to kill. Killed before the engine/dashboard
|
|
@@ -1048,7 +1145,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1048
1145
|
// Force-kill the recorded engine PID (NOT the tree — agent children must
|
|
1049
1146
|
// survive so the new engine can re-attach them via PID files).
|
|
1050
1147
|
killPidOnly(oldEnginePid);
|
|
1051
|
-
killByPort(
|
|
1148
|
+
killByPort(restartResolved.port);
|
|
1052
1149
|
killMinionsProcesses(['engine.js', 'dashboard.js', 'supervisor.js']);
|
|
1053
1150
|
// Confirm the OS finished the asynchronous termination before we spawn new
|
|
1054
1151
|
// processes. Without this, `taskkill /F` returns immediately while the
|
|
@@ -1064,14 +1161,17 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1064
1161
|
process.exit(1);
|
|
1065
1162
|
}
|
|
1066
1163
|
}
|
|
1067
|
-
const portFree = waitForPortRelease(
|
|
1164
|
+
const portFree = waitForPortRelease(restartResolved.port, 10000);
|
|
1068
1165
|
if (!portFree.ok) {
|
|
1069
|
-
console.error(`\n ERROR: Port ${
|
|
1166
|
+
console.error(`\n ERROR: Port ${restartResolved.port} still in use after 10s — killing failed.`);
|
|
1070
1167
|
console.error(` Bound by PID(s): ${portFree.stillBound.join(', ')}`);
|
|
1071
1168
|
console.error(` Manually free the port: taskkill /F /PID ${portFree.stillBound.join(' /PID ')}`);
|
|
1072
1169
|
console.error(` Then retry: minions restart`);
|
|
1073
1170
|
process.exit(1);
|
|
1074
1171
|
}
|
|
1172
|
+
// Wipe the runtime port file so a stale entry from the old dashboard
|
|
1173
|
+
// can't fool the post-spawn poll into reading the old port.
|
|
1174
|
+
shared.clearDashboardPortFile(MINIONS_HOME);
|
|
1075
1175
|
// Clear stale beacons AFTER the kill so the old dashboard's last writes
|
|
1076
1176
|
// can't repopulate the file in the gap between clear and shutdown.
|
|
1077
1177
|
_clearDashboardBrowserState(MINIONS_HOME);
|
|
@@ -1108,8 +1208,9 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1108
1208
|
|
|
1109
1209
|
// 1. Kill all processes
|
|
1110
1210
|
try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME }); } catch {}
|
|
1111
|
-
killByPort(
|
|
1211
|
+
killByPort(resolveDashboardPort([]).port);
|
|
1112
1212
|
killMinionsProcesses(['engine.js', 'dashboard.js', 'spawn-agent.js']);
|
|
1213
|
+
shared.clearDashboardPortFile(MINIONS_HOME);
|
|
1113
1214
|
console.log(' Killed all processes');
|
|
1114
1215
|
|
|
1115
1216
|
// 2. Delete runtime state
|
|
@@ -1199,7 +1300,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1199
1300
|
writeStopIntent('minions uninstall');
|
|
1200
1301
|
killSupervisor();
|
|
1201
1302
|
try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000 }); } catch {}
|
|
1202
|
-
killByPort(
|
|
1303
|
+
killByPort(resolveDashboardPort([]).port);
|
|
1203
1304
|
killMinionsProcesses(['engine.js', 'dashboard.js', 'spawn-agent.js', 'supervisor.js']);
|
|
1204
1305
|
console.log(' Killed all processes');
|
|
1205
1306
|
|
|
@@ -1239,7 +1340,11 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1239
1340
|
doctor(MINIONS_HOME).then(ok => process.exit(ok ? 0 : 1));
|
|
1240
1341
|
} else if (cmd === 'dash' || cmd === 'dashboard') {
|
|
1241
1342
|
ensureInstalled();
|
|
1242
|
-
// If dashboard is already running, just open the browser
|
|
1343
|
+
// If dashboard is already running, just open the browser. The runtime
|
|
1344
|
+
// port file wins over the resolution chain when the dashboard is up so
|
|
1345
|
+
// the probe targets the actually-bound port (which may differ from the
|
|
1346
|
+
// default after an EADDRINUSE auto-fallback).
|
|
1347
|
+
const dashResolved = resolveDashboardPort(rest);
|
|
1243
1348
|
const net = require('net');
|
|
1244
1349
|
const sock = new net.Socket();
|
|
1245
1350
|
let handled = false;
|
|
@@ -1248,7 +1353,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1248
1353
|
sock.destroy();
|
|
1249
1354
|
if (handled) return;
|
|
1250
1355
|
handled = true;
|
|
1251
|
-
const url = `http://localhost:${
|
|
1356
|
+
const url = `http://localhost:${dashResolved.port}`;
|
|
1252
1357
|
console.log(`\n Dashboard already running: ${url}\n`);
|
|
1253
1358
|
openUrlInBrowser(url);
|
|
1254
1359
|
});
|
|
@@ -1264,7 +1369,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1264
1369
|
handled = true;
|
|
1265
1370
|
delegate('dashboard.js', rest);
|
|
1266
1371
|
});
|
|
1267
|
-
sock.connect(
|
|
1372
|
+
sock.connect(dashResolved.port, '127.0.0.1');
|
|
1268
1373
|
} else if (engineCmds.has(cmd)) {
|
|
1269
1374
|
delegate('engine.js', [cmd, ...rest]);
|
|
1270
1375
|
} else if (require('./cli-api-client').isHandled(cmd)) {
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -153,10 +153,11 @@ function prRow(pr) {
|
|
|
153
153
|
? '<span class="pr-agent" title="' + escapeHtml(pr.reviewedBy.join(', ')) + '">' + escapeHtml(pr.reviewedBy.join(', ')) + '</span>'
|
|
154
154
|
: '<span style="color:var(--muted);font-size:var(--text-base)">—</span>';
|
|
155
155
|
const createdLabel = (pr.created || '—').slice(0, 16).replace('T', ' ');
|
|
156
|
-
// Per-row auto-observe toggle (W-mpmwxkzm0009ba0b
|
|
157
|
-
// means the engine polls
|
|
158
|
-
//
|
|
159
|
-
|
|
156
|
+
// Per-row auto-observe toggle (W-mpmwxkzm0009ba0b; canonicalized in
|
|
157
|
+
// W-mq5s5ttx000j7ab8-c). pr.contextOnly === true means the engine polls
|
|
158
|
+
// status/comments but does NOT dispatch review/fix agents against this
|
|
159
|
+
// PR. Default (undefined / false) = observed.
|
|
160
|
+
var observe = pr.contextOnly !== true;
|
|
160
161
|
var observeClass = observe ? 'pr-observe-on' : 'pr-observe-off';
|
|
161
162
|
var observeLabel = observe ? 'observed' : 'context';
|
|
162
163
|
var observeTitle = observe
|
|
@@ -396,9 +397,10 @@ async function unlinkPr(id) {
|
|
|
396
397
|
}
|
|
397
398
|
|
|
398
399
|
// Per-row auto-observe toggle (W-mpmwxkzm0009ba0b; toggle-switch UX in
|
|
399
|
-
// W-mpof8qam000h62fb). Optimistic
|
|
400
|
-
// revert on API error. The engine
|
|
401
|
-
// dispatch (engine/
|
|
400
|
+
// W-mpof8qam000h62fb; canonicalized in W-mq5s5ttx000j7ab8-c). Optimistic
|
|
401
|
+
// UI: flip the switch immediately, only revert on API error. The engine
|
|
402
|
+
// consumes canonical `contextOnly` to gate review/fix dispatch (engine/
|
|
403
|
+
// shared.js isAutoManagedPrRecord + discoverFromPrs).
|
|
402
404
|
//
|
|
403
405
|
// `btn` is the <input type=checkbox> inside .pr-observe-switch. Data-attrs
|
|
404
406
|
// live on the input so this handler keeps reading them directly; visual
|
package/dashboard.js
CHANGED
|
@@ -77,9 +77,26 @@ const TITLE_SUFFIX = IS_DEV_MODE ? ' [DEV]' : '';
|
|
|
77
77
|
}
|
|
78
78
|
})();
|
|
79
79
|
|
|
80
|
-
const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
|
|
81
80
|
let CONFIG = queries.getConfig();
|
|
82
81
|
let PROJECTS = _getProjects(CONFIG);
|
|
82
|
+
// PORT resolution chain (W-mq5nwl9l): explicit CLI arg (argv[2]) > env
|
|
83
|
+
// MINIONS_DASHBOARD_PORT / PORT > config.engine.dashboardPort > default 7331.
|
|
84
|
+
// REQUESTED_PORT is the user/operator intent; BOUND_PORT may differ after an
|
|
85
|
+
// EADDRINUSE retry. Test-mode badge logic stays anchored to REQUESTED_PORT so
|
|
86
|
+
// an accidental port collision (7331 already in use) does NOT cause the live
|
|
87
|
+
// dashboard to wear the "TEST" badge. `PORT` is kept as an alias for the
|
|
88
|
+
// bound port — every existing site that read PORT (banner, dashboardPort
|
|
89
|
+
// substitution for CC, etc.) now sees the actually-bound value.
|
|
90
|
+
const REQUESTED_PORT = (() => {
|
|
91
|
+
const argv = parseInt(process.argv[2], 10);
|
|
92
|
+
if (Number.isInteger(argv) && argv > 0 && argv <= 65535) return argv;
|
|
93
|
+
const envP = parseInt(process.env.MINIONS_DASHBOARD_PORT || process.env.PORT || '', 10);
|
|
94
|
+
if (Number.isInteger(envP) && envP > 0 && envP <= 65535) return envP;
|
|
95
|
+
const cfgP = parseInt(CONFIG && CONFIG.engine && CONFIG.engine.dashboardPort, 10);
|
|
96
|
+
if (Number.isInteger(cfgP) && cfgP > 0 && cfgP <= 65535) return cfgP;
|
|
97
|
+
return shared.DEFAULT_DASHBOARD_PORT;
|
|
98
|
+
})();
|
|
99
|
+
let PORT = REQUESTED_PORT;
|
|
83
100
|
const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
|
|
84
101
|
const PINNED_PATH = path.join(MINIONS_DIR, 'pinned.md');
|
|
85
102
|
const PINNED_DEFAULT_CONTENT = '# Pinned Context\n\nCritical notes visible to all agents.';
|
|
@@ -822,7 +839,7 @@ function collectArchivedWorkItems(minionsDir = MINIONS_DIR, projects = PROJECTS)
|
|
|
822
839
|
}
|
|
823
840
|
return archived;
|
|
824
841
|
}
|
|
825
|
-
function linkPullRequestForTracking({ url, title, project: projectName, autoObserve, context, workItemId }, config = CONFIG, options = {}) {
|
|
842
|
+
function linkPullRequestForTracking({ url, title, project: projectName, contextOnly, autoObserve, context, workItemId }, config = CONFIG, options = {}) {
|
|
826
843
|
if (!url) {
|
|
827
844
|
const err = new Error('url required');
|
|
828
845
|
err.statusCode = 400;
|
|
@@ -838,6 +855,12 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
|
|
|
838
855
|
const linkedWorkItemId = getWorkItemIdFromPrLinkContext(context, workItemId);
|
|
839
856
|
const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
|
|
840
857
|
const metadata = normalizePrMetadata(options.metadata);
|
|
858
|
+
// W-mq5s5ttx000j7ab8-c: canonical `contextOnly` field with `autoObserve`
|
|
859
|
+
// deprecated alias. Explicit canonical wins; otherwise fall back to the
|
|
860
|
+
// alias (autoObserve:true → contextOnly:false); otherwise default false.
|
|
861
|
+
const resolvedContextOnly = typeof contextOnly === 'boolean'
|
|
862
|
+
? contextOnly
|
|
863
|
+
: (autoObserve === undefined ? false : !autoObserve);
|
|
841
864
|
const result = shared.upsertPullRequestRecord(prPath, {
|
|
842
865
|
id: prId,
|
|
843
866
|
prNumber: parseInt(prNum, 10) || null,
|
|
@@ -850,9 +873,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
|
|
|
850
873
|
created: new Date().toISOString(),
|
|
851
874
|
url,
|
|
852
875
|
prdItems: linkedWorkItemId ? [linkedWorkItemId] : [],
|
|
853
|
-
|
|
854
|
-
_contextOnly: !autoObserve,
|
|
855
|
-
_autoObserve: !!autoObserve,
|
|
876
|
+
contextOnly: resolvedContextOnly,
|
|
856
877
|
_context: contextText,
|
|
857
878
|
_projectResolution: projectResolution,
|
|
858
879
|
}, {
|
|
@@ -863,9 +884,11 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
|
|
|
863
884
|
}
|
|
864
885
|
|
|
865
886
|
// W-mpmwxkzm0009ba0b — Per-row auto-observe toggle backing helper for
|
|
866
|
-
// POST /api/pull-requests/observe. Flips
|
|
867
|
-
//
|
|
887
|
+
// POST /api/pull-requests/observe. Flips canonical `contextOnly` on an
|
|
888
|
+
// existing tracked PR record under a lock (per CLAUDE.md mutate convention).
|
|
868
889
|
// Body shape: { host: 'github'|'ado', slug, number, observe: boolean }.
|
|
890
|
+
// The public `observe` body param is preserved for backward compatibility
|
|
891
|
+
// (W-mq5s5ttx000j7ab8-c); internally it maps to `contextOnly = !observe`.
|
|
869
892
|
// Returns the updated record + the PR path that was touched. Throws an
|
|
870
893
|
// Error with `statusCode` for the route handler to map to an HTTP status.
|
|
871
894
|
function updatePullRequestObserveFlag({ host, slug, number, observe } = {}, config = CONFIG, minionsDir = MINIONS_DIR) {
|
|
@@ -907,9 +930,8 @@ function updatePullRequestObserveFlag({ host, slug, number, observe } = {}, conf
|
|
|
907
930
|
shared.mutatePullRequests(prPath, (prs) => {
|
|
908
931
|
const pr = prs.find(p => p && p.id === canonicalId);
|
|
909
932
|
if (!pr) return prs;
|
|
910
|
-
pr.
|
|
911
|
-
pr.
|
|
912
|
-
updated = { id: pr.id, _contextOnly: pr._contextOnly, _autoObserve: pr._autoObserve };
|
|
933
|
+
pr.contextOnly = !observe;
|
|
934
|
+
updated = { id: pr.id, contextOnly: pr.contextOnly };
|
|
913
935
|
updatedPath = prPath;
|
|
914
936
|
return prs;
|
|
915
937
|
});
|
|
@@ -1052,16 +1074,21 @@ function resolvePlanPath(file) {
|
|
|
1052
1074
|
}
|
|
1053
1075
|
|
|
1054
1076
|
// Test-mode banner: surfaced in <title> + <h1> + body class when the dashboard
|
|
1055
|
-
// is started under MINIONS_TEST_DIR or on a non-default port. Makes
|
|
1056
|
-
// obvious that the user is looking at a sandboxed instance, not
|
|
1057
|
-
// fleet — prevents acting on test fixtures by mistake.
|
|
1077
|
+
// is started under MINIONS_TEST_DIR or on a non-default REQUESTED port. Makes
|
|
1078
|
+
// it visually obvious that the user is looking at a sandboxed instance, not
|
|
1079
|
+
// their live fleet — prevents acting on test fixtures by mistake. Anchored to
|
|
1080
|
+
// REQUESTED_PORT (not the bound PORT) so an EADDRINUSE auto-fallback from
|
|
1081
|
+
// 7331 → 7332 does NOT promote a live dashboard to TEST-mode.
|
|
1058
1082
|
function _isTestMode() {
|
|
1059
|
-
return !!process.env.MINIONS_TEST_DIR
|
|
1083
|
+
return !!process.env.MINIONS_TEST_DIR
|
|
1084
|
+
|| (typeof REQUESTED_PORT === 'number' && REQUESTED_PORT !== shared.DEFAULT_DASHBOARD_PORT);
|
|
1060
1085
|
}
|
|
1061
1086
|
|
|
1062
1087
|
function _testBadgeLabel() {
|
|
1063
1088
|
if (process.env.MINIONS_TEST_DIR) return 'TEST';
|
|
1064
|
-
if (typeof
|
|
1089
|
+
if (typeof REQUESTED_PORT === 'number' && REQUESTED_PORT !== shared.DEFAULT_DASHBOARD_PORT) {
|
|
1090
|
+
return `TEST :${REQUESTED_PORT}`;
|
|
1091
|
+
}
|
|
1065
1092
|
return '';
|
|
1066
1093
|
}
|
|
1067
1094
|
|
|
@@ -11351,7 +11378,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11351
11378
|
// /api/prd/regenerate removed — use /api/plans/approve which does diff-aware update
|
|
11352
11379
|
|
|
11353
11380
|
// Agents
|
|
11354
|
-
{ method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?,
|
|
11381
|
+
{ method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?, contextOnly?, autoObserve? (deprecated alias for !contextOnly), context?, workItemId?', handler: async (req, res) => {
|
|
11355
11382
|
const body = await readBody(req);
|
|
11356
11383
|
const { url } = body;
|
|
11357
11384
|
if (!url) return jsonReply(res, 400, { error: 'url required' });
|
|
@@ -11448,13 +11475,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11448
11475
|
})();
|
|
11449
11476
|
}},
|
|
11450
11477
|
|
|
11451
|
-
{ method: 'POST', path: '/api/pull-requests/observe', desc: 'Toggle
|
|
11478
|
+
{ method: 'POST', path: '/api/pull-requests/observe', desc: 'Toggle canonical contextOnly flag on a tracked PR (public `observe` body param is preserved as the inverse for backward compat)', params: 'host (github|ado), slug, number, observe (boolean)', handler: async (req, res) => {
|
|
11452
11479
|
const body = await readBody(req);
|
|
11453
11480
|
reloadConfig();
|
|
11454
11481
|
try {
|
|
11455
11482
|
const result = updatePullRequestObserveFlag(body, CONFIG);
|
|
11456
11483
|
invalidateStatusCache();
|
|
11457
|
-
return jsonReply(res, 200, { ok: true, ...result, observe: !result.
|
|
11484
|
+
return jsonReply(res, 200, { ok: true, ...result, observe: !result.contextOnly });
|
|
11458
11485
|
} catch (e) {
|
|
11459
11486
|
return jsonReply(res, e.statusCode || 400, { error: e.message });
|
|
11460
11487
|
}
|
|
@@ -12279,7 +12306,54 @@ if (require.main === module) {
|
|
|
12279
12306
|
console.warn(`[boot] pre-warm git status failed: ${e && e.message}`);
|
|
12280
12307
|
}
|
|
12281
12308
|
|
|
12282
|
-
|
|
12309
|
+
// Listen with EADDRINUSE auto-fallback (W-mq5nwl9l). Tries REQUESTED_PORT
|
|
12310
|
+
// first; on EADDRINUSE scans upward through shared.DASHBOARD_PORT_SCAN_MAX
|
|
12311
|
+
// ports and binds to the first free one. The actually-bound port is
|
|
12312
|
+
// written to engine/dashboard-port.json as soon as listen resolves so
|
|
12313
|
+
// every downstream consumer (CLI status/dash/restart, cli-api-client,
|
|
12314
|
+
// supervisor) discovers it instead of guessing 7331. The original
|
|
12315
|
+
// server.on('error') one-shot exit is replaced by the retry loop — only
|
|
12316
|
+
// non-EADDRINUSE errors abort.
|
|
12317
|
+
const maxScan = shared.DASHBOARD_PORT_SCAN_MAX || 100;
|
|
12318
|
+
let listenErr = null;
|
|
12319
|
+
for (let attempt = 0; attempt < maxScan; attempt++) {
|
|
12320
|
+
const tryPort = REQUESTED_PORT + attempt;
|
|
12321
|
+
try {
|
|
12322
|
+
await new Promise((resolve, reject) => {
|
|
12323
|
+
const onError = (e) => { server.removeListener('listening', onListen); reject(e); };
|
|
12324
|
+
const onListen = () => { server.removeListener('error', onError); resolve(); };
|
|
12325
|
+
server.once('error', onError);
|
|
12326
|
+
server.once('listening', onListen);
|
|
12327
|
+
server.listen(tryPort, '127.0.0.1');
|
|
12328
|
+
});
|
|
12329
|
+
if (tryPort !== REQUESTED_PORT) {
|
|
12330
|
+
console.log(`[dashboard] requested port ${REQUESTED_PORT} in use, bound to ${tryPort}`);
|
|
12331
|
+
}
|
|
12332
|
+
PORT = tryPort;
|
|
12333
|
+
listenErr = null;
|
|
12334
|
+
break;
|
|
12335
|
+
} catch (e) {
|
|
12336
|
+
listenErr = e;
|
|
12337
|
+
if (e && e.code !== 'EADDRINUSE') break;
|
|
12338
|
+
// Loop to next candidate port.
|
|
12339
|
+
}
|
|
12340
|
+
}
|
|
12341
|
+
if (listenErr) {
|
|
12342
|
+
if (listenErr.code === 'EADDRINUSE') {
|
|
12343
|
+
console.error(`\n No free port in [${REQUESTED_PORT}..${REQUESTED_PORT + maxScan - 1}]. Free one and retry.\n`);
|
|
12344
|
+
} else {
|
|
12345
|
+
console.error(listenErr);
|
|
12346
|
+
}
|
|
12347
|
+
process.exit(1);
|
|
12348
|
+
}
|
|
12349
|
+
|
|
12350
|
+
// Persist the actually-bound port for every downstream consumer.
|
|
12351
|
+
try {
|
|
12352
|
+
shared.writeDashboardPortFile({ port: PORT, pid: process.pid, minionsHome: MINIONS_DIR });
|
|
12353
|
+
} catch (e) {
|
|
12354
|
+
console.warn(`[dashboard] could not write dashboard-port.json: ${e && e.message}`);
|
|
12355
|
+
}
|
|
12356
|
+
|
|
12283
12357
|
console.log(`\n Minions Mission Control`);
|
|
12284
12358
|
console.log(` -----------------------------------`);
|
|
12285
12359
|
console.log(` http://localhost:${PORT}`);
|
|
@@ -12358,20 +12432,14 @@ if (require.main === module) {
|
|
|
12358
12432
|
}
|
|
12359
12433
|
}, 30000).unref();
|
|
12360
12434
|
console.log(` Engine watchdog: active (checks every 30s)`);
|
|
12361
|
-
});
|
|
12362
12435
|
})();
|
|
12363
12436
|
|
|
12364
|
-
|
|
12365
|
-
|
|
12366
|
-
|
|
12367
|
-
}
|
|
12368
|
-
|
|
12369
|
-
|
|
12370
|
-
|
|
12371
|
-
});
|
|
12372
|
-
|
|
12373
|
-
// ── Graceful shutdown: flush debounced writes ──────────────────────────────
|
|
12374
|
-
server.on('close', () => flushPendingDocSessions());
|
|
12375
|
-
process.on('SIGTERM', () => { flushPendingDocSessions(); process.exit(0); });
|
|
12376
|
-
process.on('SIGINT', () => { flushPendingDocSessions(); process.exit(0); });
|
|
12437
|
+
// ── Graceful shutdown: flush debounced writes + clear runtime port file ──
|
|
12438
|
+
function _gracefulShutdown() {
|
|
12439
|
+
try { flushPendingDocSessions(); } catch {}
|
|
12440
|
+
try { shared.clearDashboardPortFile(MINIONS_DIR); } catch {}
|
|
12441
|
+
}
|
|
12442
|
+
server.on('close', () => _gracefulShutdown());
|
|
12443
|
+
process.on('SIGTERM', () => { _gracefulShutdown(); process.exit(0); });
|
|
12444
|
+
process.on('SIGINT', () => { _gracefulShutdown(); process.exit(0); });
|
|
12377
12445
|
}
|