metheus-governance-mcp-cli 0.2.292 → 0.2.293

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.
Files changed (2) hide show
  1. package/cli.mjs +500 -83
  2. package/package.json +1 -1
package/cli.mjs CHANGED
@@ -7501,26 +7501,31 @@ async function syncRunnerRequestLedgerForProjectToServer({ normalizedRoute, runt
7501
7501
  }
7502
7502
  }
7503
7503
 
7504
- function normalizeRunnerProcessLaunchEntry(rawEntry) {
7505
- const entry = safeObject(rawEntry);
7506
- return cleanupRunnerStateRecord({
7507
- launch_id: String(entry.launch_id || entry.launchID || "").trim(),
7508
- pid: intFromRawAllowZero(entry.pid, 0) || undefined,
7509
- started_at: String(entry.started_at || entry.startedAt || "").trim(),
7510
- command: String(entry.command || "").trim(),
7511
- cli_path: String(entry.cli_path || entry.cliPath || "").trim(),
7512
- working_directory: sanitizeWorkspaceCandidate(entry.working_directory || entry.workingDirectory) || String(entry.working_directory || entry.workingDirectory || "").trim(),
7513
- route_set_signature: String(entry.route_set_signature || entry.routeSetSignature || "").trim(),
7514
- route_keys: ensureArray(entry.route_keys || entry.routeKeys).map((item) => String(item || "").trim()).filter(Boolean),
7515
- route_names: ensureArray(entry.route_names || entry.routeNames).map((item) => String(item || "").trim()).filter(Boolean),
7516
- project_ids: ensureArray(entry.project_ids || entry.projectIds).map((item) => String(item || "").trim()).filter(Boolean),
7517
- provider: normalizeBotProvider(entry.provider),
7518
- destination_labels: ensureArray(entry.destination_labels || entry.destinationLabels).map((item) => String(item || "").trim()).filter(Boolean),
7519
- log_file: String(entry.log_file || entry.logFile || "").trim(),
7520
- created_by_pid: intFromRawAllowZero(entry.created_by_pid || entry.createdByPid, 0) || undefined,
7521
- source_command: String(entry.source_command || entry.sourceCommand || "").trim(),
7522
- });
7523
- }
7504
+ function normalizeRunnerProcessLaunchEntry(rawEntry) {
7505
+ const entry = safeObject(rawEntry);
7506
+ const routeKeys = ensureArray(entry.route_keys || entry.routeKeys).map((item) => String(item || "").trim()).filter(Boolean);
7507
+ return cleanupRunnerStateRecord({
7508
+ launch_id: String(entry.launch_id || entry.launchID || "").trim(),
7509
+ pid: intFromRawAllowZero(entry.pid, 0) || undefined,
7510
+ started_at: String(entry.started_at || entry.startedAt || "").trim(),
7511
+ command: String(entry.command || "").trim(),
7512
+ cli_path: String(entry.cli_path || entry.cliPath || "").trim(),
7513
+ working_directory: sanitizeWorkspaceCandidate(entry.working_directory || entry.workingDirectory) || String(entry.working_directory || entry.workingDirectory || "").trim(),
7514
+ route_set_signature: String(entry.route_set_signature || entry.routeSetSignature || "").trim(),
7515
+ route_keys: routeKeys,
7516
+ route_names: ensureArray(entry.route_names || entry.routeNames).map((item) => String(item || "").trim()).filter(Boolean),
7517
+ project_ids: ensureArray(entry.project_ids || entry.projectIds).map((item) => String(item || "").trim()).filter(Boolean),
7518
+ provider: normalizeBotProvider(entry.provider),
7519
+ destination_labels: ensureArray(entry.destination_labels || entry.destinationLabels).map((item) => String(item || "").trim()).filter(Boolean),
7520
+ scheduling_target_keys: uniqueOrderedStrings([
7521
+ ...ensureArray(entry.scheduling_target_keys || entry.schedulingTargetKeys).map((item) => String(item || "").trim()),
7522
+ ...routeKeys.map((routeKey) => runnerSchedulingTargetKeyFromRouteKey(routeKey)).filter(Boolean),
7523
+ ], (value) => String(value || "").trim()),
7524
+ log_file: String(entry.log_file || entry.logFile || "").trim(),
7525
+ created_by_pid: intFromRawAllowZero(entry.created_by_pid || entry.createdByPid, 0) || undefined,
7526
+ source_command: String(entry.source_command || entry.sourceCommand || "").trim(),
7527
+ });
7528
+ }
7524
7529
 
