@yemi33/minions 0.1.2445 → 0.1.2447
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/install-internal-minions.js +485 -35
- package/dashboard.js +39 -10
- package/docs/internal-install.md +108 -10
- package/engine/api-contracts/orchestration.js +23 -3
- package/engine/api-contracts/paging.js +74 -0
- package/engine/api-contracts/pull-requests.js +10 -0
- package/engine/api-contracts/work-plan-prd.js +32 -5
- package/engine/plan-prd-validation.js +12 -3
- package/package.json +1 -1
|
@@ -227,8 +227,42 @@ function buildNpmInstallTarballArgs(tarballPath) {
|
|
|
227
227
|
return ['install', '-g', tarballPath];
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
function buildNpmUninstallArgs(packageName) {
|
|
231
|
-
|
|
230
|
+
function buildNpmUninstallArgs(packageName, { userconfig = null } = {}) {
|
|
231
|
+
const args = ['uninstall', '-g', packageName];
|
|
232
|
+
if (userconfig) args.push('--userconfig', userconfig);
|
|
233
|
+
return args;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Reinstall a package from whatever registry the caller's own npm config points
|
|
238
|
+
* at. This is the rollback path: it deliberately carries no `--registry`,
|
|
239
|
+
* because the package being restored is the one that was already there and the
|
|
240
|
+
* internal feed is exactly what may have just failed.
|
|
241
|
+
*/
|
|
242
|
+
function buildNpmRestoreArgs(packageName, { userconfig = null } = {}) {
|
|
243
|
+
const args = ['install', '-g', packageName];
|
|
244
|
+
if (userconfig) args.push('--userconfig', userconfig);
|
|
245
|
+
return args;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const NPM_GLOBAL_PATH_KINDS = new Set(['root', 'prefix']);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `npm root -g` / `npm prefix -g` under an explicit config file.
|
|
252
|
+
*
|
|
253
|
+
* Both answers are prefix-sensitive, and a custom prefix (the reported customer
|
|
254
|
+
* install uses `C:\.tools\.npm-global`) usually comes from an npmrc rather than
|
|
255
|
+
* from a flag. Resolving them under the SAME `--userconfig` the install uses is
|
|
256
|
+
* what stops the probe and the install from disagreeing about which prefix owns
|
|
257
|
+
* the `minions` shim we are about to inspect, clear, and recreate.
|
|
258
|
+
*/
|
|
259
|
+
function buildNpmGlobalPathArgs(kind, { userconfig = null } = {}) {
|
|
260
|
+
if (!NPM_GLOBAL_PATH_KINDS.has(kind)) {
|
|
261
|
+
throw new Error(`npm global path kind must be "root" or "prefix", got: ${kind}`);
|
|
262
|
+
}
|
|
263
|
+
const args = [kind, '-g'];
|
|
264
|
+
if (userconfig) args.push('--userconfig', userconfig);
|
|
265
|
+
return args;
|
|
232
266
|
}
|
|
233
267
|
|
|
234
268
|
/** Resolve the on-disk root of a globally installed package from `npm root -g`. */
|
|
@@ -292,6 +326,24 @@ function resolveBinShimPaths(npmPrefixG, platform = process.platform) {
|
|
|
292
326
|
return [path.join(prefix, 'bin', 'minions')];
|
|
293
327
|
}
|
|
294
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Does a `minions` shim occupy this path? Deliberately `lstat`, never
|
|
331
|
+
* `existsSync`: `existsSync` stats THROUGH a symlink and answers `false` for a
|
|
332
|
+
* dangling one — which is precisely the orphan we are hunting, npm's
|
|
333
|
+
* `<prefix>/bin/minions` link outliving the package directory it pointed into.
|
|
334
|
+
* That link still makes `npm install -g` abort with EEXIST, so "the target is
|
|
335
|
+
* gone" must never read as "the path is free".
|
|
336
|
+
*/
|
|
337
|
+
function shimPathPresent(shimPath, deps = {}) {
|
|
338
|
+
const lstat = deps.lstatSync || (p => fs.lstatSync(p));
|
|
339
|
+
try {
|
|
340
|
+
lstat(shimPath);
|
|
341
|
+
return true;
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
295
347
|
/**
|
|
296
348
|
* Does this shim dispatch into `packageName`? The scoped name is discriminating,
|
|
297
349
|
* so a shim left behind by `@yemi33/minions` cannot pass as `@opg-microsoft/minions`.
|
|
@@ -325,9 +377,13 @@ function verifyBinShims({ npmPrefixG, packageName, platform = process.platform,
|
|
|
325
377
|
const candidates = resolveBinShimPaths(npmPrefixG, platform);
|
|
326
378
|
const shims = [];
|
|
327
379
|
for (const shimPath of candidates) {
|
|
328
|
-
if (!
|
|
380
|
+
if (!shimPathPresent(shimPath, deps)) continue;
|
|
329
381
|
shims.push({
|
|
330
382
|
path: shimPath,
|
|
383
|
+
// `exists` follows the link on purpose here: the question is whether the
|
|
384
|
+
// command a user types can actually run, so a shim whose target is gone
|
|
385
|
+
// is broken no matter which package its link names.
|
|
386
|
+
broken: !exists(shimPath),
|
|
331
387
|
matches: shimTargetsPackage({ shimPath, packageName, deps }),
|
|
332
388
|
matchesPublic: packageName === PUBLIC_PACKAGE
|
|
333
389
|
? false
|
|
@@ -337,6 +393,14 @@ function verifyBinShims({ npmPrefixG, packageName, platform = process.platform,
|
|
|
337
393
|
if (!shims.length) {
|
|
338
394
|
return { ok: false, shims, reason: `no \`minions\` shim was found under ${String(npmPrefixG).trim()}` };
|
|
339
395
|
}
|
|
396
|
+
const broken = shims.find(shim => shim.broken);
|
|
397
|
+
if (broken) {
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
shims,
|
|
401
|
+
reason: `the \`minions\` shim at ${broken.path} is a broken link — its target no longer exists`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
340
404
|
const mismatched = shims.filter(shim => !shim.matches);
|
|
341
405
|
if (mismatched.length) {
|
|
342
406
|
const stale = mismatched.find(shim => shim.matchesPublic);
|
|
@@ -351,7 +415,167 @@ function verifyBinShims({ npmPrefixG, packageName, platform = process.platform,
|
|
|
351
415
|
return { ok: true, shims, reason: `\`minions\` resolves to ${packageName} (${shims.map(s => path.basename(s.path)).join(', ')})` };
|
|
352
416
|
}
|
|
353
417
|
|
|
354
|
-
// ───
|
|
418
|
+
// ─── Orphaned `minions` shim repair ─────────────────────────────────────────
|
|
419
|
+
//
|
|
420
|
+
// npm refuses to overwrite a bin shim it does not currently own and aborts the
|
|
421
|
+
// whole global install with EEXIST. A prior `npm uninstall -g` that was
|
|
422
|
+
// interrupted — or any half-finished public→internal migration — can remove
|
|
423
|
+
// `<npm root -g>/<package>` while leaving the `minions` trio under
|
|
424
|
+
// `npm prefix -g`. `readGlobalPackageVersion` then reports nothing installed,
|
|
425
|
+
// the run looks like a fresh install, and `npm install -g` dies on the leftover.
|
|
426
|
+
//
|
|
427
|
+
// The repair is ownership-safe by construction: a shim is only ever removed
|
|
428
|
+
// when its body (or symlink target) names a package this installer owns AND
|
|
429
|
+
// that package is no longer usable on disk. Anything else — a colleague's
|
|
430
|
+
// script, a wrapper, an unreadable file — blocks the run with the exact path to
|
|
431
|
+
// resolve rather than being deleted.
|
|
432
|
+
|
|
433
|
+
/** Package names whose generated `minions` shim this installer may remove. */
|
|
434
|
+
const SHIM_OWNER_PACKAGES = [INTERNAL_PACKAGE, PUBLIC_PACKAGE];
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Is a globally installed package still usable — package.json present AND every
|
|
438
|
+
* executable it declares in `bin` still on disk?
|
|
439
|
+
*
|
|
440
|
+
* "Present" is not enough. An interrupted uninstall can leave the package
|
|
441
|
+
* directory behind with its `bin/` already reaped, and a shim dispatching into
|
|
442
|
+
* that is exactly as broken as one pointing at nothing.
|
|
443
|
+
*/
|
|
444
|
+
function isGlobalPackageUsable(npmRootG, packageName, deps = {}) {
|
|
445
|
+
const exists = deps.existsSync || (p => fs.existsSync(p));
|
|
446
|
+
const readFile = deps.readFileSync || (p => fs.readFileSync(p, 'utf8'));
|
|
447
|
+
if (!String(npmRootG == null ? '' : npmRootG).trim()) return false;
|
|
448
|
+
let root;
|
|
449
|
+
try { root = resolveGlobalPackageRoot(npmRootG, packageName); } catch { return false; }
|
|
450
|
+
let manifest;
|
|
451
|
+
try { manifest = JSON.parse(readFile(path.join(root, 'package.json'))); } catch { return false; }
|
|
452
|
+
const bin = manifest && manifest.bin;
|
|
453
|
+
const targets = typeof bin === 'string'
|
|
454
|
+
? [bin]
|
|
455
|
+
: (bin && typeof bin === 'object' ? Object.values(bin).filter(v => typeof v === 'string') : []);
|
|
456
|
+
if (!targets.length) return exists(root);
|
|
457
|
+
return targets.every(rel => exists(path.join(root, ...String(rel).split('/'))));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Inspect every platform-specific `minions` shim under the exact prefix the
|
|
462
|
+
* install will use and decide what — if anything — has to be cleared first.
|
|
463
|
+
*
|
|
464
|
+
* Returns `{ needsRepair, blocked, remove, shims, reason }`:
|
|
465
|
+
* - `blocked` an existing `minions` file could not be attributed to a
|
|
466
|
+
* package we own. Nothing is scheduled for deletion and the
|
|
467
|
+
* caller must refuse; `reason` names the exact path.
|
|
468
|
+
* - `needsRepair` at least one shim is orphaned or stale. `remove` is the
|
|
469
|
+
* COMPLETE npm-generated set that exists (the Windows
|
|
470
|
+
* `.cmd`/`.ps1`/sh trio is written and rewritten as a unit, so
|
|
471
|
+
* leaving a sibling behind just moves the EEXIST).
|
|
472
|
+
*
|
|
473
|
+
* An unresolvable prefix or root is a skip, never a refusal and never a
|
|
474
|
+
* deletion: without both we cannot tell a shim from an unrelated file.
|
|
475
|
+
*/
|
|
476
|
+
function assessOrphanShims({
|
|
477
|
+
npmPrefixG,
|
|
478
|
+
npmRootG,
|
|
479
|
+
packageNames = SHIM_OWNER_PACKAGES,
|
|
480
|
+
platform = process.platform,
|
|
481
|
+
deps = {},
|
|
482
|
+
} = {}) {
|
|
483
|
+
const prefix = String(npmPrefixG == null ? '' : npmPrefixG).trim();
|
|
484
|
+
const root = String(npmRootG == null ? '' : npmRootG).trim();
|
|
485
|
+
const owners = (packageNames && packageNames.length ? packageNames : SHIM_OWNER_PACKAGES);
|
|
486
|
+
const skip = reason => ({ needsRepair: false, blocked: false, remove: [], shims: [], reason });
|
|
487
|
+
|
|
488
|
+
if (!prefix || !root) {
|
|
489
|
+
return skip('npm did not report both a global prefix and a global root; shim repair skipped');
|
|
490
|
+
}
|
|
491
|
+
let candidates;
|
|
492
|
+
try { candidates = resolveBinShimPaths(prefix, platform); } catch { return skip('the global npm prefix could not be resolved; shim repair skipped'); }
|
|
493
|
+
|
|
494
|
+
const shims = [];
|
|
495
|
+
for (const shimPath of candidates) {
|
|
496
|
+
if (!shimPathPresent(shimPath, deps)) continue;
|
|
497
|
+
const owner = owners.find(name => shimTargetsPackage({ shimPath, packageName: name, deps })) || null;
|
|
498
|
+
shims.push({ path: shimPath, owner, usable: owner ? isGlobalPackageUsable(root, owner, deps) : false });
|
|
499
|
+
}
|
|
500
|
+
if (!shims.length) {
|
|
501
|
+
return { needsRepair: false, blocked: false, remove: [], shims, reason: `no \`minions\` shim exists under ${prefix}` };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Ownership first, and wholesale: a prefix holding one unattributable
|
|
505
|
+
// `minions` file is not a prefix we are willing to partially clear.
|
|
506
|
+
const unprovable = shims.filter(shim => !shim.owner);
|
|
507
|
+
if (unprovable.length) {
|
|
508
|
+
return {
|
|
509
|
+
needsRepair: false,
|
|
510
|
+
blocked: true,
|
|
511
|
+
remove: [],
|
|
512
|
+
shims,
|
|
513
|
+
reason: `${unprovable.map(s => s.path).join(', ')} is named \`minions\` but was not generated by npm for `
|
|
514
|
+
+ `${owners.join(' or ')}; refusing to delete a file this installer does not own. `
|
|
515
|
+
+ 'Move or rename it (or remove it yourself if it is a leftover) and re-run.',
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const orphans = shims.filter(shim => !shim.usable);
|
|
520
|
+
if (!orphans.length) {
|
|
521
|
+
return {
|
|
522
|
+
needsRepair: false,
|
|
523
|
+
blocked: false,
|
|
524
|
+
remove: [],
|
|
525
|
+
shims,
|
|
526
|
+
reason: `every \`minions\` shim under ${prefix} is backed by an installed package`,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
const staleOwners = [...new Set(orphans.map(shim => shim.owner))];
|
|
530
|
+
return {
|
|
531
|
+
needsRepair: true,
|
|
532
|
+
blocked: false,
|
|
533
|
+
remove: shims.map(shim => shim.path),
|
|
534
|
+
shims,
|
|
535
|
+
reason: `${orphans.length} \`minions\` shim(s) under ${prefix} still dispatch into ${staleOwners.join(', ')}, `
|
|
536
|
+
+ 'which is no longer installed; clearing the npm-generated set so `npm install -g` can recreate it',
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Delete the shims `assessOrphanShims` scheduled. Idempotent — a path that is
|
|
542
|
+
* already gone is not a failure, so re-running a partially completed repair
|
|
543
|
+
* converges instead of erroring.
|
|
544
|
+
*
|
|
545
|
+
* `unlink` first, not `rm`: `fs.rmSync(p, { force: true })` decides "already
|
|
546
|
+
* absent" with a symlink-following existence check, so it returns success
|
|
547
|
+
* WITHOUT clearing a dangling link — the one case this repair exists for.
|
|
548
|
+
* `rm` stays as the fallback for whatever unlink refuses. Every removal is then
|
|
549
|
+
* re-probed, because a delete that silently no-ops must be reported as a
|
|
550
|
+
* failure the caller can act on rather than as a cleared path npm will still
|
|
551
|
+
* abort on.
|
|
552
|
+
*/
|
|
553
|
+
function removeOrphanShims(paths, deps = {}) {
|
|
554
|
+
const unlink = deps.unlinkSync || (p => fs.unlinkSync(p));
|
|
555
|
+
const remove = deps.rmSync || ((p, options) => fs.rmSync(p, options));
|
|
556
|
+
const removed = [];
|
|
557
|
+
const failed = [];
|
|
558
|
+
for (const target of paths || []) {
|
|
559
|
+
if (!shimPathPresent(target, deps)) continue;
|
|
560
|
+
try {
|
|
561
|
+
try {
|
|
562
|
+
unlink(target);
|
|
563
|
+
} catch (e) {
|
|
564
|
+
if (e && e.code === 'ENOENT') continue;
|
|
565
|
+
remove(target, { force: true });
|
|
566
|
+
}
|
|
567
|
+
if (shimPathPresent(target, deps)) {
|
|
568
|
+
throw new Error('the delete reported success but the path still exists');
|
|
569
|
+
}
|
|
570
|
+
removed.push(target);
|
|
571
|
+
} catch (e) {
|
|
572
|
+
failed.push({ path: target, error: e && e.message ? e.message : String(e) });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return { removed, failed };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
|
|
355
579
|
//
|
|
356
580
|
// On Windows both `npm` and `az` are `.cmd` shims. Node >= 18.20 refuses to
|
|
357
581
|
// spawn `.cmd`/`.bat` without a shell (CVE-2024-27980), and `shell: true` does
|
|
@@ -507,6 +731,96 @@ function diffPreservedPaths(before, after) {
|
|
|
507
731
|
return before.filter(rel => !afterSet.has(rel));
|
|
508
732
|
}
|
|
509
733
|
|
|
734
|
+
/**
|
|
735
|
+
* Runtime paths that the PACKAGE ships and `minions init --force` therefore
|
|
736
|
+
* rewrites with package defaults (`bin/minions.js#copyDir` force-overwrites
|
|
737
|
+
* every shipped file except `config.json`), yet whose installed contents are
|
|
738
|
+
* operator- or agent-authored:
|
|
739
|
+
*
|
|
740
|
+
* - `routing.md` the operator's work-type → agent routing table
|
|
741
|
+
* - `knowledge/agents/` per-agent memory written by engine/consolidation.js
|
|
742
|
+
*
|
|
743
|
+
* A package-channel cutover must not reseed either. Their bytes are captured
|
|
744
|
+
* before `sync-init` and put back after it, so the sync can still add newly
|
|
745
|
+
* required files and run schema migrations while existing content survives.
|
|
746
|
+
*/
|
|
747
|
+
const INIT_RESEEDABLE_PATHS = ['routing.md', 'knowledge/agents'];
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Read the current bytes of every reseedable file under a runtime root.
|
|
751
|
+
* Returns `[{ rel, contents }]` — a list, not a tree, so restoring is a plain
|
|
752
|
+
* write per entry with no directory reconciliation.
|
|
753
|
+
*/
|
|
754
|
+
function snapshotReseedableFiles(runtimeRoot, deps = {}) {
|
|
755
|
+
const exists = deps.existsSync || (p => fs.existsSync(p));
|
|
756
|
+
const stat = deps.statSync || (p => fs.statSync(p));
|
|
757
|
+
const readdir = deps.readdirSync || (p => fs.readdirSync(p));
|
|
758
|
+
const readFile = deps.readFileSync || (p => fs.readFileSync(p));
|
|
759
|
+
const snapshot = [];
|
|
760
|
+
const visit = (rel) => {
|
|
761
|
+
const full = path.join(runtimeRoot, ...rel.split('/'));
|
|
762
|
+
if (!exists(full)) return;
|
|
763
|
+
let info;
|
|
764
|
+
try { info = stat(full); } catch { return; }
|
|
765
|
+
if (info.isDirectory()) {
|
|
766
|
+
let entries;
|
|
767
|
+
try { entries = readdir(full); } catch { return; }
|
|
768
|
+
for (const entry of entries) visit(`${rel}/${String(entry && entry.name !== undefined ? entry.name : entry)}`);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
try { snapshot.push({ rel, contents: readFile(full) }); } catch { /* unreadable — nothing to restore */ }
|
|
772
|
+
};
|
|
773
|
+
for (const rel of INIT_RESEEDABLE_PATHS) visit(rel);
|
|
774
|
+
return snapshot;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Put back every snapshotted file whose bytes the sync replaced or removed.
|
|
779
|
+
* Returns the relative paths actually restored, so the operator sees exactly
|
|
780
|
+
* what the package tried to reseed.
|
|
781
|
+
*/
|
|
782
|
+
function restoreReseededFiles(runtimeRoot, snapshot, deps = {}) {
|
|
783
|
+
const readFile = deps.readFileSync || (p => fs.readFileSync(p));
|
|
784
|
+
const writeFile = deps.writeFileSync || ((p, data) => fs.writeFileSync(p, data));
|
|
785
|
+
const mkdir = deps.mkdirSync || ((p, options) => fs.mkdirSync(p, options));
|
|
786
|
+
const restored = [];
|
|
787
|
+
for (const entry of snapshot || []) {
|
|
788
|
+
const full = path.join(runtimeRoot, ...entry.rel.split('/'));
|
|
789
|
+
let current = null;
|
|
790
|
+
try { current = readFile(full); } catch { current = null; }
|
|
791
|
+
if (current && Buffer.isBuffer(current) && Buffer.from(entry.contents).equals(current)) continue;
|
|
792
|
+
try {
|
|
793
|
+
mkdir(path.dirname(full), { recursive: true });
|
|
794
|
+
writeFile(full, entry.contents);
|
|
795
|
+
restored.push(entry.rel);
|
|
796
|
+
} catch { /* best-effort; the pre-migration backup is still the fallback */ }
|
|
797
|
+
}
|
|
798
|
+
return restored;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Decide whether a failed cutover must put the previously installed package
|
|
803
|
+
* back so the machine is not left with no `minions` command at all.
|
|
804
|
+
*
|
|
805
|
+
* Deliberately PACKAGE-scoped. User state is never rolled backward: the
|
|
806
|
+
* pre-migration backup exists for a human to reach for, and state created
|
|
807
|
+
* before or during the cutover (work items, dispatches, PR records) must
|
|
808
|
+
* survive a failed package swap untouched.
|
|
809
|
+
*/
|
|
810
|
+
function assessRollback({ publicUninstalled = false, internalUsable = false } = {}) {
|
|
811
|
+
if (!publicUninstalled) {
|
|
812
|
+
return { needed: false, reason: 'no previously installed package was removed' };
|
|
813
|
+
}
|
|
814
|
+
if (internalUsable) {
|
|
815
|
+
return { needed: false, reason: `${INTERNAL_PACKAGE} is installed and owns the \`minions\` command` };
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
needed: true,
|
|
819
|
+
packageName: PUBLIC_PACKAGE,
|
|
820
|
+
reason: `${PUBLIC_PACKAGE} was removed and the cutover did not leave a usable \`minions\` command`,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
510
824
|
/**
|
|
511
825
|
* Count live agent processes by walking the dispatch PID files the engine
|
|
512
826
|
* writes under `<runtime root>/engine/tmp/` — both the per-dispatch directory
|
|
@@ -579,6 +893,7 @@ const STEP_ORDER = [
|
|
|
579
893
|
'download-artifact',
|
|
580
894
|
'backup-state',
|
|
581
895
|
'uninstall-public',
|
|
896
|
+
'repair-shims',
|
|
582
897
|
'install-internal',
|
|
583
898
|
'verify-install',
|
|
584
899
|
'verify-shim',
|
|
@@ -601,6 +916,11 @@ const STEP_ORDER = [
|
|
|
601
916
|
* restore point.
|
|
602
917
|
* - `uninstall-public` precedes `install-internal` so the two packages never
|
|
603
918
|
* race for the same `minions` bin shim.
|
|
919
|
+
* - `repair-shims` sits between them: npm removes the shims it still owns
|
|
920
|
+
* during `uninstall-public`, so whatever survives to this point is an
|
|
921
|
+
* orphan, and clearing it is what keeps `install-internal` from aborting
|
|
922
|
+
* with EEXIST. It runs after `backup-state`, so nothing is ever deleted
|
|
923
|
+
* before a restore point exists.
|
|
604
924
|
* - `verify-shim` precedes `sync-init`, so the runtime is only synchronized
|
|
605
925
|
* once `minions` actually resolves to the internal package.
|
|
606
926
|
* - `cleanup` is always last and always present — it runs in a `finally`.
|
|
@@ -634,6 +954,9 @@ function planSteps(state) {
|
|
|
634
954
|
if (alreadyAtTarget) {
|
|
635
955
|
skip.add('install-internal');
|
|
636
956
|
skip.add('download-artifact');
|
|
957
|
+
// No install means no shim for the install to recreate, so there is nothing
|
|
958
|
+
// to make room for — and nothing this run is entitled to delete.
|
|
959
|
+
skip.add('repair-shims');
|
|
637
960
|
}
|
|
638
961
|
if (!restart) skip.add('restart');
|
|
639
962
|
|
|
@@ -729,6 +1052,17 @@ function readGlobalPackageVersion(npmRootG, packageName) {
|
|
|
729
1052
|
} catch { return null; }
|
|
730
1053
|
}
|
|
731
1054
|
|
|
1055
|
+
/**
|
|
1056
|
+
* Ask npm for its global root or prefix under an explicit config. Returns null
|
|
1057
|
+
* rather than throwing: a prefix npm cannot report downgrades the shim work to
|
|
1058
|
+
* a skipped, non-destructive warning instead of failing the migration.
|
|
1059
|
+
*/
|
|
1060
|
+
function resolveNpmGlobalPath(run, kind, config) {
|
|
1061
|
+
try {
|
|
1062
|
+
return run('npm', buildNpmGlobalPathArgs(kind, config), { timeout: NPM_VIEW_TIMEOUT_MS, capture: true }).stdout.trim() || null;
|
|
1063
|
+
} catch { return null; }
|
|
1064
|
+
}
|
|
1065
|
+
|
|
732
1066
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
733
1067
|
|
|
734
1068
|
function main(argv) {
|
|
@@ -749,7 +1083,13 @@ function main(argv) {
|
|
|
749
1083
|
const log = (...args) => console.log(...args);
|
|
750
1084
|
const run = makeRunner(secrets, { log });
|
|
751
1085
|
const scope = packageScope(opts.packageName);
|
|
1086
|
+
// The runtime root is resolved ONCE, before any package is touched, and is
|
|
1087
|
+
// pinned onto every child process below (backup, init, restart) rather than
|
|
1088
|
+
// left to whatever MINIONS_HOME each child happens to inherit. A cutover that
|
|
1089
|
+
// backed up one root and synchronized another would look successful and lose
|
|
1090
|
+
// the operator's state.
|
|
752
1091
|
const runtimeRoot = resolveRuntimeRoot();
|
|
1092
|
+
const runtimeEnv = { ...process.env, MINIONS_HOME: runtimeRoot };
|
|
753
1093
|
const hasExistingRuntime = fs.existsSync(path.join(runtimeRoot, 'engine.js'));
|
|
754
1094
|
|
|
755
1095
|
log(`\n Internal Minions install`);
|
|
@@ -757,25 +1097,16 @@ function main(argv) {
|
|
|
757
1097
|
log(` registry: ${opts.registry}`);
|
|
758
1098
|
log(` runtime root: ${runtimeRoot}${hasExistingRuntime ? '' : ' (fresh install)'}`);
|
|
759
1099
|
|
|
1100
|
+
// npm-usability probe only. The authoritative global root and prefix are
|
|
1101
|
+
// re-resolved below under the same `--userconfig` the install uses, because a
|
|
1102
|
+
// custom prefix (e.g. `C:\.tools\.npm-global`) normally comes from an npmrc.
|
|
760
1103
|
let npmRootG = null;
|
|
761
1104
|
try {
|
|
762
|
-
npmRootG = run('npm',
|
|
1105
|
+
npmRootG = run('npm', buildNpmGlobalPathArgs('root'), { timeout: NPM_VIEW_TIMEOUT_MS, capture: true }).stdout.trim();
|
|
763
1106
|
} catch (e) {
|
|
764
1107
|
console.error(`\n ERROR: npm is required but not usable: ${e.message}\n`);
|
|
765
1108
|
return EXIT.FAILED;
|
|
766
1109
|
}
|
|
767
|
-
const installedInternalVersion = readGlobalPackageVersion(npmRootG, opts.packageName);
|
|
768
|
-
const installedPublic = !!readGlobalPackageVersion(npmRootG, PUBLIC_PACKAGE);
|
|
769
|
-
|
|
770
|
-
// The global bin prefix is where the `minions` shim lives. Resolved up front so
|
|
771
|
-
// a prefix npm itself cannot report is a warning, not a late failure after the
|
|
772
|
-
// package is already installed.
|
|
773
|
-
let npmPrefixG = null;
|
|
774
|
-
try {
|
|
775
|
-
npmPrefixG = run('npm', ['prefix', '-g'], { timeout: NPM_VIEW_TIMEOUT_MS, capture: true }).stdout.trim() || null;
|
|
776
|
-
} catch {
|
|
777
|
-
npmPrefixG = null;
|
|
778
|
-
}
|
|
779
1110
|
|
|
780
1111
|
// Agent-activity gate — evaluated before anything is acquired or mutated.
|
|
781
1112
|
const activity = assessAgentActivity({
|
|
@@ -828,6 +1159,20 @@ function main(argv) {
|
|
|
828
1159
|
tempDir = temp.dir;
|
|
829
1160
|
log(` npm auth staged in a temporary config (removed when this run ends).`);
|
|
830
1161
|
|
|
1162
|
+
// From here on every prefix-sensitive npm command — root, prefix, uninstall,
|
|
1163
|
+
// install, and the shim cleanup that reads the prefix — resolves through the
|
|
1164
|
+
// SAME config file. A custom global prefix (the reported install uses
|
|
1165
|
+
// `C:\.tools\.npm-global`) normally comes from an npmrc, so without this the
|
|
1166
|
+
// probe could read one config while the install honored another, and the
|
|
1167
|
+
// shim we inspected would not be the shim npm collides with.
|
|
1168
|
+
const npmConfig = { userconfig: temp.file };
|
|
1169
|
+
const resolvedRoot = resolveNpmGlobalPath(run, 'root', npmConfig);
|
|
1170
|
+
if (resolvedRoot) npmRootG = resolvedRoot;
|
|
1171
|
+
const npmPrefixG = resolveNpmGlobalPath(run, 'prefix', npmConfig);
|
|
1172
|
+
|
|
1173
|
+
const installedInternalVersion = readGlobalPackageVersion(npmRootG, opts.packageName);
|
|
1174
|
+
const installedPublic = !!readGlobalPackageVersion(npmRootG, PUBLIC_PACKAGE);
|
|
1175
|
+
|
|
831
1176
|
// 2. validate-feed ─────────────────────────────────────────────────────
|
|
832
1177
|
const viewArgs = buildNpmViewArgs({
|
|
833
1178
|
packageName: opts.packageName,
|
|
@@ -882,6 +1227,22 @@ function main(argv) {
|
|
|
882
1227
|
shimOwned,
|
|
883
1228
|
});
|
|
884
1229
|
log(`\n Plan: ${steps.join(' → ')}`);
|
|
1230
|
+
|
|
1231
|
+
// Orphaned-shim gate. Evaluated BEFORE anything is downloaded, backed up, or
|
|
1232
|
+
// uninstalled so an unattributable `minions` file refuses the run up front
|
|
1233
|
+
// instead of surfacing halfway through a cutover — and so the reported
|
|
1234
|
+
// EEXIST is caught here rather than by npm.
|
|
1235
|
+
let orphanShims = { needsRepair: false, blocked: false, remove: [], reason: 'shim repair is not part of this plan' };
|
|
1236
|
+
if (steps.includes('repair-shims')) {
|
|
1237
|
+
orphanShims = assessOrphanShims({ npmPrefixG, npmRootG, packageNames: SHIM_OWNER_PACKAGES });
|
|
1238
|
+
if (orphanShims.blocked) {
|
|
1239
|
+
console.error(`\n REFUSED: ${orphanShims.reason}`);
|
|
1240
|
+
console.error(' Nothing was downloaded, uninstalled, or modified.\n');
|
|
1241
|
+
return EXIT.REFUSED;
|
|
1242
|
+
}
|
|
1243
|
+
if (orphanShims.needsRepair) log(` Orphaned shims detected: ${orphanShims.remove.join(', ')}`);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
885
1246
|
if (installedInternalVersion === targetVersion && !steps.includes('install-internal')) {
|
|
886
1247
|
log(` ${opts.packageName}@${targetVersion} is already installed — install step skipped (idempotent).`);
|
|
887
1248
|
} else if (installedInternalVersion === targetVersion && !shimOwned) {
|
|
@@ -894,7 +1255,12 @@ function main(argv) {
|
|
|
894
1255
|
log(` npm ${redact(buildNpmPackArgs({ packageName: opts.packageName, version: targetVersion, registry: opts.registry, userconfig: temp.file, destination: '<temp>' }).join(' '), secrets)}`);
|
|
895
1256
|
}
|
|
896
1257
|
if (steps.includes('backup-state')) log(` minions state backup <backup-dir>/state.db`);
|
|
897
|
-
if (steps.includes('uninstall-public')) log(` npm ${buildNpmUninstallArgs(PUBLIC_PACKAGE).join(' ')}`);
|
|
1258
|
+
if (steps.includes('uninstall-public')) log(` npm ${buildNpmUninstallArgs(PUBLIC_PACKAGE, npmConfig).join(' ')}`);
|
|
1259
|
+
if (steps.includes('repair-shims')) {
|
|
1260
|
+
log(orphanShims.needsRepair
|
|
1261
|
+
? ` (repair) remove orphaned shims: ${orphanShims.remove.join(', ')}`
|
|
1262
|
+
: ` (repair) no orphaned \`minions\` shim to clear`);
|
|
1263
|
+
}
|
|
898
1264
|
if (steps.includes('install-internal')) {
|
|
899
1265
|
log(` npm ${redact(buildNpmInstallArgs({ packageName: opts.packageName, version: targetVersion, registry: opts.registry, userconfig: temp.file }).join(' '), secrets)}`);
|
|
900
1266
|
log(` (on failure) npm ${buildNpmInstallTarballArgs('<backup-dir>/<package>.tgz').join(' ')}`);
|
|
@@ -970,7 +1336,7 @@ function main(argv) {
|
|
|
970
1336
|
run(command, args, {
|
|
971
1337
|
timeout: CLI_INIT_TIMEOUT_MS,
|
|
972
1338
|
capture: true,
|
|
973
|
-
env:
|
|
1339
|
+
env: runtimeEnv,
|
|
974
1340
|
});
|
|
975
1341
|
if (!fs.existsSync(backupStatePath)) throw new Error('the backup command reported success but wrote no file');
|
|
976
1342
|
log(` SQLite state checkpointed and backed up (via ${backup.kind === 'runtime-module' ? 'the runtime\'s own engine/state-operations.js' : backup.cliPath}).`);
|
|
@@ -1009,6 +1375,32 @@ function main(argv) {
|
|
|
1009
1375
|
}
|
|
1010
1376
|
|
|
1011
1377
|
// 5. uninstall-public ──────────────────────────────────────────────────
|
|
1378
|
+
let publicUninstalled = false;
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Put the previous package back when a failed cutover would otherwise leave
|
|
1382
|
+
* the machine with no `minions` command. Package-scoped ONLY: the state
|
|
1383
|
+
* backup is never restored automatically, so state written before or during
|
|
1384
|
+
* the cutover is neither rolled backward nor deleted.
|
|
1385
|
+
*/
|
|
1386
|
+
const rollbackPackageIfNeeded = () => {
|
|
1387
|
+
const decision = assessRollback({
|
|
1388
|
+
publicUninstalled,
|
|
1389
|
+
internalUsable: isGlobalPackageUsable(npmRootG, opts.packageName)
|
|
1390
|
+
&& (!npmPrefixG || verifyBinShims({ npmPrefixG, packageName: opts.packageName }).ok),
|
|
1391
|
+
});
|
|
1392
|
+
if (!decision.needed) return;
|
|
1393
|
+
console.error(`\n Rolling back the package cutover: ${decision.reason}.`);
|
|
1394
|
+
console.error(` Restoring ${decision.packageName} so \`minions\` works again. Your runtime state is untouched.`);
|
|
1395
|
+
try {
|
|
1396
|
+
run('npm', buildNpmRestoreArgs(decision.packageName, npmConfig), { timeout: NPM_INSTALL_TIMEOUT_MS });
|
|
1397
|
+
console.error(` Restored ${decision.packageName}.`);
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
console.error(` Could not restore ${decision.packageName} automatically (${e.message}).`);
|
|
1400
|
+
console.error(` Run this yourself: npm ${buildNpmRestoreArgs(decision.packageName).join(' ')}`);
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1012
1404
|
if (steps.includes('uninstall-public')) {
|
|
1013
1405
|
const readiness = assessCutoverReadiness({
|
|
1014
1406
|
artifactReady: !!artifactPath,
|
|
@@ -1022,10 +1414,43 @@ function main(argv) {
|
|
|
1022
1414
|
return EXIT.FAILED;
|
|
1023
1415
|
}
|
|
1024
1416
|
log(`\n Removing the public ${PUBLIC_PACKAGE} global install (${readiness.reason})...`);
|
|
1025
|
-
run('npm', buildNpmUninstallArgs(PUBLIC_PACKAGE), { timeout: NPM_INSTALL_TIMEOUT_MS, allowFailure: true });
|
|
1417
|
+
run('npm', buildNpmUninstallArgs(PUBLIC_PACKAGE, npmConfig), { timeout: NPM_INSTALL_TIMEOUT_MS, allowFailure: true });
|
|
1418
|
+
publicUninstalled = true;
|
|
1026
1419
|
}
|
|
1027
1420
|
|
|
1028
|
-
// 6.
|
|
1421
|
+
// 6. repair-shims ──────────────────────────────────────────────────────
|
|
1422
|
+
// npm has now removed whatever shims it still owned. Anything left under the
|
|
1423
|
+
// prefix that names a Minions package with no usable install behind it is an
|
|
1424
|
+
// ORPHAN — npm will not overwrite it and aborts the global install with
|
|
1425
|
+
// EEXIST. Re-assessed here (not reused from the gate above) because the
|
|
1426
|
+
// uninstall that just ran is exactly what changes the answer.
|
|
1427
|
+
if (steps.includes('repair-shims')) {
|
|
1428
|
+
const repair = assessOrphanShims({ npmPrefixG, npmRootG, packageNames: SHIM_OWNER_PACKAGES });
|
|
1429
|
+
if (repair.blocked) {
|
|
1430
|
+
console.error(`\n REFUSED: ${repair.reason}`);
|
|
1431
|
+
if (backupDir) console.error(` Your state backup is intact at ${backupDir}.`);
|
|
1432
|
+
rollbackPackageIfNeeded();
|
|
1433
|
+
console.error('');
|
|
1434
|
+
return EXIT.REFUSED;
|
|
1435
|
+
}
|
|
1436
|
+
if (repair.needsRepair) {
|
|
1437
|
+
log(`\n Clearing orphaned \`minions\` shims — ${repair.reason}.`);
|
|
1438
|
+
const removal = removeOrphanShims(repair.remove);
|
|
1439
|
+
for (const cleared of removal.removed) log(` removed ${cleared}`);
|
|
1440
|
+
if (removal.failed.length) {
|
|
1441
|
+
console.error(`\n ERROR: could not remove ${removal.failed.map(f => `${f.path} (${f.error})`).join('; ')}.`);
|
|
1442
|
+
console.error(' `npm install -g` would abort with EEXIST on it. Remove the file(s) manually and re-run.');
|
|
1443
|
+
if (backupDir) console.error(` Your state backup is intact at ${backupDir}.`);
|
|
1444
|
+
rollbackPackageIfNeeded();
|
|
1445
|
+
console.error('');
|
|
1446
|
+
return EXIT.FAILED;
|
|
1447
|
+
}
|
|
1448
|
+
} else {
|
|
1449
|
+
log(`\n Shim check: ${repair.reason}.`);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// 7. install-internal ──────────────────────────────────────────────────
|
|
1029
1454
|
if (steps.includes('install-internal')) {
|
|
1030
1455
|
log(`\n Installing ${opts.packageName}@${targetVersion} ...`);
|
|
1031
1456
|
let installed = false;
|
|
@@ -1061,25 +1486,24 @@ function main(argv) {
|
|
|
1061
1486
|
console.error(` Retry the offline install yourself with:`);
|
|
1062
1487
|
console.error(` npm ${buildNpmInstallTarballArgs(artifactPath).join(' ')}`);
|
|
1063
1488
|
}
|
|
1064
|
-
|
|
1065
|
-
console.error(` Or fall back to the public package with:`);
|
|
1066
|
-
console.error(` npm install -g ${PUBLIC_PACKAGE}`);
|
|
1067
|
-
}
|
|
1489
|
+
rollbackPackageIfNeeded();
|
|
1068
1490
|
console.error('');
|
|
1069
1491
|
return EXIT.FAILED;
|
|
1070
1492
|
}
|
|
1071
1493
|
}
|
|
1072
1494
|
|
|
1073
|
-
//
|
|
1495
|
+
// 8. verify-install ────────────────────────────────────────────────────
|
|
1074
1496
|
const installedNow = readGlobalPackageVersion(npmRootG, opts.packageName);
|
|
1075
1497
|
if (installedNow !== targetVersion) {
|
|
1076
1498
|
console.error(`\n ERROR: expected ${opts.packageName}@${targetVersion} on disk, found ${installedNow || 'nothing'}.`);
|
|
1077
|
-
console.error(' npm may have resolved a stale metadata cache. Try `npm cache clean --force` and re-run
|
|
1499
|
+
console.error(' npm may have resolved a stale metadata cache. Try `npm cache clean --force` and re-run.');
|
|
1500
|
+
rollbackPackageIfNeeded();
|
|
1501
|
+
console.error('');
|
|
1078
1502
|
return EXIT.FAILED;
|
|
1079
1503
|
}
|
|
1080
1504
|
log(` Verified ${opts.packageName}@${installedNow} on disk.`);
|
|
1081
1505
|
|
|
1082
|
-
//
|
|
1506
|
+
// 9. verify-shim ───────────────────────────────────────────────────────
|
|
1083
1507
|
// Owning the package directory is not the same as owning the `minions`
|
|
1084
1508
|
// command: a half-finished cutover can leave the shim wired to the public
|
|
1085
1509
|
// package while the internal one sits installed and unused.
|
|
@@ -1090,9 +1514,10 @@ function main(argv) {
|
|
|
1090
1514
|
console.error(`\n ERROR: ${shimCheck.reason}.`);
|
|
1091
1515
|
console.error(` ${opts.packageName}@${installedNow} is installed, but \`minions\` does not run it.`);
|
|
1092
1516
|
console.error(` Repair the shim with:`);
|
|
1093
|
-
console.error(` npm
|
|
1517
|
+
console.error(` npm ${buildNpmUninstallArgs(PUBLIC_PACKAGE).join(' ')}`);
|
|
1094
1518
|
if (artifactPath) console.error(` npm ${buildNpmInstallTarballArgs(artifactPath).join(' ')}`);
|
|
1095
1519
|
if (backupDir) console.error(` Your state backup is intact at ${backupDir}.`);
|
|
1520
|
+
rollbackPackageIfNeeded();
|
|
1096
1521
|
console.error('');
|
|
1097
1522
|
return EXIT.FAILED;
|
|
1098
1523
|
}
|
|
@@ -1103,18 +1528,32 @@ function main(argv) {
|
|
|
1103
1528
|
|
|
1104
1529
|
const installedCliPath = path.join(resolveGlobalPackageRoot(npmRootG, opts.packageName), 'bin', 'minions.js');
|
|
1105
1530
|
|
|
1106
|
-
//
|
|
1531
|
+
// 10. sync-init ────────────────────────────────────────────────────────
|
|
1532
|
+
// `minions init --force` overwrites every file the package ships, which
|
|
1533
|
+
// includes `routing.md` and `knowledge/agents/*.md`. Those are operator- and
|
|
1534
|
+
// agent-authored, so their bytes are captured here and put back below: this
|
|
1535
|
+
// is a package-channel cutover, and the sync may only ADD newly required
|
|
1536
|
+
// files and run the runtime's own idempotent startup migrations.
|
|
1537
|
+
const reseedable = snapshotReseedableFiles(runtimeRoot);
|
|
1107
1538
|
log('\n Synchronizing the runtime root (minions init --force)...');
|
|
1108
1539
|
try {
|
|
1109
|
-
run(process.execPath, [installedCliPath, 'init', '--force', '--skip-start'], {
|
|
1540
|
+
run(process.execPath, [installedCliPath, 'init', '--force', '--skip-start'], {
|
|
1541
|
+
timeout: CLI_INIT_TIMEOUT_MS,
|
|
1542
|
+
env: runtimeEnv,
|
|
1543
|
+
});
|
|
1110
1544
|
} catch (e) {
|
|
1111
1545
|
console.error(`\n ERROR: runtime synchronization failed (${e.message}).`);
|
|
1112
1546
|
console.error(` The package is installed. Finish manually with:`);
|
|
1113
1547
|
console.error(` minions init --force`);
|
|
1114
|
-
console.error(` minions restart
|
|
1548
|
+
console.error(` minions restart`);
|
|
1549
|
+
rollbackPackageIfNeeded();
|
|
1550
|
+
console.error('');
|
|
1115
1551
|
return EXIT.FAILED;
|
|
1116
1552
|
}
|
|
1117
1553
|
|
|
1554
|
+
const reseeded = restoreReseededFiles(runtimeRoot, reseedable);
|
|
1555
|
+
if (reseeded.length) log(` Restored operator-authored files the sync reseeded: ${reseeded.join(', ')}`);
|
|
1556
|
+
|
|
1118
1557
|
const preservedAfter = snapshotPreservedPaths(runtimeRoot);
|
|
1119
1558
|
const lost = diffPreservedPaths(preservedBefore, preservedAfter);
|
|
1120
1559
|
if (lost.length) {
|
|
@@ -1124,11 +1563,11 @@ function main(argv) {
|
|
|
1124
1563
|
}
|
|
1125
1564
|
if (preservedBefore.length) log(` Preserved: ${preservedBefore.join(', ')}`);
|
|
1126
1565
|
|
|
1127
|
-
//
|
|
1566
|
+
// 11. restart ──────────────────────────────────────────────────────────
|
|
1128
1567
|
if (steps.includes('restart')) {
|
|
1129
1568
|
log('\n Restarting engine and dashboard (health-verified)...');
|
|
1130
1569
|
try {
|
|
1131
|
-
run(process.execPath, [installedCliPath, 'restart'], { timeout: CLI_RESTART_TIMEOUT_MS });
|
|
1570
|
+
run(process.execPath, [installedCliPath, 'restart'], { timeout: CLI_RESTART_TIMEOUT_MS, env: runtimeEnv });
|
|
1132
1571
|
} catch (e) {
|
|
1133
1572
|
console.error(`\n ERROR: restart failed (${e.message}).`);
|
|
1134
1573
|
console.error(' The package and runtime files are up to date; run `minions restart` to finish.\n');
|
|
@@ -1146,7 +1585,7 @@ function main(argv) {
|
|
|
1146
1585
|
console.error(`\n ERROR: ${redact(e && e.message ? e.message : String(e), secrets)}\n`);
|
|
1147
1586
|
return EXIT.FAILED;
|
|
1148
1587
|
} finally {
|
|
1149
|
-
//
|
|
1588
|
+
// 12. cleanup — runs on success and on every failure path, including the
|
|
1150
1589
|
// early `return`s above, so the token file never outlives the process. The
|
|
1151
1590
|
// temp artifact copy goes too; when there was a runtime to back up, the
|
|
1152
1591
|
// recovery tarball was already parked in the backup directory.
|
|
@@ -1169,6 +1608,8 @@ module.exports = {
|
|
|
1169
1608
|
PUBLIC_PACKAGE,
|
|
1170
1609
|
EXIT,
|
|
1171
1610
|
PRESERVED_PATHS,
|
|
1611
|
+
INIT_RESEEDABLE_PATHS,
|
|
1612
|
+
SHIM_OWNER_PACKAGES,
|
|
1172
1613
|
STEP_ORDER,
|
|
1173
1614
|
parseArgs,
|
|
1174
1615
|
normalizeRegistry,
|
|
@@ -1181,11 +1622,17 @@ module.exports = {
|
|
|
1181
1622
|
buildNpmPackArgs,
|
|
1182
1623
|
buildNpmInstallTarballArgs,
|
|
1183
1624
|
buildNpmUninstallArgs,
|
|
1625
|
+
buildNpmRestoreArgs,
|
|
1626
|
+
buildNpmGlobalPathArgs,
|
|
1184
1627
|
resolveGlobalPackageRoot,
|
|
1185
1628
|
resolvePackedTarball,
|
|
1186
1629
|
resolveBinShimPaths,
|
|
1630
|
+
shimPathPresent,
|
|
1187
1631
|
shimTargetsPackage,
|
|
1188
1632
|
verifyBinShims,
|
|
1633
|
+
isGlobalPackageUsable,
|
|
1634
|
+
assessOrphanShims,
|
|
1635
|
+
removeOrphanShims,
|
|
1189
1636
|
quoteWindowsArg,
|
|
1190
1637
|
resolveSpawnTarget,
|
|
1191
1638
|
redact,
|
|
@@ -1195,6 +1642,9 @@ module.exports = {
|
|
|
1195
1642
|
resolveBackupStrategy,
|
|
1196
1643
|
snapshotPreservedPaths,
|
|
1197
1644
|
diffPreservedPaths,
|
|
1645
|
+
snapshotReseedableFiles,
|
|
1646
|
+
restoreReseededFiles,
|
|
1647
|
+
assessRollback,
|
|
1198
1648
|
countActiveAgents,
|
|
1199
1649
|
assessAgentActivity,
|
|
1200
1650
|
assessCutoverReadiness,
|
package/dashboard.js
CHANGED
|
@@ -101,6 +101,7 @@ const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
|
101
101
|
getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
|
|
102
102
|
MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, PRD_DIR } = queries;
|
|
103
103
|
const apiContracts = require('./engine/api-contracts');
|
|
104
|
+
const listPaging = require('./engine/api-contracts/paging');
|
|
104
105
|
|
|
105
106
|
// Dev vs binary differentiation. When two dashboards run side-by-side (npm
|
|
106
107
|
// install on 7331, local checkout on 7332), the favicon and title need to
|
|
@@ -3384,7 +3385,11 @@ async function _maxInputMtimeMs(inputs) {
|
|
|
3384
3385
|
// cross-scope display order is post-enrichment (central + every project by
|
|
3385
3386
|
// rowid), which per-scope SQL LIMIT/OFFSET cannot compose, and (c) slicing the
|
|
3386
3387
|
// deterministic cached array is O(page) to serialize — the real transfer win.
|
|
3387
|
-
|
|
3388
|
+
// The bounds come from engine/api-contracts/paging.js — the SAME constants the
|
|
3389
|
+
// route catalog publishes on GET /api/routes, so the clamp and the advertised
|
|
3390
|
+
// contract can never drift (review of PR #1062).
|
|
3391
|
+
const _LIST_PAGE_MAX = listPaging.LIST_PAGE_MAX_LIMIT;
|
|
3392
|
+
const _LIST_PAGE_DEFAULT_LIMIT = listPaging.LIST_PAGE_DEFAULT_LIMIT;
|
|
3388
3393
|
function _parsePageParams(req) {
|
|
3389
3394
|
const params = new URL((req && req.url) || '/', 'http://localhost').searchParams;
|
|
3390
3395
|
const hasLimit = params.has('limit');
|
|
@@ -3394,7 +3399,7 @@ function _parsePageParams(req) {
|
|
|
3394
3399
|
const rawOffset = Number(params.get('offset'));
|
|
3395
3400
|
const limit = hasLimit && Number.isFinite(rawLimit)
|
|
3396
3401
|
? Math.max(1, Math.min(_LIST_PAGE_MAX, Math.floor(rawLimit)))
|
|
3397
|
-
:
|
|
3402
|
+
: _LIST_PAGE_DEFAULT_LIMIT;
|
|
3398
3403
|
const offset = hasOffset && Number.isFinite(rawOffset) ? Math.max(0, Math.floor(rawOffset)) : 0;
|
|
3399
3404
|
return { limit, offset };
|
|
3400
3405
|
}
|
|
@@ -9164,14 +9169,23 @@ const server = http.createServer(async (req, res) => {
|
|
|
9164
9169
|
}
|
|
9165
9170
|
|
|
9166
9171
|
async function handlePlansList(req, res) {
|
|
9172
|
+
let page;
|
|
9167
9173
|
try {
|
|
9168
|
-
|
|
9174
|
+
// Only the optional limit/offset paging params are accepted; every other
|
|
9175
|
+
// query param is still rejected with the canonical 400, and the error
|
|
9176
|
+
// blames the REJECTED param rather than the accepted paging one
|
|
9177
|
+
// (W-ms3ovk83000rb60f). _parsePageParams returns null on the legacy
|
|
9178
|
+
// no-param path so the response stays byte-identical to legacy then.
|
|
9179
|
+
planPrdValidation.validateNoQuery(req, { allow: listPaging.LIST_PAGING_PARAMS });
|
|
9180
|
+
page = _parsePageParams(req);
|
|
9169
9181
|
} catch (e) {
|
|
9170
9182
|
return apiErrorReply(res, e, req);
|
|
9171
9183
|
}
|
|
9172
9184
|
const now = Date.now();
|
|
9173
9185
|
if (_plansCache && (now - _plansCacheTs) < PLANS_CACHE_TTL_MS) {
|
|
9174
|
-
|
|
9186
|
+
// Keep caching the full array and slice per-request AFTER the cache read
|
|
9187
|
+
// so paging never corrupts the cache and `total` reflects the full set.
|
|
9188
|
+
return jsonReply(res, 200, page ? _paginateList(_plansCache, page) : _plansCache);
|
|
9175
9189
|
}
|
|
9176
9190
|
const fsp = fs.promises;
|
|
9177
9191
|
// W-mrffmjaf002f08f0 — PRD JSON is SQL-authoritative (engine/prd-store.js);
|
|
@@ -9325,7 +9339,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
9325
9339
|
plans.sort((a, b) => (b.generatedAt || '').localeCompare(a.generatedAt || ''));
|
|
9326
9340
|
_plansCache = plans;
|
|
9327
9341
|
_plansCacheTs = Date.now();
|
|
9328
|
-
return jsonReply(res, 200, plans);
|
|
9342
|
+
return jsonReply(res, 200, page ? _paginateList(plans, page) : plans);
|
|
9329
9343
|
}
|
|
9330
9344
|
|
|
9331
9345
|
async function handlePlansArchiveRead(req, res, match) {
|
|
@@ -12551,7 +12565,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12551
12565
|
// ── Watches API Handlers ─────────────────────────────────────────────────
|
|
12552
12566
|
|
|
12553
12567
|
async function handleWatchesList(req, res) {
|
|
12554
|
-
|
|
12568
|
+
const all = watchesMod.getWatches();
|
|
12569
|
+
// Optional progressive-load paging (W-ms3ovk83000rb60f). With no paging
|
|
12570
|
+
// params the response is byte-identical to the legacy `{ watches: [...] }`
|
|
12571
|
+
// shape; when a param is present we slice the SAME array and keep the
|
|
12572
|
+
// `watches` key (mapped from _paginateList's `items`) so existing consumers
|
|
12573
|
+
// keep working, adding total/hasMore/offset/limit alongside it.
|
|
12574
|
+
const page = _parsePageParams(req);
|
|
12575
|
+
if (!page) return jsonReply(res, 200, { watches: all });
|
|
12576
|
+
const { items, total, offset, limit, hasMore } = _paginateList(all, page);
|
|
12577
|
+
return jsonReply(res, 200, { watches: items, total, hasMore, offset, limit });
|
|
12555
12578
|
}
|
|
12556
12579
|
|
|
12557
12580
|
async function handleWatchesTargetTypes(req, res) {
|
|
@@ -14829,10 +14852,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14829
14852
|
},
|
|
14830
14853
|
});
|
|
14831
14854
|
}},
|
|
14832
|
-
{ method: 'GET', path: '/api/schedules', desc: 'Schedule definitions merged with SQL schedule-run state (_lastRun/_lastResult/_lastCompletedAt)', handler: (req, res) => {
|
|
14855
|
+
{ method: 'GET', path: '/api/schedules', desc: 'Schedule definitions merged with SQL schedule-run state (_lastRun/_lastResult/_lastCompletedAt). Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: (req, res) => {
|
|
14856
|
+
const page = _parsePageParams(req);
|
|
14833
14857
|
return serveFreshJson(req, res, {
|
|
14834
14858
|
tag: 'schedules',
|
|
14835
14859
|
inputs: [CONFIG_PATH],
|
|
14860
|
+
variant: page ? ('p' + page.offset + '.' + page.limit) : '',
|
|
14861
|
+
transform: page ? (list) => _paginateList(list, page) : null,
|
|
14836
14862
|
builder: () => {
|
|
14837
14863
|
// Read config.json directly via queries.getConfig() rather than
|
|
14838
14864
|
// calling reloadConfig() — the latter cascades into
|
|
@@ -14857,12 +14883,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14857
14883
|
},
|
|
14858
14884
|
});
|
|
14859
14885
|
}},
|
|
14860
|
-
{ method: 'GET', path: '/api/pipelines', desc: 'Pipeline definitions merged with last-5 SQL-backed runs', handler: (req, res) => {
|
|
14886
|
+
{ method: 'GET', path: '/api/pipelines', desc: 'Pipeline definitions merged with last-5 SQL-backed runs. Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: (req, res) => {
|
|
14887
|
+
const page = _parsePageParams(req);
|
|
14861
14888
|
return serveFreshJson(req, res, {
|
|
14862
14889
|
tag: 'pipelines',
|
|
14863
14890
|
inputs: [
|
|
14864
14891
|
path.join(MINIONS_DIR, 'pipelines'),
|
|
14865
14892
|
],
|
|
14893
|
+
variant: page ? ('p' + page.offset + '.' + page.limit) : '',
|
|
14894
|
+
transform: page ? (list) => _paginateList(list, page) : null,
|
|
14866
14895
|
builder: () => {
|
|
14867
14896
|
try {
|
|
14868
14897
|
const pl = require('./engine/pipeline');
|
|
@@ -15100,7 +15129,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
15100
15129
|
|
|
15101
15130
|
// Plans
|
|
15102
15131
|
{ method: 'POST', path: '/api/plan', desc: 'Create a plan work item that chains to PRD on completion', params: `title, description?, ${WORK_ITEM_PRIORITY_PARAM_HINT}, project? (string OR array for cross-repo plans), agent?, branch_strategy? or branchStrategy?`, handler: handlePlanCreate },
|
|
15103
|
-
{ method: 'GET', path: '/api/plans', desc: 'List plan files (.md drafts + .json PRDs)', handler: handlePlansList },
|
|
15132
|
+
{ method: 'GET', path: '/api/plans', desc: 'List plan files (.md drafts + .json PRDs). Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: handlePlansList },
|
|
15104
15133
|
{ method: 'POST', path: '/api/plans/trigger-verify', desc: 'Manually trigger verification for a completed plan', params: 'file', handler: handlePlansTriggerVerify },
|
|
15105
15134
|
{ method: 'POST', path: '/api/plans/approve', desc: 'Approve a plan for execution', params: 'file, approvedBy?, forceRegen?, skipRegen?', handler: handlePlansApprove },
|
|
15106
15135
|
{ method: 'POST', path: '/api/plans/pause', desc: 'Pause a plan (stops materialization + resets active items)', params: 'file', handler: handlePlansPause },
|
|
@@ -15996,7 +16025,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
15996
16025
|
{ method: 'POST', path: '/api/schedules/run-now', desc: 'Manually enqueue the work item for a schedule', params: 'id', handler: handleSchedulesRunNow },
|
|
15997
16026
|
|
|
15998
16027
|
// Watches
|
|
15999
|
-
{ method: 'GET', path: '/api/watches', desc: 'List all watches', handler: handleWatchesList },
|
|
16028
|
+
{ method: 'GET', path: '/api/watches', desc: 'List all watches. Optional ?limit=&offset= returns a wrapped { watches, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the response is byte-identical to legacy { watches: [...] }.', handler: handleWatchesList },
|
|
16000
16029
|
{ method: 'GET', path: '/api/watches/target-types', desc: 'List registered watch target types and their valid conditions', handler: handleWatchesTargetTypes },
|
|
16001
16030
|
{ method: 'GET', path: '/api/watches/action-types', desc: 'List registered follow-up action types (notify, dispatch-work-item, webhook, ...)', handler: handleWatchesActionTypes },
|
|
16002
16031
|
{ method: 'GET', path: /^\/api\/watches\/([\w-]+)\/history$/, template: '/api/watches/:id/history', desc: 'Read the persisted evaluation history (last 25 checks) for a watch', handler: handleWatchHistory },
|
package/docs/internal-install.md
CHANGED
|
@@ -97,7 +97,25 @@ by `test/unit/install-internal-minions.test.js`.
|
|
|
97
97
|
(or already installed at the target version) *and* a state backup exists
|
|
98
98
|
whenever there was a runtime to back up. Skipped with `--keep-public`, or when
|
|
99
99
|
no public install is present.
|
|
100
|
-
6. **`
|
|
100
|
+
6. **`repair-shims`** — clears **orphaned** `minions` shims under `npm prefix -g`.
|
|
101
|
+
`npm uninstall -g` has just removed the shims it still owned, so anything left
|
|
102
|
+
under the prefix that dispatches into `@yemi33/minions` or
|
|
103
|
+
`@opg-microsoft/minions` with no usable package behind it is a leftover from a
|
|
104
|
+
prior uninstall or an interrupted migration. npm refuses to overwrite a shim it
|
|
105
|
+
does not own and aborts the whole global install with `EEXIST`, so the complete
|
|
106
|
+
npm-generated set is cleared here and `install-internal` recreates it. Ownership
|
|
107
|
+
must be *proven* — a shim is removed only when its body or symlink target names
|
|
108
|
+
one of those packages **and** that package's declared `bin` is no longer on
|
|
109
|
+
disk. An unrelated file named `minions` refuses the run (exit `3`) with its
|
|
110
|
+
exact path instead of being deleted, and an npm that reports no prefix or root
|
|
111
|
+
skips the repair entirely rather than guessing. `npm --force` is never used.
|
|
112
|
+
Shims are detected with `lstat`, not an existence check: on Linux and macOS
|
|
113
|
+
the orphan is usually a **dangling** `<prefix>/bin/minions` symlink whose
|
|
114
|
+
target went with the package, and a symlink-following check reports that
|
|
115
|
+
directory entry as absent while npm still aborts on it. Each deletion is
|
|
116
|
+
re-probed afterwards, so a delete that quietly no-ops is reported as a
|
|
117
|
+
failure with its path rather than as a cleared shim.
|
|
118
|
+
7. **`install-internal`** — `npm install -g <package>@<resolved-version>` against the
|
|
101
119
|
feed. **If that fails, the script installs the already-downloaded tarball**
|
|
102
120
|
(`npm install -g <backup-dir>/<package>.tgz` — no registry, no token, so it
|
|
103
121
|
still works when the feed is exactly what failed) rather than leaving the
|
|
@@ -107,29 +125,65 @@ by `test/unit/install-internal-minions.test.js`.
|
|
|
107
125
|
`uninstall-public` is planned. `npm uninstall -g` takes the shim with the
|
|
108
126
|
package that owns it, so skipping the install alongside an uninstall would
|
|
109
127
|
remove the command with nothing to restore it.
|
|
110
|
-
|
|
128
|
+
8. **`verify-install`** — re-reads the installed `package.json` from disk and fails
|
|
111
129
|
loudly if npm did not actually land the resolved version.
|
|
112
|
-
|
|
130
|
+
9. **`verify-shim`** — resolves the global npm prefix and proves the `minions`
|
|
113
131
|
command itself dispatches into the internal package (`minions.cmd` /
|
|
114
132
|
`minions.ps1` / the sh shim on Windows, the `<prefix>/bin/minions` symlink
|
|
115
133
|
elsewhere). Owning the package directory is not the same as owning the
|
|
116
134
|
command: a half-finished cutover can leave `@opg-microsoft/minions` installed
|
|
117
135
|
while `minions` still runs the public build. A shim still pointing at
|
|
118
|
-
`@yemi33/minions` is a hard failure with the repair commands printed
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
136
|
+
`@yemi33/minions` is a hard failure with the repair commands printed, and so
|
|
137
|
+
is a shim whose link target no longer exists — naming the right package is
|
|
138
|
+
not the same as being able to run it.
|
|
139
|
+
10. **`sync-init`** — `minions init --force --skip-start`, the supported runtime
|
|
140
|
+
synchronization. It overwrites `.js`/`.html` runtime files and adds files the
|
|
141
|
+
new version requires. `--force` would otherwise also rewrite the shipped
|
|
142
|
+
`routing.md` and `knowledge/agents/*.md` with package defaults, so the
|
|
143
|
+
installer snapshots those before the sync and puts them back after it.
|
|
144
|
+
11. **`restart`** — `minions restart`, which is health-verified
|
|
123
145
|
(`engine/restart-health.js`: PID + HTTP probe). Skipped with `--no-restart`.
|
|
124
|
-
|
|
146
|
+
12. **`cleanup`** — always runs, on success and on every failure path. The
|
|
125
147
|
temporary npm config always goes; the downloaded artifact is retained (and its
|
|
126
148
|
path printed) when the run ended with the machine still needing it.
|
|
127
149
|
|
|
150
|
+
Every prefix-sensitive npm command — `root -g`, `prefix -g`, `uninstall -g`,
|
|
151
|
+
`install -g`, and the shim inspection between them — runs against the **same**
|
|
152
|
+
`--userconfig`, so a custom global prefix (for example
|
|
153
|
+
`C:\.tools\.npm-global`, which usually comes from an `.npmrc` rather than a flag)
|
|
154
|
+
cannot be honoured by the install and ignored by the probe.
|
|
155
|
+
|
|
128
156
|
After `sync-init` the script re-checks every runtime path that existed
|
|
129
157
|
beforehand (`config.json`, `engine/state.db`, `notes/`, `notes.md`, `plans/`,
|
|
130
158
|
`knowledge/`, `projects/`, `pinned.md`). A path that existed before and is
|
|
131
159
|
missing after is a hard failure pointing at the backup, not a silent data loss.
|
|
132
160
|
|
|
161
|
+
## What this is not
|
|
162
|
+
|
|
163
|
+
This is a **package-channel cutover**: it swaps the globally installed npm
|
|
164
|
+
package and leaves your runtime where it is. It is **not** a reset, not a
|
|
165
|
+
reinitialization, not an import/export migration, and it does not create a fresh
|
|
166
|
+
Minions home.
|
|
167
|
+
|
|
168
|
+
The runtime root is resolved **once**, before any package is touched
|
|
169
|
+
(`MINIONS_HOME`, otherwise `~/.minions`), and that exact path is pinned onto the
|
|
170
|
+
backup, the `init --force` sync, the verification, the restart, and any rollback.
|
|
171
|
+
Every one of the following survives a successful run, a failed run, a rollback,
|
|
172
|
+
and a re-run:
|
|
173
|
+
|
|
174
|
+
| State | Preserved how |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `engine/state.db` — work items, dispatches, PR records, schedules, pipelines, watches, meetings, PRDs, QA state, small state | Never replaced. Checkpointed to the backup directory (`PRAGMA wal_checkpoint(TRUNCATE)` + `VACUUM INTO`) before the package changes; the **live** database stays in place and the new version runs its own idempotent startup migrations against it. |
|
|
177
|
+
| `config.json` | `minions init` never overwrites it (`neverOverwrite` in `bin/minions.js`), and it is copied into the backup directory as well. |
|
|
178
|
+
| `routing.md`, `knowledge/agents/*.md` | Shipped by the package, so `init --force` *would* rewrite them. Snapshotted before `sync-init` and restored byte-for-byte after it. |
|
|
179
|
+
| `notes.md`, `notes/`, `pinned.md`, `plans/`, `prd/`, `projects/`, `agents/` | Not shipped by the package, so the sync never touches them. Existence is re-verified after the sync. |
|
|
180
|
+
|
|
181
|
+
The backup exists for **recovery only**. A failed install, verification, sync, or
|
|
182
|
+
shim check restores the *previous package* so `minions` keeps working — it never
|
|
183
|
+
restores state, never rolls your state backward, and never deletes state written
|
|
184
|
+
before or during the cutover.
|
|
185
|
+
|
|
186
|
+
|
|
133
187
|
## Token handling
|
|
134
188
|
|
|
135
189
|
- The ADO token is acquired **per run** and is short-lived.
|
|
@@ -169,6 +223,11 @@ the temporary npm config is removed either way.
|
|
|
169
223
|
Re-running is also the **repair** path: if a previous run left the internal
|
|
170
224
|
package on disk while `minions` still resolved elsewhere (or nowhere), the plan
|
|
171
225
|
reinstates `download-artifact` + `install-internal` so the shim is re-linked.
|
|
226
|
+
And if a previous run was interrupted between `npm uninstall -g` and
|
|
227
|
+
`npm install -g` — leaving `minions` / `minions.cmd` / `minions.ps1` behind with
|
|
228
|
+
no package to back them — `repair-shims` clears the orphans so the next run
|
|
229
|
+
converges instead of dying on `EEXIST`. See
|
|
230
|
+
[Orphaned `minions` shims](#orphaned-minions-shims-eexist).
|
|
172
231
|
|
|
173
232
|
## Relationship to `minions update`
|
|
174
233
|
|
|
@@ -203,7 +262,46 @@ the internal package on a machine that has no Minions at all.
|
|
|
203
262
|
| `refusing to uninstall @yemi33/minions — …` | The artifact or the backup is missing | Nothing was removed. Re-run; the message names which precondition failed. |
|
|
204
263
|
| `no way to back up the existing runtime state was found` | The runtime root has neither `engine/state-operations.js` nor a resolvable CLI | Nothing was uninstalled. The error lists every path that was probed; run `minions init --force` against the existing install to restore its engine files, then re-run. |
|
|
205
264
|
| `the minions shim … still points at @yemi33/minions` | A previous half-finished cutover left the public shim in place | `npm uninstall -g @yemi33/minions`, then re-run. The install commands are printed with the error. |
|
|
206
|
-
|
|
|
265
|
+
| `npm ERR! EEXIST … <prefix>\minions` on `npm install -g` | **Orphaned shim** — a prior uninstall or interrupted migration removed the package directory but left the `minions` command behind, so nothing reported the old package as installed and npm refused to overwrite the leftover. | Handled automatically by `repair-shims`: just re-run the installer. Details below. |
|
|
266
|
+
| `REFUSED: … is named minions but was not generated by npm for …` | A file called `minions` under `npm prefix -g` could not be attributed to `@yemi33/minions` or `@opg-microsoft/minions` | The installer never deletes a file it cannot prove it owns. Inspect the named path, move or rename it (or remove it yourself if it is a leftover), then re-run. Nothing was modified. |
|
|
267
|
+
| Feed install failed after the public package was removed | Feed/network failure mid-install | Handled automatically — the script reinstalls from the tarball it downloaded before the uninstall. If that also fails, the retained artifact path and the offline `npm install -g <tarball>` command are printed, and the previous package is restored so `minions` keeps working. Your state is untouched. |
|
|
268
|
+
|
|
269
|
+
### Orphaned `minions` shims (`EEXIST`)
|
|
270
|
+
|
|
271
|
+
The reported failure, from a Windows install whose npm global prefix is
|
|
272
|
+
`C:\.tools\.npm-global`:
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
npm error code EEXIST
|
|
276
|
+
npm error path C:\.tools\.npm-global\minions
|
|
277
|
+
npm error EEXIST: file already exists
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`<npm root -g>\@yemi33\minions` had already been removed, but the shim trio
|
|
281
|
+
`minions`, `minions.cmd`, and `minions.ps1` was still sitting under
|
|
282
|
+
`npm prefix -g`. With no package metadata left to read, the installer saw nothing
|
|
283
|
+
installed, planned no `uninstall-public`, and npm collided with the leftover.
|
|
284
|
+
|
|
285
|
+
Inspect the state yourself with:
|
|
286
|
+
|
|
287
|
+
```powershell
|
|
288
|
+
npm prefix -g
|
|
289
|
+
Get-ChildItem (npm prefix -g) -Filter 'minions*'
|
|
290
|
+
Get-ChildItem (Join-Path (npm root -g) '@yemi33'), (Join-Path (npm root -g) '@opg-microsoft') -ErrorAction SilentlyContinue
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
ls -l "$(npm prefix -g)/bin/minions"
|
|
295
|
+
ls -d "$(npm root -g)"/@yemi33/minions "$(npm root -g)"/@opg-microsoft/minions 2>/dev/null
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Re-running the installer is the fix — `repair-shims` clears the complete
|
|
299
|
+
npm-generated set so the install can recreate it. Nothing else about your install
|
|
300
|
+
is touched: the repair runs **after** the state backup, removes only files it has
|
|
301
|
+
proven npm generated for a Minions package that is no longer usable, and refuses
|
|
302
|
+
the run (exit `3`, nothing modified) rather than deleting an unrelated `minions`
|
|
303
|
+
executable. Your runtime state is never involved — see
|
|
304
|
+
[What this is not](#what-this-is-not).
|
|
207
305
|
|
|
208
306
|
## See also
|
|
209
307
|
|
|
@@ -4,6 +4,23 @@ const keepProcesses = require('../keep-process-sweep');
|
|
|
4
4
|
const managedSpawn = require('../managed-spawn');
|
|
5
5
|
const managedSpecNameMax = managedSpawn.validateManagedSpecName('x').maxLength;
|
|
6
6
|
const agentApiValidation = require('../agent-api-validation');
|
|
7
|
+
const paging = require('./paging');
|
|
8
|
+
|
|
9
|
+
// The optional progressive-load paging window is the ONLY caller-supplied input
|
|
10
|
+
// these list endpoints read; unknown params are ignored (not rejected) because
|
|
11
|
+
// they never reach a validator.
|
|
12
|
+
function pagedListContract() {
|
|
13
|
+
return {
|
|
14
|
+
audit: 'audited',
|
|
15
|
+
query: paging.listPagingQuery(
|
|
16
|
+
'Optional progressive-load paging window; any other query parameter is ignored, not rejected.',
|
|
17
|
+
),
|
|
18
|
+
constraints: [
|
|
19
|
+
...paging.listPagingClampConstraints(),
|
|
20
|
+
{ kind: 'ignores-unknown-query-params', location: 'query' },
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
7
24
|
|
|
8
25
|
function absentOrEmptyOperatorBody() {
|
|
9
26
|
return {
|
|
@@ -74,9 +91,12 @@ module.exports = {
|
|
|
74
91
|
],
|
|
75
92
|
overrides: {
|
|
76
93
|
'GET /api/keep-processes': { noInput: true },
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
// These three consume the optional ?limit=&offset= paging window
|
|
95
|
+
// (W-ms3ovk83000rb60f) — they are NOT input-less, so the catalog must publish
|
|
96
|
+
// the real parameters instead of certifying them noInput.
|
|
97
|
+
'GET /api/schedules': pagedListContract(),
|
|
98
|
+
'GET /api/pipelines': pagedListContract(),
|
|
99
|
+
'GET /api/watches': pagedListContract(),
|
|
80
100
|
'GET /api/watches/target-types': { noInput: true },
|
|
81
101
|
'GET /api/watches/action-types': { noInput: true },
|
|
82
102
|
'GET /api/watches/<id>/history': {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Single source of truth for the OPTIONAL progressive-load paging query surface
|
|
4
|
+
// (`?limit=&offset=`) shared by the large list endpoints.
|
|
5
|
+
//
|
|
6
|
+
// dashboard.js's `_parsePageParams` clamps incoming values with these bounds and
|
|
7
|
+
// the api-contract owner modules publish the same bounds on `GET /api/routes`,
|
|
8
|
+
// so the runtime behavior and the advertised contract cannot drift. Adding
|
|
9
|
+
// paging to another list endpoint means declaring `listPagingQuery()` on its
|
|
10
|
+
// contract — otherwise the route keeps certifying itself input-less while
|
|
11
|
+
// silently consuming caller input (review of PR #1062).
|
|
12
|
+
|
|
13
|
+
const LIST_PAGE_MAX_LIMIT = 1000;
|
|
14
|
+
const LIST_PAGE_DEFAULT_LIMIT = 50;
|
|
15
|
+
const LIST_PAGING_PARAMS = Object.freeze(['limit', 'offset']);
|
|
16
|
+
|
|
17
|
+
function listPagingQueryFields() {
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
name: 'limit',
|
|
21
|
+
type: 'integer',
|
|
22
|
+
required: false,
|
|
23
|
+
min: 1,
|
|
24
|
+
max: LIST_PAGE_MAX_LIMIT,
|
|
25
|
+
default: LIST_PAGE_DEFAULT_LIMIT,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'offset',
|
|
29
|
+
type: 'integer',
|
|
30
|
+
required: false,
|
|
31
|
+
min: 0,
|
|
32
|
+
default: 0,
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// `rationale` documents the per-route behavior for the params this contract does
|
|
38
|
+
// NOT list (rejected on /api/plans, ignored on the serveFreshJson endpoints).
|
|
39
|
+
function listPagingQuery(rationale) {
|
|
40
|
+
const query = { policy: 'optional', fields: listPagingQueryFields() };
|
|
41
|
+
if (rationale) query.rationale = String(rationale);
|
|
42
|
+
return query;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Out-of-range/non-numeric paging values are clamped to the published bounds
|
|
46
|
+
// rather than rejected, so the clamp is part of the contract, not a 400 path.
|
|
47
|
+
function listPagingClampConstraints() {
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
kind: 'clamped-limit',
|
|
51
|
+
field: 'limit',
|
|
52
|
+
location: 'query',
|
|
53
|
+
min: 1,
|
|
54
|
+
max: LIST_PAGE_MAX_LIMIT,
|
|
55
|
+
default: LIST_PAGE_DEFAULT_LIMIT,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
kind: 'clamped-offset',
|
|
59
|
+
field: 'offset',
|
|
60
|
+
location: 'query',
|
|
61
|
+
min: 0,
|
|
62
|
+
default: 0,
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
LIST_PAGE_MAX_LIMIT,
|
|
69
|
+
LIST_PAGE_DEFAULT_LIMIT,
|
|
70
|
+
LIST_PAGING_PARAMS,
|
|
71
|
+
listPagingQueryFields,
|
|
72
|
+
listPagingQuery,
|
|
73
|
+
listPagingClampConstraints,
|
|
74
|
+
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { PR_ACTIONS } = require('../pr-action');
|
|
4
4
|
const { EXECUTION_TARGET_IDS } = require('../pr-fix-target');
|
|
5
5
|
const { PR_FIX_CAUSE } = require('../shared');
|
|
6
|
+
const paging = require('./paging');
|
|
6
7
|
|
|
7
8
|
const PR_REFERENCE_FORMATS = Object.freeze([
|
|
8
9
|
'GitHub PR URL',
|
|
@@ -52,6 +53,15 @@ module.exports = {
|
|
|
52
53
|
overrides: {
|
|
53
54
|
'GET /api/pull-requests': {
|
|
54
55
|
audit: 'audited',
|
|
56
|
+
// Consumes the optional ?limit=&offset= paging window (W-ms3bbzry000i6612),
|
|
57
|
+
// clamped rather than rejected — declared so the catalog publishes it.
|
|
58
|
+
query: paging.listPagingQuery(
|
|
59
|
+
'Optional progressive-load paging window; any other query parameter is ignored, not rejected.',
|
|
60
|
+
),
|
|
61
|
+
constraints: [
|
|
62
|
+
...paging.listPagingClampConstraints(),
|
|
63
|
+
{ kind: 'ignores-unknown-query-params', location: 'query' },
|
|
64
|
+
],
|
|
55
65
|
},
|
|
56
66
|
'GET /api/prs/<id>': {
|
|
57
67
|
audit: 'audited',
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const paging = require('./paging');
|
|
4
|
+
|
|
3
5
|
const AUDITED_NEGATIVE_TESTS = Object.freeze([{
|
|
4
6
|
strategy: 'isolated-http-invalid-input-state-snapshot',
|
|
5
7
|
expectedStatus: '400-or-409',
|
|
@@ -57,7 +59,24 @@ module.exports = {
|
|
|
57
59
|
query: { policy: 'none', rationale: 'Handler rejects any query string (planPrdValidation.validateNoQuery).' },
|
|
58
60
|
},
|
|
59
61
|
'GET /api/plans': {
|
|
60
|
-
|
|
62
|
+
audit: 'audited',
|
|
63
|
+
// Accepts the optional ?limit=&offset= paging window (W-ms3ovk83000rb60f);
|
|
64
|
+
// every OTHER query parameter is still rejected with the canonical 400 via
|
|
65
|
+
// planPrdValidation.validateNoQuery(req, { allow: LIST_PAGING_PARAMS }).
|
|
66
|
+
query: paging.listPagingQuery(
|
|
67
|
+
'Optional progressive-load paging window; any other query parameter is rejected (planPrdValidation.validateNoQuery).',
|
|
68
|
+
),
|
|
69
|
+
constraints: [
|
|
70
|
+
...paging.listPagingClampConstraints(),
|
|
71
|
+
{
|
|
72
|
+
kind: 'reject-unknown-query-params',
|
|
73
|
+
location: 'query',
|
|
74
|
+
allowed: [...paging.LIST_PAGING_PARAMS],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
negativeTests: [
|
|
78
|
+
{ strategy: 'reject-unknown-query-param-blaming-the-rejected-name', expectedStatus: 400 },
|
|
79
|
+
],
|
|
61
80
|
},
|
|
62
81
|
'POST /api/work-items': {
|
|
63
82
|
audit: 'audited',
|
|
@@ -248,10 +267,18 @@ module.exports = {
|
|
|
248
267
|
},
|
|
249
268
|
},
|
|
250
269
|
'GET /api/work-items': {
|
|
251
|
-
// Polled list endpoint
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
270
|
+
// Polled list endpoint. Reads no body/headers; the only caller-supplied
|
|
271
|
+
// input is the optional ?limit=&offset= paging window (W-ms3bbzry000i6612),
|
|
272
|
+
// which is clamped rather than rejected. Declared explicitly so the catalog
|
|
273
|
+
// publishes the real parameters instead of certifying the route input-less.
|
|
274
|
+
audit: 'audited',
|
|
275
|
+
query: paging.listPagingQuery(
|
|
276
|
+
'Optional progressive-load paging window; any other query parameter is ignored, not rejected.',
|
|
277
|
+
),
|
|
278
|
+
constraints: [
|
|
279
|
+
...paging.listPagingClampConstraints(),
|
|
280
|
+
{ kind: 'ignores-unknown-query-params', location: 'query' },
|
|
281
|
+
],
|
|
255
282
|
},
|
|
256
283
|
'GET /api/work-items/archive': {
|
|
257
284
|
// Archived-item listing: reads no query/body. Affirmed input-less.
|
|
@@ -65,11 +65,20 @@ function validateBody(body) {
|
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
// Reject caller-supplied query params. `options.allow` lists the params the
|
|
69
|
+
// endpoint genuinely accepts (e.g. the optional `limit`/`offset` paging window);
|
|
70
|
+
// those are filtered out BEFORE the offending name is chosen, so the message and
|
|
71
|
+
// the machine-readable `field`/`path` always blame a rejected param rather than
|
|
72
|
+
// an accepted one (review of PR #1062).
|
|
73
|
+
function validateNoQuery(req, options = {}) {
|
|
74
|
+
const allowed = new Set(Array.isArray(options.allow) ? options.allow : []);
|
|
69
75
|
const parsed = new URL(req?.url || '/', 'http://localhost');
|
|
70
|
-
const names = [...new Set(parsed.searchParams.keys())];
|
|
76
|
+
const names = [...new Set(parsed.searchParams.keys())].filter(name => !allowed.has(name));
|
|
71
77
|
if (names.length > 0) {
|
|
72
|
-
|
|
78
|
+
const message = allowed.size > 0
|
|
79
|
+
? `This endpoint only accepts the query parameters ${[...allowed].join(', ')}: ${names.join(', ')}`
|
|
80
|
+
: `This endpoint does not accept query parameters: ${names.join(', ')}`;
|
|
81
|
+
inputError(message, {
|
|
73
82
|
code: 'unexpected-query',
|
|
74
83
|
field: names[0],
|
|
75
84
|
path: `query.${names[0]}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2447",
|
|
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"
|