metheus-governance-mcp-cli 0.2.292 → 0.2.294

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/cli.mjs CHANGED
@@ -151,6 +151,9 @@ import {
151
151
  selectPendingArchiveComments,
152
152
  printRunnerResult,
153
153
  } from "./lib/runner-helpers.mjs";
154
+ import {
155
+ normalizeRunnerRecentLocalInboundReceiptMap,
156
+ } from "./lib/runner-local-inbound-receipts.mjs";
154
157
  import {
155
158
  normalizeRunnerConversationDecisionBundle,
156
159
  validateRunnerConversationDecisionBundle,
@@ -1966,88 +1969,6 @@ function prefersRunnerStateRecord(candidate, current) {
1966
1969
  return false;
1967
1970
  }
1968
1971
 
1969
- function normalizeRunnerRecentLocalInboundReceiptRecord(rawReceipt, fallbackKey = "") {
1970
- const receipt = safeObject(rawReceipt);
1971
- const chatID = String(receipt.chat_id || receipt.chatID || "").trim();
1972
- const messageID = intFromRawAllowZero(receipt.message_id ?? receipt.messageID, 0);
1973
- const receiptKey = String(fallbackKey || `${chatID}:${messageID}`).trim();
1974
- if (!receiptKey || !chatID || !(messageID > 0)) {
1975
- return null;
1976
- }
1977
- const normalized = {
1978
- chat_id: chatID,
1979
- message_id: messageID,
1980
- receipt_origin: firstNonEmptyString([
1981
- receipt.receipt_origin,
1982
- receipt.receiptOrigin,
1983
- receipt.source_origin,
1984
- receipt.sourceOrigin,
1985
- "local_telegram_inbound",
1986
- ]),
1987
- receipt_route_key: firstNonEmptyString([
1988
- receipt.receipt_route_key,
1989
- receipt.receiptRouteKey,
1990
- receipt.source_route_key,
1991
- receipt.sourceRouteKey,
1992
- ]),
1993
- receipt_bot_username: normalizeTelegramMentionUsername(
1994
- firstNonEmptyString([
1995
- receipt.receipt_bot_username,
1996
- receipt.receiptBotUsername,
1997
- receipt.source_bot_username,
1998
- receipt.sourceBotUsername,
1999
- ]),
2000
- ),
2001
- kind: firstNonEmptyString([receipt.kind, "telegram_message"]),
2002
- sender: firstNonEmptyString([receipt.sender, receipt.from_name, receipt.fromName]),
2003
- sender_username: firstNonEmptyString([
2004
- receipt.sender_username,
2005
- receipt.senderUsername,
2006
- receipt.from_username,
2007
- receipt.fromUsername,
2008
- ]),
2009
- sender_is_bot: Boolean(receipt.sender_is_bot ?? receipt.senderIsBot ?? false),
2010
- body: firstNonEmptyString([receipt.body, receipt.text]),
2011
- occurred_at: firstNonEmptyString([receipt.occurred_at, receipt.occurredAt]),
2012
- received_at: firstNonEmptyString([receipt.received_at, receipt.receivedAt, new Date().toISOString()]),
2013
- };
2014
- const senderID = firstNonEmptyString([receipt.sender_id, receipt.senderID, receipt.from_id, receipt.fromID]);
2015
- if (senderID) {
2016
- normalized.sender_id = senderID;
2017
- }
2018
- const updateID = intFromRawAllowZero(receipt.update_id ?? receipt.updateID, 0);
2019
- if (updateID > 0) {
2020
- normalized.update_id = updateID;
2021
- }
2022
- const messageThreadID = intFromRawAllowZero(receipt.message_thread_id ?? receipt.messageThreadID, 0);
2023
- if (messageThreadID > 0) {
2024
- normalized.message_thread_id = messageThreadID;
2025
- }
2026
- const replyToMessageID = intFromRawAllowZero(receipt.reply_to_message_id ?? receipt.replyToMessageID, 0);
2027
- if (replyToMessageID > 0) {
2028
- normalized.reply_to_message_id = replyToMessageID;
2029
- }
2030
- const chatType = firstNonEmptyString([receipt.chat_type, receipt.chatType]);
2031
- if (chatType) {
2032
- normalized.chat_type = chatType;
2033
- }
2034
- const chatTitle = firstNonEmptyString([receipt.chat_title, receipt.chatTitle]);
2035
- if (chatTitle) {
2036
- normalized.chat_title = chatTitle;
2037
- }
2038
- return [receiptKey, normalized];
2039
- }
2040
-
2041
- function normalizeRunnerRecentLocalInboundReceiptMap(rawReceipts) {
2042
- const normalizedEntries = [];
2043
- for (const [key, value] of Object.entries(safeObject(rawReceipts))) {
2044
- const normalizedEntry = normalizeRunnerRecentLocalInboundReceiptRecord(value, key);
2045
- if (!normalizedEntry) continue;
2046
- normalizedEntries.push(normalizedEntry);
2047
- }
2048
- return Object.fromEntries(normalizedEntries);
2049
- }
2050
-
2051
1972
  function mergeRunnerStateRecords(preferred, fallback) {
2052
1973
  const primary = safeObject(preferred);
2053
1974
  const secondary = safeObject(fallback);
@@ -7501,26 +7422,31 @@ async function syncRunnerRequestLedgerForProjectToServer({ normalizedRoute, runt
7501
7422
  }
7502
7423
  }
7503
7424
 
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
- }
7425
+ function normalizeRunnerProcessLaunchEntry(rawEntry) {
7426
+ const entry = safeObject(rawEntry);
7427
+ const routeKeys = ensureArray(entry.route_keys || entry.routeKeys).map((item) => String(item || "").trim()).filter(Boolean);
7428
+ return cleanupRunnerStateRecord({
7429
+ launch_id: String(entry.launch_id || entry.launchID || "").trim(),
7430
+ pid: intFromRawAllowZero(entry.pid, 0) || undefined,
7431
+ started_at: String(entry.started_at || entry.startedAt || "").trim(),
7432
+ command: String(entry.command || "").trim(),
7433
+ cli_path: String(entry.cli_path || entry.cliPath || "").trim(),
7434
+ working_directory: sanitizeWorkspaceCandidate(entry.working_directory || entry.workingDirectory) || String(entry.working_directory || entry.workingDirectory || "").trim(),
7435
+ route_set_signature: String(entry.route_set_signature || entry.routeSetSignature || "").trim(),
7436
+ route_keys: routeKeys,
7437
+ route_names: ensureArray(entry.route_names || entry.routeNames).map((item) => String(item || "").trim()).filter(Boolean),
7438
+ project_ids: ensureArray(entry.project_ids || entry.projectIds).map((item) => String(item || "").trim()).filter(Boolean),
7439
+ provider: normalizeBotProvider(entry.provider),
7440
+ destination_labels: ensureArray(entry.destination_labels || entry.destinationLabels).map((item) => String(item || "").trim()).filter(Boolean),
7441
+ scheduling_target_keys: uniqueOrderedStrings([
7442
+ ...ensureArray(entry.scheduling_target_keys || entry.schedulingTargetKeys).map((item) => String(item || "").trim()),
7443
+ ...routeKeys.map((routeKey) => runnerSchedulingTargetKeyFromRouteKey(routeKey)).filter(Boolean),
7444
+ ], (value) => String(value || "").trim()),
7445
+ log_file: String(entry.log_file || entry.logFile || "").trim(),
7446
+ created_by_pid: intFromRawAllowZero(entry.created_by_pid || entry.createdByPid, 0) || undefined,
7447
+ source_command: String(entry.source_command || entry.sourceCommand || "").trim(),
7448
+ });
7449
+ }
7524
7450
 
7525
7451
  function normalizeBotRunnerProcessRegistryContents(rawValue) {
7526
7452
  const launchesInput = safeObject(rawValue).launches;
@@ -12324,6 +12250,9 @@ function buildRunnerProjectTUISnapshot({
12324
12250
  const summaryPayload = safeObject(normalizedAuditResult.summaryPayload);
12325
12251
  const matchingRoutes = ensureArray(normalizedAuditResult.matchingRoutes)
12326
12252
  .map((route) => normalizeRunnerRoute(route));
12253
+ const matchingRouteNames = matchingRoutes
12254
+ .map((route) => String(route.name || runnerRouteKey(route)).trim())
12255
+ .filter(Boolean);
12327
12256
  const registry = loadBotRunnerProcessRegistry({ persistIfNeeded: true });
12328
12257
  const existingLaunch = matchingRoutes.length
12329
12258
  ? findExistingDetachedRunnerLaunch(registry, matchingRoutes)
@@ -12334,22 +12263,60 @@ function buildRunnerProjectTUISnapshot({
12334
12263
  alive: true,
12335
12264
  }
12336
12265
  : null;
12266
+ const destinationID = String(summaryPayload.destination_id || "").trim();
12267
+ const destinationLabel = String(summaryPayload.destination_label || "").trim();
12268
+ const projectID = String(summaryPayload.project_id || "").trim();
12269
+ const relatedLaunches = Object.values(safeObject(registry).launches || {})
12270
+ .map((entry) => normalizeRunnerProcessLaunchEntry(entry))
12271
+ .filter((entry) => isProcessAlive(entry.pid))
12272
+ .filter((entry) => {
12273
+ const sameProject = ensureArray(entry.project_ids)
12274
+ .map((item) => String(item || "").trim())
12275
+ .includes(projectID);
12276
+ if (!sameProject) return false;
12277
+ const sameDestinationLabel = destinationLabel
12278
+ ? ensureArray(entry.destination_labels)
12279
+ .map((item) => String(item || "").trim().toLowerCase())
12280
+ .includes(destinationLabel.toLowerCase())
12281
+ : false;
12282
+ const sameDestinationID = destinationID
12283
+ ? ensureArray(entry.route_keys)
12284
+ .map((item) => String(item || "").trim())
12285
+ .some((item) => item.includes(`::${destinationID}`))
12286
+ : false;
12287
+ return sameDestinationLabel || sameDestinationID;
12288
+ })
12289
+ .map((entry) => {
12290
+ const routeNames = ensureArray(entry.route_names)
12291
+ .map((item) => String(item || "").trim())
12292
+ .filter(Boolean);
12293
+ const sharedRouteNames = routeNames.filter((routeName) => matchingRouteNames.includes(routeName));
12294
+ return {
12295
+ launch_id: String(entry.launch_id || "").trim(),
12296
+ pid: intFromRawAllowZero(entry.pid, 0),
12297
+ route_names: routeNames,
12298
+ shared_route_names: sharedRouteNames,
12299
+ source_command: String(entry.source_command || "").trim(),
12300
+ same_selection: Boolean(launch && String(launch.launch_id || "").trim() === String(entry.launch_id || "").trim()),
12301
+ };
12302
+ });
12337
12303
  const logFilePath = String(launch?.log_file || "").trim();
12338
12304
  const logLines = readTextFileTailLines(logFilePath, 12);
12339
12305
  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(),
12306
+ project_id: projectID,
12307
+ destination_label: destinationLabel,
12308
+ destination_id: destinationID,
12343
12309
  room_probe_ok: Boolean(summaryPayload.room_probe_ok),
12344
12310
  route_apply_requested: Boolean(summaryPayload.route_apply_requested),
12345
12311
  route_apply_changed: Boolean(summaryPayload.route_apply_changed),
12346
12312
  route_config_file: String(summaryPayload.route_config_file || "").trim(),
12347
12313
  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),
12314
+ matching_routes: matchingRouteNames,
12349
12315
  next_steps: ensureArray(summaryPayload.next_steps).map((item) => String(item || "").trim()).filter(Boolean),
12350
12316
  warning: String(summaryPayload.warning || "").trim(),
12351
12317
  error: String(summaryPayload.error || errorMessage || "").trim(),
12352
12318
  launch,
12319
+ related_launches: relatedLaunches,
12353
12320
  auto_start: autoStart,
12354
12321
  status_message: String(statusMessage || "").trim(),
12355
12322
  last_audit_at: String(lastAuditAt || "").trim() || new Date().toISOString(),
@@ -12360,6 +12327,25 @@ function buildRunnerProjectTUISnapshot({
12360
12327
  };
12361
12328
  }
12362
12329
 
12330
+ function buildRunnerProjectTUIKeyLine(label, value, labelWidth = 14) {
12331
+ return `${bootstrapPadRight(`${label}:`, labelWidth)} ${String(value || "-").trim() || "-"}`;
12332
+ }
12333
+
12334
+ function buildRunnerProjectTUIList(items = [], {
12335
+ emptyText = "-",
12336
+ bullet = "- ",
12337
+ indent = " ",
12338
+ width = 140,
12339
+ } = {}) {
12340
+ const normalizedItems = ensureArray(items)
12341
+ .map((item) => String(item || "").trim())
12342
+ .filter(Boolean);
12343
+ if (!normalizedItems.length) {
12344
+ return [`${indent}${emptyText}`];
12345
+ }
12346
+ return normalizedItems.map((item) => `${indent}${bullet}${truncateRunnerTUIText(item, width)}`);
12347
+ }
12348
+
12363
12349
  function buildRunnerProjectTUIFrame({
12364
12350
  snapshot,
12365
12351
  now = Date.now(),
@@ -12368,41 +12354,105 @@ function buildRunnerProjectTUIFrame({
12368
12354
  }) {
12369
12355
  const normalizedSnapshot = safeObject(snapshot);
12370
12356
  const alive = Boolean(safeObject(normalizedSnapshot.launch).alive);
12371
- const statusLabel = alive ? "RUNNING" : "STOPPED";
12357
+ const statusLabel = alive ? "[ RUNNING ]" : "[ STOPPED ]";
12372
12358
  const statusDetail = alive
12373
12359
  ? `launch=${String(safeObject(normalizedSnapshot.launch).launch_id || "").trim() || "-"} pid=${String(safeObject(normalizedSnapshot.launch).pid || "").trim() || "-"}`
12374
12360
  : "no detached runner is active for this selection";
12361
+ const enabledRoutes = ensureArray(normalizedSnapshot.enabled_routes);
12362
+ const matchingRoutes = ensureArray(normalizedSnapshot.matching_routes);
12363
+ const relatedLaunches = ensureArray(normalizedSnapshot.related_launches);
12364
+ const selectedLaunch = safeObject(normalizedSnapshot.launch);
12365
+ const otherLaunches = relatedLaunches.filter((launch) => !safeObject(launch).same_selection);
12366
+ const overlappingLaunches = otherLaunches.filter((launch) => ensureArray(safeObject(launch).shared_route_names).length > 0);
12367
+ const alertLines = [];
12368
+ if (String(normalizedSnapshot.warning || "").trim()) {
12369
+ alertLines.push(`warning: ${String(normalizedSnapshot.warning || "").trim()}`);
12370
+ }
12371
+ if (String(normalizedSnapshot.error || "").trim()) {
12372
+ alertLines.push(`error: ${String(normalizedSnapshot.error || "").trim()}`);
12373
+ }
12374
+ if (overlappingLaunches.length) {
12375
+ alertLines.push(`overlap: ${overlappingLaunches.length} other detached launch(es) share at least one route in this room`);
12376
+ } else if (otherLaunches.length) {
12377
+ alertLines.push(`notice: ${otherLaunches.length} other detached launch(es) are active for this project/destination`);
12378
+ }
12379
+ if (!alive) {
12380
+ alertLines.push("runner is not active; press s to start or reuse a detached runner");
12381
+ }
12382
+ const stateHint = alive
12383
+ ? "This screen can be closed with q. The detached runner keeps running."
12384
+ : "No detached runner is attached to this selection yet.";
12375
12385
  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() || "-"}`,
12386
+ "+======================================================================================+",
12387
+ "| METHEUS PROJECT RUNNER |",
12388
+ "| Detached runner dashboard |",
12389
+ "+======================================================================================+",
12390
+ "",
12391
+ "[ STATUS ]",
12392
+ ` ${statusLabel}${stopping ? " (closing TUI)" : ""}`,
12393
+ ` ${statusDetail}`,
12394
+ ` ${stateHint}`,
12395
+ "",
12396
+ "[ PROJECT ]",
12397
+ ` ${buildRunnerProjectTUIKeyLine("Project", normalizedSnapshot.project_id)}`,
12398
+ ` ${buildRunnerProjectTUIKeyLine("Destination", String(normalizedSnapshot.destination_label || normalizedSnapshot.destination_id || "").trim() || "-")}`,
12399
+ ` ${buildRunnerProjectTUIKeyLine("Updated", new Date(now).toLocaleString("sv-SE", { hour12: false }))}`,
12400
+ ` ${buildRunnerProjectTUIKeyLine("Last Audit", String(normalizedSnapshot.last_audit_at || "").trim() || "-")}`,
12401
+ "",
12402
+ "[ RUNNER ]",
12403
+ ` ${buildRunnerProjectTUIKeyLine("Auto Start", normalizedSnapshot.auto_start ? "enabled" : "prepare-only")}`,
12404
+ ` ${buildRunnerProjectTUIKeyLine("Room Probe", normalizedSnapshot.room_probe_ok ? "ok" : "failed")}`,
12405
+ ` ${buildRunnerProjectTUIKeyLine("Apply Result", normalizedSnapshot.route_apply_changed ? "route file changed" : "no route file change")}`,
12406
+ ` ${buildRunnerProjectTUIKeyLine("Active Launches", `${relatedLaunches.length}`)}`,
12407
+ ` ${buildRunnerProjectTUIKeyLine("This Launch", alive ? String(selectedLaunch.launch_id || "-").trim() || "-" : "-")}`,
12408
+ ` ${buildRunnerProjectTUIKeyLine("PID", alive ? String(selectedLaunch.pid || "-").trim() || "-" : "-")}`,
12409
+ ` ${buildRunnerProjectTUIKeyLine("Log File", String(normalizedSnapshot.log_file || "").trim() || "-")}`,
12410
+ "",
12411
+ "[ ROUTES ]",
12412
+ ` ${buildRunnerProjectTUIKeyLine("Enabled", `${enabledRoutes.length}`)}`,
12413
+ ` ${buildRunnerProjectTUIKeyLine("Matched", `${matchingRoutes.length}`)}`,
12414
+ ...buildRunnerProjectTUIList(matchingRoutes.length ? matchingRoutes : enabledRoutes, {
12415
+ emptyText: "no routes selected",
12416
+ bullet: "- ",
12417
+ indent: " ",
12418
+ width: 96,
12419
+ }),
12394
12420
  "",
12395
- "Controls: [s] start/reuse [x] stop [r] refresh [a] re-audit [q] quit",
12421
+ "[ ALERTS ]",
12422
+ ...buildRunnerProjectTUIList(alertLines, {
12423
+ emptyText: "none",
12424
+ bullet: "- ",
12425
+ indent: " ",
12426
+ width: 96,
12427
+ }),
12428
+ "",
12429
+ "[ CONTROLS ]",
12430
+ " s = start/reuse detached runner",
12431
+ " x = stop detached runner",
12432
+ " r = refresh status",
12433
+ " a = re-audit route selection",
12434
+ " q = quit this dashboard",
12396
12435
  "",
12397
- "Recent Log Tail:",
12398
- ...ensureArray(normalizedSnapshot.log_lines).map((line) => ` ${truncateRunnerTUIText(line, 140)}`),
12436
+ "[ RECENT LOG TAIL ]",
12437
+ ...ensureArray(normalizedSnapshot.log_lines).map((line) => ` ${truncateRunnerTUIText(line, 108)}`),
12399
12438
  ];
12439
+ if (otherLaunches.length) {
12440
+ lines.push("");
12441
+ lines.push("[ OTHER ACTIVE LAUNCHES ]");
12442
+ for (const launch of otherLaunches) {
12443
+ const currentLaunch = safeObject(launch);
12444
+ const sharedRoutes = ensureArray(currentLaunch.shared_route_names).join(", ") || "-";
12445
+ lines.push(` - ${String(currentLaunch.launch_id || "-").trim() || "-"} pid=${String(currentLaunch.pid || "-").trim() || "-"} source=${String(currentLaunch.source_command || "-").trim() || "-"}`);
12446
+ lines.push(` shared routes: ${truncateRunnerTUIText(sharedRoutes, 96)}`);
12447
+ lines.push(` all routes: ${truncateRunnerTUIText(ensureArray(currentLaunch.route_names).join(", ") || "-", 96)}`);
12448
+ }
12449
+ }
12400
12450
  const frame = lines.join("\n");
12401
12451
  if (!useColor) {
12402
12452
  return frame;
12403
12453
  }
12404
12454
  const statusColor = alive ? "\u001b[32m" : "\u001b[33m";
12405
- return frame.replace(`Status: ${statusLabel}`, `Status: ${statusColor}${statusLabel}\u001b[0m`);
12455
+ return frame.replace(statusLabel, `${statusColor}${statusLabel}\u001b[0m`);
12406
12456
  }
12407
12457
 
12408
12458
  async function runRunnerProjectTUI(flags) {
@@ -13083,13 +13133,121 @@ function serializeCLIFlags(flags, options = {}) {
13083
13133
  return args;
13084
13134
  }
13085
13135
 
13086
- function runnerDetachedRouteSetSignature(routes) {
13087
- return ensureArray(routes)
13088
- .map((route) => runnerRouteKey(normalizeRunnerRoute(route)))
13089
- .filter(Boolean)
13090
- .sort()
13091
- .join("||");
13092
- }
13136
+ function runnerDetachedRouteSetSignature(routes) {
13137
+ return ensureArray(routes)
13138
+ .map((route) => runnerRouteKey(normalizeRunnerRoute(route)))
13139
+ .filter(Boolean)
13140
+ .sort()
13141
+ .join("||");
13142
+ }
13143
+
13144
+ function runnerSchedulingTargetKeyFromRouteKey(routeKey) {
13145
+ const parts = String(routeKey || "").trim().split("::").filter(Boolean);
13146
+ if (parts.length < 6) {
13147
+ return "";
13148
+ }
13149
+ return [
13150
+ String(parts[parts.length - 5] || "").trim(),
13151
+ String(parts[parts.length - 4] || "").trim(),
13152
+ String(parts[parts.length - 1] || "").trim(),
13153
+ ].join("::");
13154
+ }
13155
+
13156
+ function collectRunnerDetachedLaunchSchedulingTargetKeys(entryRaw) {
13157
+ const entry = normalizeRunnerProcessLaunchEntry(entryRaw);
13158
+ const explicitKeys = uniqueOrderedStrings(
13159
+ ensureArray(entry.scheduling_target_keys || entry.schedulingTargetKeys),
13160
+ (value) => String(value || "").trim(),
13161
+ ).filter(Boolean);
13162
+ if (explicitKeys.length > 0) {
13163
+ return explicitKeys;
13164
+ }
13165
+ const routeKeyDerived = uniqueOrderedStrings(
13166
+ ensureArray(entry.route_keys).map((value) => runnerSchedulingTargetKeyFromRouteKey(value)).filter(Boolean),
13167
+ (value) => String(value || "").trim(),
13168
+ ).filter(Boolean);
13169
+ if (routeKeyDerived.length > 0) {
13170
+ return routeKeyDerived;
13171
+ }
13172
+ const provider = normalizeBotProvider(entry.provider);
13173
+ const projectIDs = ensureArray(entry.project_ids).map((value) => String(value || "").trim()).filter(Boolean);
13174
+ const destinations = ensureArray(entry.destination_labels).map((value) => String(value || "").trim()).filter(Boolean);
13175
+ return uniqueOrderedStrings(
13176
+ projectIDs.flatMap((projectID) => destinations.map((destination) => [projectID, provider, destination].join("::"))),
13177
+ (value) => String(value || "").trim(),
13178
+ ).filter(Boolean);
13179
+ }
13180
+
13181
+ function describeDetachedRunnerLaunch(entryRaw) {
13182
+ const entry = normalizeRunnerProcessLaunchEntry(entryRaw);
13183
+ return `launch_id=${entry.launch_id || "-"} pid=${entry.pid || "-"} routes=${ensureArray(entry.route_names).join(", ") || "-"}`;
13184
+ }
13185
+
13186
+ function classifyDetachedRunnerLaunchReuse(registry, routes) {
13187
+ const normalizedRoutes = ensureArray(routes).map((route) => normalizeRunnerRoute(route));
13188
+ const targetSignature = runnerDetachedRouteSetSignature(normalizedRoutes);
13189
+ const targetRouteKeys = normalizedRoutes.map((route) => runnerRouteKey(route)).filter(Boolean);
13190
+ const targetRouteKeySet = new Set(targetRouteKeys);
13191
+ const targetSchedulingTargetKeys = uniqueOrderedStrings(
13192
+ normalizedRoutes.map((route) => runnerRouteSchedulingGroupKey(route)).filter(Boolean),
13193
+ (value) => String(value || "").trim(),
13194
+ ).filter(Boolean);
13195
+ const aliveLaunches = Object.values(safeObject(registry).launches || {})
13196
+ .map((entryRaw) => normalizeRunnerProcessLaunchEntry(entryRaw))
13197
+ .filter((entry) => entry.launch_id && entry.pid && isProcessAlive(entry.pid));
13198
+ const exact = aliveLaunches.find((entry) => entry.route_set_signature === targetSignature) || null;
13199
+ if (exact) {
13200
+ return {
13201
+ kind: "reuse",
13202
+ reason: "exact",
13203
+ launch: exact,
13204
+ };
13205
+ }
13206
+ if (!targetSchedulingTargetKeys.length) {
13207
+ return {
13208
+ kind: "none",
13209
+ };
13210
+ }
13211
+ const sameTargetLaunches = aliveLaunches.filter((entry) => {
13212
+ const entryTargetKeys = collectRunnerDetachedLaunchSchedulingTargetKeys(entry);
13213
+ return entryTargetKeys.some((key) => targetSchedulingTargetKeys.includes(key));
13214
+ });
13215
+ if (!sameTargetLaunches.length) {
13216
+ return {
13217
+ kind: "none",
13218
+ };
13219
+ }
13220
+ const supersets = sameTargetLaunches.filter((entry) => targetRouteKeys.every((routeKey) => ensureArray(entry.route_keys).includes(routeKey)));
13221
+ if (supersets.length === 1) {
13222
+ return {
13223
+ kind: "reuse",
13224
+ reason: "superset",
13225
+ launch: supersets[0],
13226
+ };
13227
+ }
13228
+ const conflictingLaunches = supersets.length > 1 ? supersets : sameTargetLaunches;
13229
+ const overlapRouteNames = Array.from(
13230
+ new Set(
13231
+ conflictingLaunches.flatMap((entry) => ensureArray(entry.route_keys))
13232
+ .filter((routeKey) => targetRouteKeySet.has(routeKey))
13233
+ .map((routeKey) => {
13234
+ const matchedRoute = normalizedRoutes.find((route) => runnerRouteKey(route) === routeKey);
13235
+ return matchedRoute?.name || routeKey;
13236
+ })
13237
+ .filter(Boolean),
13238
+ ),
13239
+ );
13240
+ const detail = supersets.length > 1
13241
+ ? `multiple detached runners already cover the requested route set for the same project/provider/destination target (${conflictingLaunches.map((entry) => describeDetachedRunnerLaunch(entry)).join(" | ")})`
13242
+ : `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`;
13243
+ return {
13244
+ kind: "conflict",
13245
+ detail,
13246
+ launch: conflictingLaunches.length === 1 ? conflictingLaunches[0] : null,
13247
+ conflicting_launches: conflictingLaunches,
13248
+ overlapping_route_names: overlapRouteNames,
13249
+ };
13250
+ }
13093
13251
 
13094
13252
  function buildRunnerDetachedPosixLaunchScript({
13095
13253
  scriptPath,
@@ -13149,25 +13307,28 @@ function buildRunnerDetachedLaunchRecord(childPID, routes, flags, sourceCommand,
13149
13307
  cli_path: cliPath,
13150
13308
  working_directory: path.dirname(cliPath),
13151
13309
  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
- }
13310
+ route_keys: normalizedRoutes.map((route) => runnerRouteKey(route)),
13311
+ route_names: normalizedRoutes.map((route) => route.name || runnerRouteKey(route)),
13312
+ project_ids: Array.from(new Set(normalizedRoutes.map((route) => String(route.projectID || "").trim()).filter(Boolean))),
13313
+ provider: firstNonEmptyString(normalizedRoutes.map((route) => route.provider)),
13314
+ destination_labels: Array.from(new Set(normalizedRoutes.map((route) => String(route.destinationLabel || route.destinationID || "").trim()).filter(Boolean))),
13315
+ scheduling_target_keys: uniqueOrderedStrings(
13316
+ normalizedRoutes.map((route) => runnerRouteSchedulingGroupKey(route)).filter(Boolean),
13317
+ (value) => String(value || "").trim(),
13318
+ ),
13319
+ log_file: String(logFilePath || "").trim(),
13320
+ created_by_pid: process.pid,
13321
+ source_command: sourceCommand,
13322
+ });
13323
+ }
13162
13324
 
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
- }
13325
+ function findExistingDetachedRunnerLaunch(registry, routes) {
13326
+ const decision = classifyDetachedRunnerLaunchReuse(registry, routes);
13327
+ if (decision.kind === "reuse" && decision.launch) {
13328
+ return decision.launch;
13329
+ }
13330
+ return null;
13331
+ }
13171
13332
 
13172
13333
  async function launchDetachedRunnerProcess(flags, routes, sourceCommand) {
13173
13334
  const cliPath = fileURLToPath(import.meta.url);
@@ -13359,13 +13520,15 @@ async function runRunnerStop(flags) {
13359
13520
  async function runRunnerStartDetachedResolvedRoutes(routes, flags, sourceCommand = "runner start-detached", options = {}) {
13360
13521
  const silent = boolFromRaw(safeObject(options).silent, false);
13361
13522
  const registry = loadBotRunnerProcessRegistry({ persistIfNeeded: true });
13362
- const existing = findExistingDetachedRunnerLaunch(registry, routes);
13363
- if (existing) {
13523
+ const reuseDecision = classifyDetachedRunnerLaunchReuse(registry, routes);
13524
+ if (reuseDecision.kind === "reuse" && reuseDecision.launch) {
13525
+ const existing = reuseDecision.launch;
13364
13526
  const payload = {
13365
13527
  ok: true,
13366
- already_running: true,
13367
- registry_file: registry.filePath,
13368
- launch: {
13528
+ already_running: true,
13529
+ reuse_reason: reuseDecision.reason,
13530
+ registry_file: registry.filePath,
13531
+ launch: {
13369
13532
  ...existing,
13370
13533
  alive: true,
13371
13534
  },
@@ -13375,15 +13538,24 @@ async function runRunnerStartDetachedResolvedRoutes(routes, flags, sourceCommand
13375
13538
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
13376
13539
  return payload;
13377
13540
  }
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`);
13541
+ 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
13542
  }
13380
13543
  return payload;
13381
13544
  }
13545
+ if (reuseDecision.kind === "conflict") {
13546
+ const overlappingRouteNames = ensureArray(reuseDecision.overlapping_route_names)
13547
+ .map((item) => String(item || "").trim())
13548
+ .filter(Boolean);
13549
+ const overlapSuffix = overlappingRouteNames.length
13550
+ ? ` overlapping_routes=${overlappingRouteNames.join(", ")}`
13551
+ : "";
13552
+ throw new Error(`${String(reuseDecision.detail || "detached runner conflict").trim()}${overlapSuffix}`);
13553
+ }
13382
13554
  const launch = await launchDetachedRunnerProcess(flags, routes, sourceCommand);
13383
13555
  const nextLaunches = {
13384
13556
  ...safeObject(registry).launches,
13385
13557
  [launch.launch_id]: launch,
13386
- };
13558
+ };
13387
13559
  saveBotRunnerProcessRegistry({ launches: nextLaunches });
13388
13560
  const payload = {
13389
13561
  ok: true,
@@ -19658,10 +19830,176 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
19658
19830
  } catch (err) {
19659
19831
  push("detached_runner_launch_record_persists_log_file", false, String(err?.message || err));
19660
19832
  }
19661
-
19662
- let detachedRunnerPosixTempDir = "";
19663
- try {
19664
- detachedRunnerPosixTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-detached-script-selftest-"));
19833
+
19834
+ try {
19835
+ const routeBot1 = normalizeRunnerRoute({
19836
+ name: "telegram-monitor-selftest-bot-1",
19837
+ projectID: selftestProjectID,
19838
+ provider: "telegram",
19839
+ role: "monitor",
19840
+ botID: "bot-1",
19841
+ botName: "SelftestBot1",
19842
+ destinationID: "dest-1",
19843
+ destinationLabel: "Selftest Room",
19844
+ });
19845
+ const routeBot2 = normalizeRunnerRoute({
19846
+ name: "telegram-monitor-selftest-bot-2",
19847
+ projectID: selftestProjectID,
19848
+ provider: "telegram",
19849
+ role: "monitor",
19850
+ botID: "bot-2",
19851
+ botName: "SelftestBot2",
19852
+ destinationID: "dest-1",
19853
+ destinationLabel: "Selftest Room",
19854
+ });
19855
+ const supersetLaunch = buildRunnerDetachedLaunchRecord(
19856
+ process.pid,
19857
+ [routeBot1, routeBot2],
19858
+ { tui: false },
19859
+ "runner start-detached",
19860
+ );
19861
+ const decision = classifyDetachedRunnerLaunchReuse(
19862
+ { launches: { [supersetLaunch.launch_id]: supersetLaunch } },
19863
+ [routeBot2],
19864
+ );
19865
+ push(
19866
+ "detached_runner_reuses_existing_superset_launch_for_same_target",
19867
+ decision.kind === "reuse"
19868
+ && decision.reason === "superset"
19869
+ && String(safeObject(decision.launch).launch_id || "").trim() === supersetLaunch.launch_id,
19870
+ `kind=${String(decision.kind || "")} reason=${String(decision.reason || "")} launch=${String(safeObject(decision.launch).launch_id || "")}`,
19871
+ );
19872
+ } catch (err) {
19873
+ push("detached_runner_reuses_existing_superset_launch_for_same_target", false, String(err?.message || err));
19874
+ }
19875
+
19876
+ try {
19877
+ const routeBot1 = normalizeRunnerRoute({
19878
+ name: "telegram-monitor-selftest-bot-1",
19879
+ projectID: selftestProjectID,
19880
+ provider: "telegram",
19881
+ role: "monitor",
19882
+ botID: "bot-1",
19883
+ botName: "SelftestBot1",
19884
+ destinationID: "dest-1",
19885
+ destinationLabel: "Selftest Room",
19886
+ });
19887
+ const routeBot2 = normalizeRunnerRoute({
19888
+ name: "telegram-monitor-selftest-bot-2",
19889
+ projectID: selftestProjectID,
19890
+ provider: "telegram",
19891
+ role: "monitor",
19892
+ botID: "bot-2",
19893
+ botName: "SelftestBot2",
19894
+ destinationID: "dest-1",
19895
+ destinationLabel: "Selftest Room",
19896
+ });
19897
+ const supersetLaunch = buildRunnerDetachedLaunchRecord(
19898
+ process.pid,
19899
+ [routeBot1, routeBot2],
19900
+ { tui: false },
19901
+ "runner project tui",
19902
+ );
19903
+ const existing = findExistingDetachedRunnerLaunch(
19904
+ { launches: { [supersetLaunch.launch_id]: supersetLaunch } },
19905
+ [routeBot2],
19906
+ );
19907
+ push(
19908
+ "detached_runner_existing_launch_lookup_reuses_superset_for_tui_snapshot",
19909
+ String(safeObject(existing).launch_id || "").trim() === supersetLaunch.launch_id,
19910
+ `launch=${String(safeObject(existing).launch_id || "").trim() || "(none)"}`,
19911
+ );
19912
+ } catch (err) {
19913
+ push("detached_runner_existing_launch_lookup_reuses_superset_for_tui_snapshot", false, String(err?.message || err));
19914
+ }
19915
+
19916
+ try {
19917
+ const routeBot1 = normalizeRunnerRoute({
19918
+ name: "telegram-monitor-selftest-bot-1",
19919
+ projectID: selftestProjectID,
19920
+ provider: "telegram",
19921
+ role: "monitor",
19922
+ botID: "bot-1",
19923
+ botName: "SelftestBot1",
19924
+ destinationID: "dest-1",
19925
+ destinationLabel: "Selftest Room",
19926
+ });
19927
+ const routeBot2 = normalizeRunnerRoute({
19928
+ name: "telegram-monitor-selftest-bot-2",
19929
+ projectID: selftestProjectID,
19930
+ provider: "telegram",
19931
+ role: "monitor",
19932
+ botID: "bot-2",
19933
+ botName: "SelftestBot2",
19934
+ destinationID: "dest-1",
19935
+ destinationLabel: "Selftest Room",
19936
+ });
19937
+ const subsetLaunch = buildRunnerDetachedLaunchRecord(
19938
+ process.pid,
19939
+ [routeBot2],
19940
+ { tui: false },
19941
+ "runner start-detached",
19942
+ );
19943
+ const decision = classifyDetachedRunnerLaunchReuse(
19944
+ { launches: { [subsetLaunch.launch_id]: subsetLaunch } },
19945
+ [routeBot1, routeBot2],
19946
+ );
19947
+ push(
19948
+ "detached_runner_blocks_partial_overlap_for_same_target",
19949
+ decision.kind === "conflict"
19950
+ && ensureArray(decision.overlapping_route_names).includes("telegram-monitor-selftest-bot-2")
19951
+ && String(decision.detail || "").includes("runner project tui"),
19952
+ `kind=${String(decision.kind || "")} overlap=${ensureArray(decision.overlapping_route_names).join(", ")} detail=${String(decision.detail || "")}`,
19953
+ );
19954
+ } catch (err) {
19955
+ push("detached_runner_blocks_partial_overlap_for_same_target", false, String(err?.message || err));
19956
+ }
19957
+
19958
+ try {
19959
+ const routeBot1 = normalizeRunnerRoute({
19960
+ name: "telegram-monitor-selftest-bot-1",
19961
+ projectID: selftestProjectID,
19962
+ provider: "telegram",
19963
+ role: "monitor",
19964
+ botID: "bot-1",
19965
+ botName: "SelftestBot1",
19966
+ destinationID: "dest-1",
19967
+ destinationLabel: "Selftest Room",
19968
+ });
19969
+ const routeBot2 = normalizeRunnerRoute({
19970
+ name: "telegram-monitor-selftest-bot-2",
19971
+ projectID: selftestProjectID,
19972
+ provider: "telegram",
19973
+ role: "monitor",
19974
+ botID: "bot-2",
19975
+ botName: "SelftestBot2",
19976
+ destinationID: "dest-1",
19977
+ destinationLabel: "Selftest Room",
19978
+ });
19979
+ const singleLaunch = buildRunnerDetachedLaunchRecord(
19980
+ process.pid,
19981
+ [routeBot1],
19982
+ { tui: false },
19983
+ "runner start-detached",
19984
+ );
19985
+ const decision = classifyDetachedRunnerLaunchReuse(
19986
+ { launches: { [singleLaunch.launch_id]: singleLaunch } },
19987
+ [routeBot2],
19988
+ );
19989
+ push(
19990
+ "detached_runner_blocks_disjoint_same_target_parallel_launches",
19991
+ decision.kind === "conflict"
19992
+ && ensureArray(decision.overlapping_route_names).length === 0
19993
+ && String(decision.detail || "").includes("same project/provider/destination target"),
19994
+ `kind=${String(decision.kind || "")} overlap=${ensureArray(decision.overlapping_route_names).join(", ")} detail=${String(decision.detail || "")}`,
19995
+ );
19996
+ } catch (err) {
19997
+ push("detached_runner_blocks_disjoint_same_target_parallel_launches", false, String(err?.message || err));
19998
+ }
19999
+
20000
+ let detachedRunnerPosixTempDir = "";
20001
+ try {
20002
+ detachedRunnerPosixTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-detached-script-selftest-"));
19665
20003
  const scriptPath = path.join(detachedRunnerPosixTempDir, "start-runner.sh");
19666
20004
  const pidFilePath = path.join(detachedRunnerPosixTempDir, "runner.pid");
19667
20005
  buildRunnerDetachedPosixLaunchScript({