7525
7530
  function normalizeBotRunnerProcessRegistryContents(rawValue) {
7526
7531
  const launchesInput = safeObject(rawValue).launches;
@@ -12324,6 +12329,9 @@ function buildRunnerProjectTUISnapshot({
12324
12329
  const summaryPayload = safeObject(normalizedAuditResult.summaryPayload);
12325
12330
  const matchingRoutes = ensureArray(normalizedAuditResult.matchingRoutes)
12326
12331
  .map((route) => normalizeRunnerRoute(route));
12332
+ const matchingRouteNames = matchingRoutes
12333
+ .map((route) => String(route.name || runnerRouteKey(route)).trim())
12334
+ .filter(Boolean);
12327
12335
  const registry = loadBotRunnerProcessRegistry({ persistIfNeeded: true });
12328
12336
  const existingLaunch = matchingRoutes.length
12329
12337
  ? findExistingDetachedRunnerLaunch(registry, matchingRoutes)
@@ -12334,22 +12342,60 @@ function buildRunnerProjectTUISnapshot({
12334
12342
  alive: true,
12335
12343
  }
12336
12344
  : null;
12345
+ const destinationID = String(summaryPayload.destination_id || "").trim();
12346
+ const destinationLabel = String(summaryPayload.destination_label || "").trim();
12347
+ const projectID = String(summaryPayload.project_id || "").trim();
12348
+ const relatedLaunches = Object.values(safeObject(registry).launches || {})
12349
+ .map((entry) => normalizeRunnerProcessLaunchEntry(entry))
12350
+ .filter((entry) => isProcessAlive(entry.pid))
12351
+ .filter((entry) => {
12352
+ const sameProject = ensureArray(entry.project_ids)
12353
+ .map((item) => String(item || "").trim())
12354
+ .includes(projectID);
12355
+ if (!sameProject) return false;
12356
+ const sameDestinationLabel = destinationLabel
12357
+ ? ensureArray(entry.destination_labels)
12358
+ .map((item) => String(item || "").trim().toLowerCase())
12359
+ .includes(destinationLabel.toLowerCase())
12360
+ : false;
12361
+ const sameDestinationID = destinationID
12362
+ ? ensureArray(entry.route_keys)
12363
+ .map((item) => String(item || "").trim())
12364
+ .some((item) => item.includes(`::${destinationID}`))
12365
+ : false;
12366
+ return sameDestinationLabel || sameDestinationID;
12367
+ })
12368
+ .map((entry) => {
12369
+ const routeNames = ensureArray(entry.route_names)
12370
+ .map((item) => String(item || "").trim())
12371
+ .filter(Boolean);
12372
+ const sharedRouteNames = routeNames.filter((routeName) => matchingRouteNames.includes(routeName));
12373
+ return {
12374
+ launch_id: String(entry.launch_id || "").trim(),
12375
+ pid: intFromRawAllowZero(entry.pid, 0),
12376
+ route_names: routeNames,
12377
+ shared_route_names: sharedRouteNames,
12378
+ source_command: String(entry.source_command || "").trim(),
12379
+ same_selection: Boolean(launch && String(launch.launch_id || "").trim() === String(entry.launch_id || "").trim()),
12380
+ };
12381
+ });
12337
12382
  const logFilePath = String(launch?.log_file || "").trim();
12338
12383
  const logLines = readTextFileTailLines(logFilePath, 12);
12339
12384
  return {
12340
- project_id: String(summaryPayload.project_id || "").trim(),
12341
- destination_label: String(summaryPayload.destination_label || "").trim(),
12342
- destination_id: String(summaryPayload.destination_id || "").trim(),
12385
+ project_id: projectID,
12386
+ destination_label: destinationLabel,
12387
+ destination_id: destinationID,
12343
12388
  room_probe_ok: Boolean(summaryPayload.room_probe_ok),
12344
12389
  route_apply_requested: Boolean(summaryPayload.route_apply_requested),
12345
12390
  route_apply_changed: Boolean(summaryPayload.route_apply_changed),
12346
12391
  route_config_file: String(summaryPayload.route_config_file || "").trim(),
12347
12392
  enabled_routes: ensureArray(summaryPayload.enabled_routes_for_selection).map((item) => String(item || "").trim()).filter(Boolean),
12348
- matching_routes: matchingRoutes.map((route) => String(route.name || runnerRouteKey(route)).trim()).filter(Boolean),
12393
+ matching_routes: matchingRouteNames,
12349
12394
  next_steps: ensureArray(summaryPayload.next_steps).map((item) => String(item || "").trim()).filter(Boolean),
12350
12395
  warning: String(summaryPayload.warning || "").trim(),
12351
12396
  error: String(summaryPayload.error || errorMessage || "").trim(),
12352
12397
  launch,
12398
+ related_launches: relatedLaunches,
12353
12399
  auto_start: autoStart,
12354
12400
  status_message: String(statusMessage || "").trim(),
12355
12401
  last_audit_at: String(lastAuditAt || "").trim() || new Date().toISOString(),
@@ -12360,6 +12406,25 @@ function buildRunnerProjectTUISnapshot({
12360
12406
  };
12361
12407
  }
12362
12408
 
12409
+ function buildRunnerProjectTUIKeyLine(label, value, labelWidth = 14) {
12410
+ return `${bootstrapPadRight(`${label}:`, labelWidth)} ${String(value || "-").trim() || "-"}`;
12411
+ }
12412
+
12413
+ function buildRunnerProjectTUIList(items = [], {
12414
+ emptyText = "-",
12415
+ bullet = "- ",
12416
+ indent = " ",
12417
+ width = 140,
12418
+ } = {}) {
12419
+ const normalizedItems = ensureArray(items)
12420
+ .map((item) => String(item || "").trim())
12421
+ .filter(Boolean);
12422
+ if (!normalizedItems.length) {
12423
+ return [`${indent}${emptyText}`];
12424
+ }
12425
+ return normalizedItems.map((item) => `${indent}${bullet}${truncateRunnerTUIText(item, width)}`);
12426
+ }
12427
+
12363
12428
  function buildRunnerProjectTUIFrame({
12364
12429
  snapshot,
12365
12430
  now = Date.now(),
@@ -12368,41 +12433,105 @@ function buildRunnerProjectTUIFrame({
12368
12433
  }) {
12369
12434
  const normalizedSnapshot = safeObject(snapshot);
12370
12435
  const alive = Boolean(safeObject(normalizedSnapshot.launch).alive);
12371
- const statusLabel = alive ? "RUNNING" : "STOPPED";
12436
+ const statusLabel = alive ? "[ RUNNING ]" : "[ STOPPED ]";
12372
12437
  const statusDetail = alive
12373
12438
  ? `launch=${String(safeObject(normalizedSnapshot.launch).launch_id || "").trim() || "-"} pid=${String(safeObject(normalizedSnapshot.launch).pid || "").trim() || "-"}`
12374
12439
  : "no detached runner is active for this selection";
12440
+ const enabledRoutes = ensureArray(normalizedSnapshot.enabled_routes);
12441
+ const matchingRoutes = ensureArray(normalizedSnapshot.matching_routes);
12442
+ const relatedLaunches = ensureArray(normalizedSnapshot.related_launches);
12443
+ const selectedLaunch = safeObject(normalizedSnapshot.launch);
12444
+ const otherLaunches = relatedLaunches.filter((launch) => !safeObject(launch).same_selection);
12445
+ const overlappingLaunches = otherLaunches.filter((launch) => ensureArray(safeObject(launch).shared_route_names).length > 0);
12446
+ const alertLines = [];
12447
+ if (String(normalizedSnapshot.warning || "").trim()) {
12448
+ alertLines.push(`warning: ${String(normalizedSnapshot.warning || "").trim()}`);
12449
+ }
12450
+ if (String(normalizedSnapshot.error || "").trim()) {
12451
+ alertLines.push(`error: ${String(normalizedSnapshot.error || "").trim()}`);
12452
+ }
12453
+ if (overlappingLaunches.length) {
12454
+ alertLines.push(`overlap: ${overlappingLaunches.length} other detached launch(es) share at least one route in this room`);
12455
+ } else if (otherLaunches.length) {
12456
+ alertLines.push(`notice: ${otherLaunches.length} other detached launch(es) are active for this project/destination`);
12457
+ }
12458
+ if (!alive) {
12459
+ alertLines.push("runner is not active; press s to start or reuse a detached runner");
12460
+ }
12461
+ const stateHint = alive
12462
+ ? "This screen can be closed with q. The detached runner keeps running."
12463
+ : "No detached runner is attached to this selection yet.";
12375
12464
  const lines = [
12376
- "+----------------------------------------------------------------+",
12377
- "| RUNNER PROJECT TUI |",
12378
- "| Project-scoped detached runner dashboard |",
12379
- "+----------------------------------------------------------------+",
12380
- `Status: ${statusLabel}${stopping ? " (closing TUI)" : ""}`,
12381
- `Detail: ${statusDetail}`,
12382
- `Project: ${String(normalizedSnapshot.project_id || "").trim() || "-"}`,
12383
- `Destination: ${String(normalizedSnapshot.destination_label || normalizedSnapshot.destination_id || "").trim() || "-"}`,
12384
- `Auto Start: ${normalizedSnapshot.auto_start ? "true" : "false"}`,
12385
- `Routes: ${ensureArray(normalizedSnapshot.enabled_routes).join(", ") || "-"}`,
12386
- `Room Probe OK: ${normalizedSnapshot.room_probe_ok ? "true" : "false"}`,
12387
- `Route Apply Changed: ${normalizedSnapshot.route_apply_changed ? "true" : "false"}`,
12388
- `Log File: ${String(normalizedSnapshot.log_file || "").trim() || "-"}`,
12389
- `Last Audit: ${String(normalizedSnapshot.last_audit_at || "").trim() || "-"}`,
12390
- `Updated: ${new Date(now).toLocaleString("sv-SE", { hour12: false })}`,
12391
- `Message: ${String(normalizedSnapshot.status_message || "").trim() || "-"}`,
12392
- `Warning: ${String(normalizedSnapshot.warning || "").trim() || "-"}`,
12393
- `Error: ${String(normalizedSnapshot.error || "").trim() || "-"}`,
12465
+ "+======================================================================================+",
12466
+ "| METHEUS PROJECT RUNNER |",
12467
+ "| Detached runner dashboard |",
12468
+ "+======================================================================================+",
12469
+ "",
12470
+ "[ STATUS ]",
12471
+ ` ${statusLabel}${stopping ? " (closing TUI)" : ""}`,
12472
+ ` ${statusDetail}`,
12473
+ ` ${stateHint}`,
12394
12474
  "",
12395
- "Controls: [s] start/reuse [x] stop [r] refresh [a] re-audit [q] quit",
12475
+ "[ PROJECT ]",
12476
+ ` ${buildRunnerProjectTUIKeyLine("Project", normalizedSnapshot.project_id)}`,
12477
+ ` ${buildRunnerProjectTUIKeyLine("Destination", String(normalizedSnapshot.destination_label || normalizedSnapshot.destination_id || "").trim() || "-")}`,
12478
+ ` ${buildRunnerProjectTUIKeyLine("Updated", new Date(now).toLocaleString("sv-SE", { hour12: false }))}`,
12479
+ ` ${buildRunnerProjectTUIKeyLine("Last Audit", String(normalizedSnapshot.last_audit_at || "").trim() || "-")}`,
12396
12480
  "",
12397
- "Recent Log Tail:",
12398
- ...ensureArray(normalizedSnapshot.log_lines).map((line) => ` ${truncateRunnerTUIText(line, 140)}`),
12481
+ "[ RUNNER ]",
12482
+ ` ${buildRunnerProjectTUIKeyLine("Auto Start", normalizedSnapshot.auto_start ? "enabled" : "prepare-only")}`,
12483
+ ` ${buildRunnerProjectTUIKeyLine("Room Probe", normalizedSnapshot.room_probe_ok ? "ok" : "failed")}`,
12484
+ ` ${buildRunnerProjectTUIKeyLine("Apply Result", normalizedSnapshot.route_apply_changed ? "route file changed" : "no route file change")}`,
12485
+ ` ${buildRunnerProjectTUIKeyLine("Active Launches", `${relatedLaunches.length}`)}`,
12486
+ ` ${buildRunnerProjectTUIKeyLine("This Launch", alive ? String(selectedLaunch.launch_id || "-").trim() || "-" : "-")}`,
12487
+ ` ${buildRunnerProjectTUIKeyLine("PID", alive ? String(selectedLaunch.pid || "-").trim() || "-" : "-")}`,
12488
+ ` ${buildRunnerProjectTUIKeyLine("Log File", String(normalizedSnapshot.log_file || "").trim() || "-")}`,
12489
+ "",
12490
+ "[ ROUTES ]",
12491
+ ` ${buildRunnerProjectTUIKeyLine("Enabled", `${enabledRoutes.length}`)}`,
12492
+ ` ${buildRunnerProjectTUIKeyLine("Matched", `${matchingRoutes.length}`)}`,
12493
+ ...buildRunnerProjectTUIList(matchingRoutes.length ? matchingRoutes : enabledRoutes, {
12494
+ emptyText: "no routes selected",
12495
+ bullet: "- ",
12496
+ indent: " ",
12497
+ width: 96,
12498
+ }),
12499
+ "",
12500
+ "[ ALERTS ]",
12501
+ ...buildRunnerProjectTUIList(alertLines, {
12502
+ emptyText: "none",
12503
+ bullet: "- ",
12504
+ indent: " ",
12505
+ width: 96,
12506
+ }),
12507
+ "",
12508
+ "[ CONTROLS ]",
12509
+ " s = start/reuse detached runner",
12510
+ " x = stop detached runner",
12511
+ " r = refresh status",
12512
+ " a = re-audit route selection",
12513
+ " q = quit this dashboard",
12514
+ "",
12515
+ "[ RECENT LOG TAIL ]",
12516
+ ...ensureArray(normalizedSnapshot.log_lines).map((line) => ` ${truncateRunnerTUIText(line, 108)}`),
12399
12517
  ];
12518
+ if (otherLaunches.length) {
12519
+ lines.push("");
12520
+ lines.push("[ OTHER ACTIVE LAUNCHES ]");
12521
+ for (const launch of otherLaunches) {
12522
+ const currentLaunch = safeObject(launch);
12523
+ const sharedRoutes = ensureArray(currentLaunch.shared_route_names).join(", ") || "-";
12524
+ lines.push(` - ${String(currentLaunch.launch_id || "-").trim() || "-"} pid=${String(currentLaunch.pid || "-").trim() || "-"} source=${String(currentLaunch.source_command || "-").trim() || "-"}`);
12525
+ lines.push(` shared routes: ${truncateRunnerTUIText(sharedRoutes, 96)}`);
12526
+ lines.push(` all routes: ${truncateRunnerTUIText(ensureArray(currentLaunch.route_names).join(", ") || "-", 96)}`);
12527
+ }
12528
+ }
12400
12529
  const frame = lines.join("\n");
12401
12530
  if (!useColor) {
12402
12531
  return frame;
12403
12532
  }
12404
12533
  const statusColor = alive ? "\u001b[32m" : "\u001b[33m";
12405
- return frame.replace(`Status: ${statusLabel}`, `Status: ${statusColor}${statusLabel}\u001b[0m`);
12534
+ return frame.replace(statusLabel, `${statusColor}${statusLabel}\u001b[0m`);
12406
12535
  }
12407
12536
 
12408
12537
  async function runRunnerProjectTUI(flags) {
@@ -13083,13 +13212,121 @@ function serializeCLIFlags(flags, options = {}) {
13083
13212
  return args;
13084
13213
  }
13085
13214
 
13086
- function runnerDetachedRouteSetSignature(routes) {
13087
- return ensureArray(routes)
13088
- .map((route) => runnerRouteKey(normalizeRunnerRoute(route)))
13089
- .filter(Boolean)
13090
- .sort()
13091
- .join("||");
13092
- }
13215
+ function runnerDetachedRouteSetSignature(routes) {
13216
+ return ensureArray(routes)
13217
+ .map((route) => runnerRouteKey(normalizeRunnerRoute(route)))
13218
+ .filter(Boolean)
13219
+ .sort()
13220
+ .join("||");
13221
+ }
13222
+
13223
+ function runnerSchedulingTargetKeyFromRouteKey(routeKey) {
13224
+ const parts = String(routeKey || "").trim().split("::").filter(Boolean);
13225
+ if (parts.length < 6) {
13226
+ return "";
13227
+ }
13228
+ return [
13229
+ String(parts[parts.length - 5] || "").trim(),
13230
+ String(parts[parts.length - 4] || "").trim(),
13231
+ String(parts[parts.length - 1] || "").trim(),
13232
+ ].join("::");
13233
+ }
13234
+
13235
+ function collectRunnerDetachedLaunchSchedulingTargetKeys(entryRaw) {
13236
+ const entry = normalizeRunnerProcessLaunchEntry(entryRaw);
13237
+ const explicitKeys = uniqueOrderedStrings(
13238
+ ensureArray(entry.scheduling_target_keys || entry.schedulingTargetKeys),
13239
+ (value) => String(value || "").trim(),
13240
+ ).filter(Boolean);
13241
+ if (explicitKeys.length > 0) {
13242
+ return explicitKeys;
13243
+ }
13244
+ const routeKeyDerived = uniqueOrderedStrings(
13245
+ ensureArray(entry.route_keys).map((value) => runnerSchedulingTargetKeyFromRouteKey(value)).filter(Boolean),
13246
+ (value) => String(value || "").trim(),
13247
+ ).filter(Boolean);
13248
+ if (routeKeyDerived.length > 0) {
13249
+ return routeKeyDerived;
13250
+ }
13251
+ const provider = normalizeBotProvider(entry.provider);
13252
+ const projectIDs = ensureArray(entry.project_ids).map((value) => String(value || "").trim()).filter(Boolean);
13253
+ const destinations = ensureArray(entry.destination_labels).map((value) => String(value || "").trim()).filter(Boolean);
13254
+ return uniqueOrderedStrings(
13255
+ projectIDs.flatMap((projectID) => destinations.map((destination) => [projectID, provider, destination].join("::"))),
13256
+ (value) => String(value || "").trim(),
13257
+ ).filter(Boolean);
13258
+ }
13259
+
13260
+ function describeDetachedRunnerLaunch(entryRaw) {
13261
+ const entry = normalizeRunnerProcessLaunchEntry(entryRaw);
13262
+ return `launch_id=${entry.launch_id || "-"} pid=${entry.pid || "-"} routes=${ensureArray(entry.route_names).join(", ") || "-"}`;
13263
+ }
13264
+
13265
+ function classifyDetachedRunnerLaunchReuse(registry, routes) {
13266
+ const normalizedRoutes = ensureArray(routes).map((route) => normalizeRunnerRoute(route));
13267
+ const targetSignature = runnerDetachedRouteSetSignature(normalizedRoutes);
13268
+ const targetRouteKeys = normalizedRoutes.map((route) => runnerRouteKey(route)).filter(Boolean);
13269
+ const targetRouteKeySet = new Set(targetRouteKeys);
13270
+ const targetSchedulingTargetKeys = uniqueOrderedStrings(
13271
+ normalizedRoutes.map((route) => runnerRouteSchedulingGroupKey(route)).filter(Boolean),
13272
+ (value) => String(value || "").trim(),
13273
+ ).filter(Boolean);
13274
+ const aliveLaunches = Object.values(safeObject(registry).launches || {})
13275
+ .map((entryRaw) => normalizeRunnerProcessLaunchEntry(entryRaw))
13276
+ .filter((entry) => entry.launch_id && entry.pid && isProcessAlive(entry.pid));
13277
+ const exact = aliveLaunches.find((entry) => entry.route_set_signature === targetSignature) || null;
13278
+ if (exact) {
13279
+ return {
13280
+ kind: "reuse",
13281
+ reason: "exact",
13282
+ launch: exact,
13283
+ };
13284
+ }
13285
+ if (!targetSchedulingTargetKeys.length) {
13286
+ return {
13287
+ kind: "none",
13288
+ };
13289
+ }
13290
+ const sameTargetLaunches = aliveLaunches.filter((entry) => {
13291
+ const entryTargetKeys = collectRunnerDetachedLaunchSchedulingTargetKeys(entry);
13292
+ return entryTargetKeys.some((key) => targetSchedulingTargetKeys.includes(key));
13293
+ });
13294
+ if (!sameTargetLaunches.length) {
13295
+ return {
13296
+ kind: "none",
13297
+ };
13298
+ }
13299
+ const supersets = sameTargetLaunches.filter((entry) => targetRouteKeys.every((routeKey) => ensureArray(entry.route_keys).includes(routeKey)));
13300
+ if (supersets.length === 1) {
13301
+ return {
13302
+ kind: "reuse",
13303
+ reason: "superset",
13304
+ launch: supersets[0],
13305
+ };
13306
+ }
13307
+ const conflictingLaunches = supersets.length > 1 ? supersets : sameTargetLaunches;
13308
+ const overlapRouteNames = Array.from(
13309
+ new Set(
13310
+ conflictingLaunches.flatMap((entry) => ensureArray(entry.route_keys))
13311
+ .filter((routeKey) => targetRouteKeySet.has(routeKey))
13312
+ .map((routeKey) => {
13313
+ const matchedRoute = normalizedRoutes.find((route) => runnerRouteKey(route) === routeKey);
13314
+ return matchedRoute?.name || routeKey;
13315
+ })
13316
+ .filter(Boolean),
13317
+ ),
13318
+ );
13319
+ const detail = supersets.length > 1
13320
+ ? `multiple detached runners already cover the requested route set for the same project/provider/destination target (${conflictingLaunches.map((entry) => describeDetachedRunnerLaunch(entry)).join(" | ")})`
13321
+ : `another detached runner already owns the same project/provider/destination target (${conflictingLaunches.map((entry) => describeDetachedRunnerLaunch(entry)).join(" | ")}). Stop it first or use runner project tui so one detached runner owns the full route set for that destination`;
13322
+ return {
13323
+ kind: "conflict",
13324
+ detail,
13325
+ launch: conflictingLaunches.length === 1 ? conflictingLaunches[0] : null,
13326
+ conflicting_launches: conflictingLaunches,
13327
+ overlapping_route_names: overlapRouteNames,
13328
+ };
13329
+ }
13093
13330
 
13094
13331
  function buildRunnerDetachedPosixLaunchScript({
13095
13332
  scriptPath,
@@ -13149,25 +13386,28 @@ function buildRunnerDetachedLaunchRecord(childPID, routes, flags, sourceCommand,
13149
13386
  cli_path: cliPath,
13150
13387
  working_directory: path.dirname(cliPath),
13151
13388
  route_set_signature: runnerDetachedRouteSetSignature(normalizedRoutes),
13152
- route_keys: normalizedRoutes.map((route) => runnerRouteKey(route)),
13153
- route_names: normalizedRoutes.map((route) => route.name || runnerRouteKey(route)),
13154
- project_ids: Array.from(new Set(normalizedRoutes.map((route) => String(route.projectID || "").trim()).filter(Boolean))),
13155
- provider: firstNonEmptyString(normalizedRoutes.map((route) => route.provider)),
13156
- destination_labels: Array.from(new Set(normalizedRoutes.map((route) => String(route.destinationLabel || route.destinationID || "").trim()).filter(Boolean))),
13157
- log_file: String(logFilePath || "").trim(),
13158
- created_by_pid: process.pid,
13159
- source_command: sourceCommand,
13160
- });
13161
- }
13389
+ route_keys: normalizedRoutes.map((route) => runnerRouteKey(route)),
13390
+ route_names: normalizedRoutes.map((route) => route.name || runnerRouteKey(route)),
13391
+ project_ids: Array.from(new Set(normalizedRoutes.map((route) => String(route.projectID || "").trim()).filter(Boolean))),
13392
+ provider: firstNonEmptyString(normalizedRoutes.map((route) => route.provider)),
13393
+ destination_labels: Array.from(new Set(normalizedRoutes.map((route) => String(route.destinationLabel || route.destinationID || "").trim()).filter(Boolean))),
13394
+ scheduling_target_keys: uniqueOrderedStrings(
13395
+ normalizedRoutes.map((route) => runnerRouteSchedulingGroupKey(route)).filter(Boolean),
13396
+ (value) => String(value || "").trim(),
13397
+ ),
13398
+ log_file: String(logFilePath || "").trim(),
13399
+ created_by_pid: process.pid,
13400
+ source_command: sourceCommand,
13401
+ });
13402
+ }
13162
13403
 
13163
- function findExistingDetachedRunnerLaunch(registry, routes) {
13164
- const targetSignature = runnerDetachedRouteSetSignature(routes);
13165
- if (!targetSignature) return null;
13166
- return Object.values(safeObject(registry).launches || {}).find((entryRaw) => {
13167
- const entry = normalizeRunnerProcessLaunchEntry(entryRaw);
13168
- return entry.route_set_signature === targetSignature && isProcessAlive(entry.pid);
13169
- }) || null;
13170
- }
13404
+ function findExistingDetachedRunnerLaunch(registry, routes) {
13405
+ const decision = classifyDetachedRunnerLaunchReuse(registry, routes);
13406
+ if (decision.kind === "reuse" && decision.launch) {
13407
+ return decision.launch;
13408
+ }
13409
+ return null;
13410
+ }
13171
13411
 
13172
13412
  async function launchDetachedRunnerProcess(flags, routes, sourceCommand) {
13173
13413
  const cliPath = fileURLToPath(import.meta.url);
@@ -13359,13 +13599,15 @@ async function runRunnerStop(flags) {
13359
13599
  async function runRunnerStartDetachedResolvedRoutes(routes, flags, sourceCommand = "runner start-detached", options = {}) {
13360
13600
  const silent = boolFromRaw(safeObject(options).silent, false);
13361
13601
  const registry = loadBotRunnerProcessRegistry({ persistIfNeeded: true });
13362
- const existing = findExistingDetachedRunnerLaunch(registry, routes);
13363
- if (existing) {
13602
+ const reuseDecision = classifyDetachedRunnerLaunchReuse(registry, routes);
13603
+ if (reuseDecision.kind === "reuse" && reuseDecision.launch) {
13604
+ const existing = reuseDecision.launch;
13364
13605
  const payload = {
13365
13606
  ok: true,
13366
- already_running: true,
13367
- registry_file: registry.filePath,
13368
- launch: {
13607
+ already_running: true,
13608
+ reuse_reason: reuseDecision.reason,
13609
+ registry_file: registry.filePath,
13610
+ launch: {
13369
13611
  ...existing,
13370
13612
  alive: true,
13371
13613
  },
@@ -13375,15 +13617,24 @@ async function runRunnerStartDetachedResolvedRoutes(routes, flags, sourceCommand
13375
13617
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
13376
13618
  return payload;
13377
13619
  }
13378
- process.stdout.write(`Detached runner already running: launch_id=${existing.launch_id} pid=${existing.pid}${existing.log_file ? ` log_file=${existing.log_file}` : ""}\n`);
13620
+ process.stdout.write(`Detached runner already running: launch_id=${existing.launch_id} pid=${existing.pid}${existing.log_file ? ` log_file=${existing.log_file}` : ""}${reuseDecision.reason ? ` reuse=${reuseDecision.reason}` : ""}\n`);
13379
13621
  }
13380
13622
  return payload;
13381
13623
  }
13624
+ if (reuseDecision.kind === "conflict") {
13625
+ const overlappingRouteNames = ensureArray(reuseDecision.overlapping_route_names)
13626
+ .map((item) => String(item || "").trim())
13627
+ .filter(Boolean);
13628
+ const overlapSuffix = overlappingRouteNames.length
13629
+ ? ` overlapping_routes=${overlappingRouteNames.join(", ")}`
13630
+ : "";
13631
+ throw new Error(`${String(reuseDecision.detail || "detached runner conflict").trim()}${overlapSuffix}`);
13632
+ }
13382
13633
  const launch = await launchDetachedRunnerProcess(flags, routes, sourceCommand);
13383
13634
  const nextLaunches = {
13384
13635
  ...safeObject(registry).launches,
13385
13636
  [launch.launch_id]: launch,
13386
- };
13637
+ };
13387
13638
  saveBotRunnerProcessRegistry({ launches: nextLaunches });
13388
13639
  const payload = {
13389
13640
  ok: true,
@@ -19658,10 +19909,176 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
19658
19909
  } catch (err) {
19659
19910
  push("detached_runner_launch_record_persists_log_file", false, String(err?.message || err));
19660
19911
  }
19661
-
19662
- let detachedRunnerPosixTempDir = "";
19663
- try {
19664
- detachedRunnerPosixTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-detached-script-selftest-"));
19912
+
19913
+ try {
19914
+ const routeBot1 = normalizeRunnerRoute({
19915
+ name: "telegram-monitor-selftest-bot-1",
19916
+ projectID: selftestProjectID,
19917
+ provider: "telegram",
19918
+ role: "monitor",
19919
+ botID: "bot-1",
19920
+ botName: "SelftestBot1",
19921
+ destinationID: "dest-1",
19922
+ destinationLabel: "Selftest Room",
19923
+ });
19924
+ const routeBot2 = normalizeRunnerRoute({
19925
+ name: "telegram-monitor-selftest-bot-2",
19926
+ projectID: selftestProjectID,
19927
+ provider: "telegram",
19928
+ role: "monitor",
19929
+ botID: "bot-2",
19930
+ botName: "SelftestBot2",
19931
+ destinationID: "dest-1",
19932
+ destinationLabel: "Selftest Room",
19933
+ });
19934
+ const supersetLaunch = buildRunnerDetachedLaunchRecord(
19935
+ process.pid,
19936
+ [routeBot1, routeBot2],
19937
+ { tui: false },
19938
+ "runner start-detached",
19939
+ );
19940
+ const decision = classifyDetachedRunnerLaunchReuse(
19941
+ { launches: { [supersetLaunch.launch_id]: supersetLaunch } },
19942
+ [routeBot2],
19943
+ );
19944
+ push(
19945
+ "detached_runner_reuses_existing_superset_launch_for_same_target",
19946
+ decision.kind === "reuse"
19947
+ && decision.reason === "superset"
19948
+ && String(safeObject(decision.launch).launch_id || "").trim() === supersetLaunch.launch_id,
19949
+ `kind=${String(decision.kind || "")} reason=${String(decision.reason || "")} launch=${String(safeObject(decision.launch).launch_id || "")}`,
19950
+ );
19951
+ } catch (err) {
19952
+ push("detached_runner_reuses_existing_superset_launch_for_same_target", false, String(err?.message || err));
19953
+ }
19954
+
19955
+ try {
19956
+ const routeBot1 = normalizeRunnerRoute({
19957
+ name: "telegram-monitor-selftest-bot-1",
19958
+ projectID: selftestProjectID,
19959
+ provider: "telegram",
19960
+ role: "monitor",
19961
+ botID: "bot-1",
19962
+ botName: "SelftestBot1",
19963
+ destinationID: "dest-1",
19964
+ destinationLabel: "Selftest Room",
19965
+ });
19966
+ const routeBot2 = normalizeRunnerRoute({
19967
+ name: "telegram-monitor-selftest-bot-2",
19968
+ projectID: selftestProjectID,
19969
+ provider: "telegram",
19970
+ role: "monitor",
19971
+ botID: "bot-2",
19972
+ botName: "SelftestBot2",
19973
+ destinationID: "dest-1",
19974
+ destinationLabel: "Selftest Room",
19975
+ });
19976
+ const supersetLaunch = buildRunnerDetachedLaunchRecord(
19977
+ process.pid,
19978
+ [routeBot1, routeBot2],
19979
+ { tui: false },
19980
+ "runner project tui",
19981
+ );
19982
+ const existing = findExistingDetachedRunnerLaunch(
19983
+ { launches: { [supersetLaunch.launch_id]: supersetLaunch } },
19984
+ [routeBot2],
19985
+ );
19986
+ push(
19987
+ "detached_runner_existing_launch_lookup_reuses_superset_for_tui_snapshot",
19988
+ String(safeObject(existing).launch_id || "").trim() === supersetLaunch.launch_id,
19989
+ `launch=${String(safeObject(existing).launch_id || "").trim() || "(none)"}`,
19990
+ );
19991
+ } catch (err) {
19992
+ push("detached_runner_existing_launch_lookup_reuses_superset_for_tui_snapshot", false, String(err?.message || err));
19993
+ }
19994
+
19995
+ try {
19996
+ const routeBot1 = normalizeRunnerRoute({
19997
+ name: "telegram-monitor-selftest-bot-1",
19998
+ projectID: selftestProjectID,
19999
+ provider: "telegram",
20000
+ role: "monitor",
20001
+ botID: "bot-1",
20002
+ botName: "SelftestBot1",
20003
+ destinationID: "dest-1",
20004
+ destinationLabel: "Selftest Room",
20005
+ });
20006
+ const routeBot2 = normalizeRunnerRoute({
20007
+ name: "telegram-monitor-selftest-bot-2",
20008
+ projectID: selftestProjectID,
20009
+ provider: "telegram",
20010
+ role: "monitor",
20011
+ botID: "bot-2",
20012
+ botName: "SelftestBot2",
20013
+ destinationID: "dest-1",
20014
+ destinationLabel: "Selftest Room",
20015
+ });
20016
+ const subsetLaunch = buildRunnerDetachedLaunchRecord(
20017
+ process.pid,
20018
+ [routeBot2],
20019
+ { tui: false },
20020
+ "runner start-detached",
20021
+ );
20022
+ const decision = classifyDetachedRunnerLaunchReuse(
20023
+ { launches: { [subsetLaunch.launch_id]: subsetLaunch } },
20024
+ [routeBot1, routeBot2],
20025
+ );
20026
+ push(
20027
+ "detached_runner_blocks_partial_overlap_for_same_target",
20028
+ decision.kind === "conflict"
20029
+ && ensureArray(decision.overlapping_route_names).includes("telegram-monitor-selftest-bot-2")
20030
+ && String(decision.detail || "").includes("runner project tui"),
20031
+ `kind=${String(decision.kind || "")} overlap=${ensureArray(decision.overlapping_route_names).join(", ")} detail=${String(decision.detail || "")}`,
20032
+ );
20033
+ } catch (err) {
20034
+ push("detached_runner_blocks_partial_overlap_for_same_target", false, String(err?.message || err));
20035
+ }
20036
+
20037
+ try {
20038
+ const routeBot1 = normalizeRunnerRoute({
20039
+ name: "telegram-monitor-selftest-bot-1",
20040
+ projectID: selftestProjectID,
20041
+ provider: "telegram",
20042
+ role: "monitor",
20043
+ botID: "bot-1",
20044
+ botName: "SelftestBot1",
20045
+ destinationID: "dest-1",
20046
+ destinationLabel: "Selftest Room",
20047
+ });
20048
+ const routeBot2 = normalizeRunnerRoute({
20049
+ name: "telegram-monitor-selftest-bot-2",
20050
+ projectID: selftestProjectID,
20051
+ provider: "telegram",
20052
+ role: "monitor",
20053
+ botID: "bot-2",
20054
+ botName: "SelftestBot2",
20055
+ destinationID: "dest-1",
20056
+ destinationLabel: "Selftest Room",
20057
+ });
20058
+ const singleLaunch = buildRunnerDetachedLaunchRecord(
20059
+ process.pid,
20060
+ [routeBot1],
20061
+ { tui: false },
20062
+ "runner start-detached",
20063
+ );
20064
+ const decision = classifyDetachedRunnerLaunchReuse(
20065
+ { launches: { [singleLaunch.launch_id]: singleLaunch } },
20066
+ [routeBot2],
20067
+ );
20068
+ push(
20069
+ "detached_runner_blocks_disjoint_same_target_parallel_launches",
20070
+ decision.kind === "conflict"
20071
+ && ensureArray(decision.overlapping_route_names).length === 0
20072
+ && String(decision.detail || "").includes("same project/provider/destination target"),
20073
+ `kind=${String(decision.kind || "")} overlap=${ensureArray(decision.overlapping_route_names).join(", ")} detail=${String(decision.detail || "")}`,
20074
+ );
20075
+ } catch (err) {
20076
+ push("detached_runner_blocks_disjoint_same_target_parallel_launches", false, String(err?.message || err));
20077
+ }
20078
+
20079
+ let detachedRunnerPosixTempDir = "";
20080
+ try {
20081
+ detachedRunnerPosixTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-detached-script-selftest-"));
19665
20082
  const scriptPath = path.join(detachedRunnerPosixTempDir, "start-runner.sh");
19666
20083
  const pidFilePath = path.join(detachedRunnerPosixTempDir, "runner.pid");
19667
20084
  buildRunnerDetachedPosixLaunchScript({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.292",
3
+ "version": "0.2.293",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [