ylib-syim 0.0.14 → 0.0.16

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/bridges/main.ts CHANGED
@@ -41,6 +41,58 @@ const runtimeErrorLogFallbackLines = Math.max(
41
41
  String(RUNTIME_ERROR_LOG_FALLBACK_LINES_DEFAULT),
42
42
  ) || RUNTIME_ERROR_LOG_FALLBACK_LINES_DEFAULT,
43
43
  );
44
+ const RUNTIME_LOG_DOWNLOAD_MAX_BYTES_DEFAULT = 2 * 1024 * 1024;
45
+ const runtimeLogDownloadMaxBytes = Math.max(
46
+ 64 * 1024,
47
+ Number(
48
+ process.env.RUNTIME_LOG_DOWNLOAD_MAX_BYTES ||
49
+ String(RUNTIME_LOG_DOWNLOAD_MAX_BYTES_DEFAULT),
50
+ ) || RUNTIME_LOG_DOWNLOAD_MAX_BYTES_DEFAULT,
51
+ );
52
+ const runtimeStatusProbeIntervalMs = Math.max(
53
+ 0,
54
+ Number(process.env.RUNTIME_STATUS_PROBE_INTERVAL_MS || "15000") || 15000,
55
+ );
56
+ const runtimeStatusProbeTtlMs = Math.max(
57
+ 0,
58
+ Number(
59
+ process.env.RUNTIME_STATUS_PROBE_TTL_MS ||
60
+ String(Math.max(1000, runtimeStatusProbeIntervalMs || 15000)),
61
+ ) || Math.max(1000, runtimeStatusProbeIntervalMs || 15000),
62
+ );
63
+ const runtimeStatusProbeTimeoutMs = Math.max(
64
+ 500,
65
+ Number(process.env.RUNTIME_STATUS_PROBE_TIMEOUT_MS || "3000") || 3000,
66
+ );
67
+ const runtimeStatusProbeTimeoutMsWeixin = Math.max(
68
+ runtimeStatusProbeTimeoutMs,
69
+ Number(process.env.RUNTIME_STATUS_PROBE_TIMEOUT_MS_WEIXIN || "4000") || 4000,
70
+ );
71
+ const runtimeStatusProbeConcurrency = {
72
+ dingtalk: Math.max(
73
+ 1,
74
+ Number(process.env.RUNTIME_STATUS_PROBE_CONCURRENCY_DINGTALK || "4") || 4,
75
+ ),
76
+ feishu: Math.max(
77
+ 1,
78
+ Number(process.env.RUNTIME_STATUS_PROBE_CONCURRENCY_FEISHU || "4") || 4,
79
+ ),
80
+ weixin: Math.max(
81
+ 1,
82
+ Number(process.env.RUNTIME_STATUS_PROBE_CONCURRENCY_WEIXIN || "2") || 2,
83
+ ),
84
+ };
85
+ const runtimeEventHeartbeatMs = Math.max(
86
+ 1000,
87
+ Number(process.env.RUNTIME_EVENT_HEARTBEAT_MS || "20000") || 20000,
88
+ );
89
+ const runtimeEventHeartbeatStaleMs = Math.max(
90
+ runtimeEventHeartbeatMs,
91
+ Number(
92
+ process.env.RUNTIME_EVENT_HEARTBEAT_STALE_MS ||
93
+ String(runtimeEventHeartbeatMs * 3),
94
+ ) || runtimeEventHeartbeatMs * 3,
95
+ );
44
96
 
45
97
  type RuntimeBotStatus = {
46
98
  platform: "dingtalk" | "feishu" | "weixin";
@@ -60,7 +112,16 @@ type RuntimeBotStatus = {
60
112
  last_probe_at?: string | null;
61
113
  };
62
114
 
115
+ type RuntimeHeartbeatEntry = {
116
+ platform: "dingtalk" | "feishu" | "weixin";
117
+ bot_account_id: string;
118
+ last_heartbeat_at: string;
119
+ last_event?: string | null;
120
+ };
121
+
63
122
  const runtimeStatusRegistry = new Map<string, RuntimeBotStatus>();
123
+ const runtimeHeartbeatRegistry = new Map<string, RuntimeHeartbeatEntry>();
124
+ let runtimeHeartbeatLastReceivedAt: string | null = null;
64
125
  let loadedConfigForRuntime: Record<string, unknown> | null = {
65
126
  channels: {},
66
127
  bindings: [],
@@ -78,6 +139,14 @@ let larkBridgeStarting = false;
78
139
  let weixinBridgeStarted = false;
79
140
  let weixinBridgeStarting = false;
80
141
  let pluginTempHomeDir: string | null = null;
142
+ let probeInFlight: Promise<void> | null = null;
143
+ let lastProbeStartedAt: string | null = null;
144
+ let lastProbeFinishedAt: string | null = null;
145
+ let lastProbeDurationMs = 0;
146
+ let runtimeStatusCacheHitTotal = 0;
147
+ let runtimeStatusSingleflightReusedTotal = 0;
148
+ let runtimeStatusProbeTimeoutTotal = 0;
149
+ const botControlInFlight = new Set<string>();
81
150
 
82
151
  type RuntimeGatewayChannel = "dingtalk" | "feishu" | "weixin";
83
152
 
@@ -87,6 +156,21 @@ type RuntimeGatewayInvokeResult = {
87
156
  result?: unknown;
88
157
  };
89
158
 
159
+ type RuntimePlatform = "dingtalk" | "feishu" | "weixin";
160
+
161
+ type RuntimeBridgeControl = {
162
+ restart?: () => Promise<void>;
163
+ stop?: () => Promise<void>;
164
+ startAccount?: (accountId: string) => Promise<void>;
165
+ stopAccount?: (accountId: string) => Promise<void>;
166
+ restartAccount?: (accountId: string) => Promise<void>;
167
+ };
168
+
169
+ type ProbeRefreshOptions = {
170
+ force?: boolean;
171
+ reason?: string;
172
+ };
173
+
90
174
  function pluginHomeConfigPayload(
91
175
  cfg: Record<string, unknown>,
92
176
  ): Record<string, unknown> {
@@ -99,7 +183,6 @@ function pluginHomeConfigPayload(
99
183
  : [],
100
184
  };
101
185
  }
102
-
103
186
  /** 远端配置拉取成功时:临时 HOME + ~/.syim/syim.json 与 ~/.openclaw/openclaw.json(内容一致)。 */
104
187
  function applyRemoteRuntimeConfigToPluginHome(): void {
105
188
  if (
@@ -137,6 +220,8 @@ function applyRemoteRuntimeConfigToPluginHome(): void {
137
220
  );
138
221
  }
139
222
  fs.writeFileSync(openclawPath, payload, "utf-8");
223
+ // 让 openclaw host 与插件均优先读取 .syim/syim.json。
224
+ process.env.OPENCLAW_CONFIG_PATH = syimPath;
140
225
  process.env.HOME = pluginTempHomeDir;
141
226
  if (process.platform === "win32")
142
227
  process.env.USERPROFILE = pluginTempHomeDir;
@@ -176,6 +261,69 @@ function upsertRuntimeStatus(entry: RuntimeBotStatus): void {
176
261
  });
177
262
  }
178
263
 
264
+ function upsertRuntimeHeartbeat(entry: RuntimeHeartbeatEntry): void {
265
+ const key = keyOf(entry.platform, entry.bot_account_id);
266
+ const previous = runtimeHeartbeatRegistry.get(key);
267
+ runtimeHeartbeatRegistry.set(key, {
268
+ ...previous,
269
+ ...entry,
270
+ });
271
+ runtimeHeartbeatLastReceivedAt = nowIso();
272
+ }
273
+
274
+ function isHeartbeatOnlyEvent(eventName: string | null | undefined): boolean {
275
+ const normalized = String(eventName || "")
276
+ .trim()
277
+ .toLowerCase();
278
+ return normalized === "runtime_heartbeat" || normalized === "heartbeat";
279
+ }
280
+
281
+ function buildRuntimeHeartbeatSnapshot(nowMs = Date.now()): {
282
+ heartbeat_ms: number;
283
+ stale_after_ms: number;
284
+ last_received_at: string | null;
285
+ entries: Array<{
286
+ platform: "dingtalk" | "feishu" | "weixin";
287
+ bot_account_id: string;
288
+ last_heartbeat_at: string | null;
289
+ age_ms: number | null;
290
+ stale: boolean;
291
+ last_event: string | null;
292
+ }>;
293
+ } {
294
+ const entries = Array.from(runtimeHeartbeatRegistry.values())
295
+ .map((item) => {
296
+ const rawHeartbeatAt = String(item.last_heartbeat_at || "").trim();
297
+ const heartbeatAt = rawHeartbeatAt || null;
298
+ const parsedMs = heartbeatAt ? Date.parse(heartbeatAt) : Number.NaN;
299
+ const ageMs = Number.isNaN(parsedMs)
300
+ ? null
301
+ : Math.max(0, nowMs - parsedMs);
302
+ const stale = ageMs == null ? true : ageMs > runtimeEventHeartbeatStaleMs;
303
+ return {
304
+ platform: item.platform,
305
+ bot_account_id: item.bot_account_id,
306
+ last_heartbeat_at: heartbeatAt,
307
+ age_ms: ageMs,
308
+ stale,
309
+ last_event: item.last_event || null,
310
+ };
311
+ })
312
+ .sort((a, b) => {
313
+ if (a.platform !== b.platform) {
314
+ return a.platform.localeCompare(b.platform);
315
+ }
316
+ return a.bot_account_id.localeCompare(b.bot_account_id);
317
+ });
318
+
319
+ return {
320
+ heartbeat_ms: runtimeEventHeartbeatMs,
321
+ stale_after_ms: runtimeEventHeartbeatStaleMs,
322
+ last_received_at: runtimeHeartbeatLastReceivedAt,
323
+ entries,
324
+ };
325
+ }
326
+
179
327
  function isGenericRuntimeErrorMessage(
180
328
  message: string | null | undefined,
181
329
  ): boolean {
@@ -324,16 +472,20 @@ function readRecentBridgeErrorFromLog(
324
472
  /** Bridge file lines from setupBridgeLogger: `[ISO] [log|info|warn|error|debug] message` */
325
473
  const BRIDGE_FILE_ERROR_LINE = /^\[[^\]]+\] \[error\] /;
326
474
 
475
+ function getBridgeLoggerFilePath(): string {
476
+ const loggerState = (globalThis as Record<string, unknown>)
477
+ .__imAgentHubBridgeLogger as
478
+ | { enabled?: boolean; logFilePath?: string }
479
+ | undefined;
480
+ return String(loggerState?.logFilePath || "").trim();
481
+ }
482
+
327
483
  function readRuntimeLogLines(limit: number): {
328
484
  log_file_path: string | null;
329
485
  lines: string[];
330
486
  log_level_filter: "error";
331
487
  } {
332
- const loggerState = (globalThis as Record<string, unknown>)
333
- .__imAgentHubBridgeLogger as
334
- | { enabled?: boolean; logFilePath?: string }
335
- | undefined;
336
- const logFilePath = String(loggerState?.logFilePath || "").trim();
488
+ const logFilePath = getBridgeLoggerFilePath();
337
489
  const clampedLimit = Math.max(1, Math.min(1000, Number(limit) || 100));
338
490
  if (!logFilePath || !fs.existsSync(logFilePath)) {
339
491
  return {
@@ -363,6 +515,99 @@ function readRuntimeLogLines(limit: number): {
363
515
  }
364
516
  }
365
517
 
518
+ function buildRuntimeLogDownloadFileName(logFilePath: string | null): string {
519
+ const candidate = String(logFilePath || "").trim();
520
+ if (candidate) {
521
+ const baseName = path.basename(candidate).trim();
522
+ if (baseName) return baseName;
523
+ }
524
+ return `im-agent-hub-runtime-${new Date().toISOString().replace(/[:.]/g, "-")}.log`;
525
+ }
526
+
527
+ function parseRuntimeLogDownloadMaxBytes(maxBytesRaw?: unknown): number {
528
+ return Math.max(
529
+ 64 * 1024,
530
+ Math.min(
531
+ 20 * 1024 * 1024,
532
+ Number(maxBytesRaw) || runtimeLogDownloadMaxBytes,
533
+ ),
534
+ );
535
+ }
536
+
537
+ function streamRuntimeLogDownload(
538
+ res: http.ServerResponse,
539
+ options?: { maxBytesRaw?: unknown },
540
+ ): void {
541
+ const maxBytes = parseRuntimeLogDownloadMaxBytes(options?.maxBytesRaw);
542
+ const logFilePath = getBridgeLoggerFilePath();
543
+ const fileName = buildRuntimeLogDownloadFileName(logFilePath || null);
544
+
545
+ const baseHeaders: Record<string, string> = {
546
+ "Content-Type": "text/plain; charset=utf-8",
547
+ "Content-Disposition": `attachment; filename="${fileName}"`,
548
+ "X-IM-Runtime-Log-File-Name": fileName,
549
+ "X-IM-Runtime-Log-Max-Bytes": String(maxBytes),
550
+ "X-IM-Runtime-Instance-Id": runtimeInstanceId,
551
+ };
552
+
553
+ if (!logFilePath || !fs.existsSync(logFilePath)) {
554
+ res.writeHead(200, {
555
+ ...baseHeaders,
556
+ "Content-Length": "0",
557
+ "X-IM-Runtime-Log-Truncated": "0",
558
+ "X-IM-Runtime-Log-Bytes": "0",
559
+ });
560
+ res.end();
561
+ return;
562
+ }
563
+
564
+ try {
565
+ const stat = fs.statSync(logFilePath);
566
+ const totalBytes = Math.max(0, Number(stat.size) || 0);
567
+ const start = Math.max(0, totalBytes - maxBytes);
568
+ const contentBytes = Math.max(0, totalBytes - start);
569
+ const truncated = start > 0;
570
+
571
+ if (totalBytes <= 0) {
572
+ res.writeHead(200, {
573
+ ...baseHeaders,
574
+ "Content-Length": "0",
575
+ "X-IM-Runtime-Log-Truncated": "0",
576
+ "X-IM-Runtime-Log-Bytes": "0",
577
+ });
578
+ res.end();
579
+ return;
580
+ }
581
+
582
+ res.writeHead(200, {
583
+ ...baseHeaders,
584
+ "Content-Length": String(contentBytes),
585
+ "X-IM-Runtime-Log-Truncated": truncated ? "1" : "0",
586
+ "X-IM-Runtime-Log-Bytes": String(contentBytes),
587
+ });
588
+
589
+ const stream = fs.createReadStream(logFilePath, { start });
590
+ stream.on("error", (err) => {
591
+ if (!res.headersSent) {
592
+ res.writeHead(500, {
593
+ "Content-Type": "text/plain; charset=utf-8",
594
+ "X-IM-Runtime-Read-Error": (err as Error).message.slice(0, 200),
595
+ });
596
+ }
597
+ if (!res.writableEnded) {
598
+ res.end(`read log failed: ${(err as Error).message}`);
599
+ }
600
+ });
601
+ stream.pipe(res);
602
+ } catch (err) {
603
+ res.writeHead(500, {
604
+ "Content-Type": "text/plain; charset=utf-8",
605
+ "X-IM-Runtime-Read-Error": (err as Error).message.slice(0, 200),
606
+ });
607
+ res.end(`read log failed: ${(err as Error).message}`);
608
+ }
609
+ }
610
+
366
611
  function clearPlatformStatuses(
367
612
  platform: "dingtalk" | "feishu" | "weixin",
368
613
  ): void {
@@ -494,10 +739,10 @@ function markPlatformStarted(platform: "dingtalk" | "feishu" | "weixin"): void {
494
739
  if (!key.startsWith(`${platform}:`)) continue;
495
740
  runtimeStatusRegistry.set(key, {
496
741
  ...value,
497
- link_status: "connected",
742
+ link_status: "connecting",
498
743
  last_heartbeat_at: nowIso(),
499
744
  last_error: null,
500
- last_event: "platform_started",
745
+ last_event: "platform_process_started",
501
746
  status_source: "manual",
502
747
  });
503
748
  }
@@ -532,7 +777,8 @@ async function ensureBridgesStartedByConfig(): Promise<void> {
532
777
  (startupOnlyMode === "all" || startupOnlyMode === "lark") &&
533
778
  isChannelEnabled("feishu") &&
534
779
  readChannelAccountCount("feishu") > 0;
535
- const canStartWeixin = startupOnlyMode === "all" || startupOnlyMode === "weixin";
780
+ const canStartWeixin =
781
+ startupOnlyMode === "all" || startupOnlyMode === "weixin";
536
782
 
537
783
  const tasks: Array<Promise<void>> = [];
538
784
 
@@ -619,9 +865,7 @@ async function ensureBridgesStartedByConfig(): Promise<void> {
619
865
  }),
620
866
  );
621
867
  } else if (startupOnlyMode === "all" || startupOnlyMode === "weixin") {
622
- console.log(
623
- "[bridges/main] skip weixin bridge start: enabled=false",
624
- );
868
+ console.log("[bridges/main] skip weixin bridge start: enabled=false");
625
869
  }
626
870
  } else {
627
871
  console.log(
@@ -650,7 +894,9 @@ async function ensureWeixinBridgeReadyForGatewayInvoke(): Promise<void> {
650
894
  }
651
895
 
652
896
  weixinBridgeStarting = true;
653
- console.log("[bridges/main] lazy starting weixin bridge for gateway invoke...");
897
+ console.log(
898
+ "[bridges/main] lazy starting weixin bridge for gateway invoke...",
899
+ );
654
900
  try {
655
901
  await import("./weixin-stdio-bridge.ts");
656
902
  weixinBridgeStarted = true;
@@ -712,6 +958,48 @@ function normalizeGatewayChannel(input: unknown): RuntimeGatewayChannel | null {
712
958
  return null;
713
959
  }
714
960
 
961
+ function normalizeRuntimePlatform(input: unknown): RuntimePlatform | null {
962
+ const raw = String(input || "")
963
+ .trim()
964
+ .toLowerCase();
965
+ if (raw === "dingtalk" || raw === "dingtalk-connector") return "dingtalk";
966
+ if (raw === "feishu" || raw === "lark" || raw === "openclaw-lark") {
967
+ return "feishu";
968
+ }
969
+ if (raw === "weixin" || raw === "openclaw-weixin") {
970
+ return "weixin";
971
+ }
972
+ return null;
973
+ }
974
+
975
+ function platformToChannelKey(
976
+ platform: RuntimePlatform,
977
+ ): "dingtalk-connector" | "feishu" | "openclaw-weixin" {
978
+ if (platform === "dingtalk") return "dingtalk-connector";
979
+ if (platform === "feishu") return "feishu";
980
+ return "openclaw-weixin";
981
+ }
982
+
983
+ function resolveRuntimeBridgeControl(
984
+ platform: RuntimePlatform,
985
+ ): RuntimeBridgeControl | undefined {
986
+ const key =
987
+ platform === "dingtalk"
988
+ ? "__IM_DINGTALK_BRIDGE_CONTROL__"
989
+ : platform === "feishu"
990
+ ? "__IM_LARK_BRIDGE_CONTROL__"
991
+ : "__IM_WEIXIN_BRIDGE_CONTROL__";
992
+ const control = (globalThis as Record<string, unknown>)[key] as
993
+ | RuntimeBridgeControl
994
+ | undefined;
995
+ return control;
996
+ }
997
+
998
+ function generateOperationId(prefix: string): string {
999
+ const rand = Math.random().toString(36).slice(2, 10);
1000
+ return `${prefix}-${Date.now()}-${rand}`;
1001
+ }
1002
+
715
1003
  async function invokeGatewayMethodByChannel(args: {
716
1004
  channel: RuntimeGatewayChannel;
717
1005
  method: string;
@@ -774,7 +1062,66 @@ async function invokeGatewayMethodByChannel(args: {
774
1062
  }
775
1063
  }
776
1064
 
777
- async function probeAndRefreshStatuses(): Promise<void> {
1065
+ function parseBoolFlag(input: unknown): boolean {
1066
+ const normalized = String(input || "")
1067
+ .trim()
1068
+ .toLowerCase();
1069
+ return normalized === "1" || normalized === "true" || normalized === "yes";
1070
+ }
1071
+
1072
+ function isProbeFresh(nowMs = Date.now()): boolean {
1073
+ if (!lastProbeFinishedAt) return false;
1074
+ const lastMs = Date.parse(lastProbeFinishedAt);
1075
+ if (Number.isNaN(lastMs)) return false;
1076
+ return nowMs - lastMs <= runtimeStatusProbeTtlMs;
1077
+ }
1078
+
1079
+ async function withProbeTimeout<T>(
1080
+ work: Promise<T>,
1081
+ timeoutMs: number,
1082
+ timeoutMessage: string,
1083
+ ): Promise<T> {
1084
+ if (timeoutMs <= 0) return await work;
1085
+ let timer: ReturnType<typeof setTimeout> | null = null;
1086
+ try {
1087
+ return await Promise.race([
1088
+ work,
1089
+ new Promise<T>((_, reject) => {
1090
+ timer = setTimeout(() => {
1091
+ runtimeStatusProbeTimeoutTotal += 1;
1092
+ reject(new Error(timeoutMessage));
1093
+ }, timeoutMs);
1094
+ }),
1095
+ ]);
1096
+ } finally {
1097
+ if (timer) {
1098
+ clearTimeout(timer);
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ async function runWithConcurrency<T>(
1104
+ items: T[],
1105
+ concurrency: number,
1106
+ worker: (item: T) => Promise<void>,
1107
+ ): Promise<void> {
1108
+ if (!items.length) return;
1109
+ const limit = Math.max(1, Math.min(concurrency, items.length));
1110
+ let cursor = 0;
1111
+
1112
+ const runners = Array.from({ length: limit }, async () => {
1113
+ while (true) {
1114
+ const current = cursor;
1115
+ cursor += 1;
1116
+ if (current >= items.length) return;
1117
+ await worker(items[current] as T);
1118
+ }
1119
+ });
1120
+
1121
+ await Promise.all(runners);
1122
+ }
1123
+
1124
+ async function runFullProbeAndRefreshStatuses(): Promise<void> {
778
1125
  if (!loadedConfigForRuntime) return;
779
1126
  const cfg = loadedConfigForRuntime;
780
1127
 
@@ -817,120 +1164,152 @@ async function probeAndRefreshStatuses(): Promise<void> {
817
1164
  if (listAccountIds) {
818
1165
  const ids = configuredIds;
819
1166
  if (resolveAccount && probeAccount) {
820
- for (const accountId of ids) {
821
- const previous = runtimeStatusRegistry.get(
822
- keyOf("dingtalk", accountId),
823
- );
824
- try {
825
- const account = resolveAccount(cfg, accountId);
826
- const probeResult = await probeAccount({ account });
827
- const ok = probeResult?.ok !== false;
828
- const probeError = buildDetailedProbeErrorMessage(
829
- probeResult as Record<string, unknown>,
1167
+ await runWithConcurrency(
1168
+ ids,
1169
+ runtimeStatusProbeConcurrency.dingtalk,
1170
+ async (accountId) => {
1171
+ const previous = runtimeStatusRegistry.get(
1172
+ keyOf("dingtalk", accountId),
830
1173
  );
831
- const probeErrorWithLogFallback = isGenericRuntimeErrorMessage(
832
- probeError,
833
- )
834
- ? pickMoreSpecificErrorMessage(
835
- probeError,
836
- readRecentBridgeErrorFromLog(accountId, "dingtalk"),
837
- )
838
- : probeError;
839
- if (!ok) {
840
- console.error(
841
- `[bridges/main] dingtalk probeAccount failed account=${accountId} raw=${toLogText(probeResult)}`,
1174
+ try {
1175
+ const account = resolveAccount(cfg, accountId);
1176
+ const probeResult = await withProbeTimeout(
1177
+ probeAccount({ account }),
1178
+ runtimeStatusProbeTimeoutMs,
1179
+ `[bridges/main] dingtalk probeAccount timeout account=${accountId} timeoutMs=${runtimeStatusProbeTimeoutMs}`,
842
1180
  );
843
- console.error(
844
- `[bridges/main] dingtalk probeAccount derived_error account=${accountId} error=${toLogText(probeErrorWithLogFallback || "")}`,
1181
+ const ok = probeResult?.ok !== false;
1182
+ const probeError = buildDetailedProbeErrorMessage(
1183
+ probeResult as Record<string, unknown>,
845
1184
  );
846
- }
847
- const error = ok
848
- ? null
849
- : pickMoreSpecificErrorMessage(
850
- previous?.last_error || null,
851
- probeErrorWithLogFallback,
1185
+ const probeErrorWithLogFallback = isGenericRuntimeErrorMessage(
1186
+ probeError,
1187
+ )
1188
+ ? pickMoreSpecificErrorMessage(
1189
+ probeError,
1190
+ readRecentBridgeErrorFromLog(accountId, "dingtalk"),
1191
+ )
1192
+ : probeError;
1193
+ if (!ok) {
1194
+ console.error(
1195
+ `[bridges/main] dingtalk probeAccount failed account=${accountId} raw=${toLogText(probeResult)}`,
852
1196
  );
853
- if (!ok) {
854
- console.error(
855
- `[bridges/main] dingtalk probeAccount merged_error account=${accountId} error=${toLogText(error || "")}`,
856
- );
1197
+ console.error(
1198
+ `[bridges/main] dingtalk probeAccount derived_error account=${accountId} error=${toLogText(probeErrorWithLogFallback || "")}`,
1199
+ );
1200
+ }
1201
+ const error = ok
1202
+ ? null
1203
+ : pickMoreSpecificErrorMessage(
1204
+ previous?.last_error || null,
1205
+ probeErrorWithLogFallback,
1206
+ );
1207
+ if (!ok) {
1208
+ console.error(
1209
+ `[bridges/main] dingtalk probeAccount merged_error account=${accountId} error=${toLogText(error || "")}`,
1210
+ );
1211
+ }
1212
+ upsertRuntimeStatus({
1213
+ platform: "dingtalk",
1214
+ bot_account_id: accountId,
1215
+ link_status: ok ? "connected" : "error",
1216
+ started_at: previous?.started_at || nowIso(),
1217
+ last_heartbeat_at: nowIso(),
1218
+ last_error: error,
1219
+ status_source: "probe",
1220
+ last_probe_at: nowIso(),
1221
+ });
1222
+ } catch (err) {
1223
+ upsertRuntimeStatus({
1224
+ platform: "dingtalk",
1225
+ bot_account_id: accountId,
1226
+ link_status: "error",
1227
+ started_at: previous?.started_at || nowIso(),
1228
+ last_heartbeat_at: nowIso(),
1229
+ last_error: pickMoreSpecificErrorMessage(
1230
+ previous?.last_error || null,
1231
+ (err as Error).message,
1232
+ ),
1233
+ status_source: "probe",
1234
+ last_probe_at: nowIso(),
1235
+ });
857
1236
  }
858
- upsertRuntimeStatus({
859
- platform: "dingtalk",
860
- bot_account_id: accountId,
861
- link_status: ok ? "connected" : "error",
862
- started_at: previous?.started_at || nowIso(),
863
- last_heartbeat_at: nowIso(),
864
- last_error: error,
865
- status_source: "probe",
866
- last_probe_at: nowIso(),
867
- });
868
- } catch (err) {
869
- upsertRuntimeStatus({
870
- platform: "dingtalk",
871
- bot_account_id: accountId,
872
- link_status: "error",
873
- started_at: previous?.started_at || nowIso(),
874
- last_heartbeat_at: nowIso(),
875
- last_error: pickMoreSpecificErrorMessage(
876
- previous?.last_error || null,
877
- (err as Error).message,
878
- ),
879
- status_source: "probe",
880
- last_probe_at: nowIso(),
881
- });
882
- }
883
- }
1237
+ },
1238
+ );
884
1239
  } else if (probe) {
885
- for (const accountId of ids) {
886
- const probeResult = await probe({ cfg, accountId });
887
- const ok = Boolean(probeResult?.ok);
888
- if (!ok) {
889
- console.error(
890
- `[bridges/main] dingtalk probe failed raw=${toLogText(probeResult)}`,
1240
+ await runWithConcurrency(
1241
+ ids,
1242
+ runtimeStatusProbeConcurrency.dingtalk,
1243
+ async (accountId) => {
1244
+ const previous = runtimeStatusRegistry.get(
1245
+ keyOf("dingtalk", accountId),
891
1246
  );
892
- }
893
- const previous = runtimeStatusRegistry.get(
894
- keyOf("dingtalk", accountId),
895
- );
896
- const probeError = buildDetailedProbeErrorMessage(
897
- probeResult as Record<string, unknown>,
898
- );
899
- const probeErrorWithLogFallback = isGenericRuntimeErrorMessage(
900
- probeError,
901
- )
902
- ? pickMoreSpecificErrorMessage(
1247
+ try {
1248
+ const probeResult = await withProbeTimeout(
1249
+ probe({ cfg, accountId }),
1250
+ runtimeStatusProbeTimeoutMs,
1251
+ `[bridges/main] dingtalk probe timeout account=${accountId} timeoutMs=${runtimeStatusProbeTimeoutMs}`,
1252
+ );
1253
+ const ok = Boolean(probeResult?.ok);
1254
+ if (!ok) {
1255
+ console.error(
1256
+ `[bridges/main] dingtalk probe failed raw=${toLogText(probeResult)}`,
1257
+ );
1258
+ }
1259
+ const probeError = buildDetailedProbeErrorMessage(
1260
+ probeResult as Record<string, unknown>,
1261
+ );
1262
+ const probeErrorWithLogFallback = isGenericRuntimeErrorMessage(
903
1263
  probeError,
904
- readRecentBridgeErrorFromLog(accountId, "dingtalk"),
905
1264
  )
906
- : probeError;
907
- if (!ok) {
908
- console.error(
909
- `[bridges/main] dingtalk probe derived_error account=${accountId} error=${toLogText(probeErrorWithLogFallback || "")}`,
910
- );
911
- }
912
- const error = ok
913
- ? null
914
- : pickMoreSpecificErrorMessage(
915
- previous?.last_error || null,
916
- probeErrorWithLogFallback,
917
- );
918
- if (!ok) {
919
- console.error(
920
- `[bridges/main] dingtalk probe merged_error account=${accountId} error=${toLogText(error || "")}`,
921
- );
922
- }
923
- upsertRuntimeStatus({
924
- platform: "dingtalk",
925
- bot_account_id: accountId,
926
- link_status: ok ? "connected" : "error",
927
- started_at: previous?.started_at || nowIso(),
928
- last_heartbeat_at: nowIso(),
929
- last_error: error,
930
- status_source: "probe",
931
- last_probe_at: nowIso(),
932
- });
933
- }
1265
+ ? pickMoreSpecificErrorMessage(
1266
+ probeError,
1267
+ readRecentBridgeErrorFromLog(accountId, "dingtalk"),
1268
+ )
1269
+ : probeError;
1270
+ if (!ok) {
1271
+ console.error(
1272
+ `[bridges/main] dingtalk probe derived_error account=${accountId} error=${toLogText(probeErrorWithLogFallback || "")}`,
1273
+ );
1274
+ }
1275
+ const error = ok
1276
+ ? null
1277
+ : pickMoreSpecificErrorMessage(
1278
+ previous?.last_error || null,
1279
+ probeErrorWithLogFallback,
1280
+ );
1281
+ if (!ok) {
1282
+ console.error(
1283
+ `[bridges/main] dingtalk probe merged_error account=${accountId} error=${toLogText(error || "")}`,
1284
+ );
1285
+ }
1286
+ upsertRuntimeStatus({
1287
+ platform: "dingtalk",
1288
+ bot_account_id: accountId,
1289
+ link_status: ok ? "connected" : "error",
1290
+ started_at: previous?.started_at || nowIso(),
1291
+ last_heartbeat_at: nowIso(),
1292
+ last_error: error,
1293
+ status_source: "probe",
1294
+ last_probe_at: nowIso(),
1295
+ });
1296
+ } catch (err) {
1297
+ upsertRuntimeStatus({
1298
+ platform: "dingtalk",
1299
+ bot_account_id: accountId,
1300
+ link_status: "error",
1301
+ started_at: previous?.started_at || nowIso(),
1302
+ last_heartbeat_at: nowIso(),
1303
+ last_error: pickMoreSpecificErrorMessage(
1304
+ previous?.last_error || null,
1305
+ (err as Error).message,
1306
+ ),
1307
+ status_source: "probe",
1308
+ last_probe_at: nowIso(),
1309
+ });
1310
+ }
1311
+ },
1312
+ );
934
1313
  }
935
1314
  }
936
1315
  }
@@ -970,117 +1349,206 @@ async function probeAndRefreshStatuses(): Promise<void> {
970
1349
 
971
1350
  if (listAccountIds && resolveAccount && probeAccount) {
972
1351
  const ids = configuredIds;
973
- for (const accountId of ids) {
1352
+ await runWithConcurrency(
1353
+ ids,
1354
+ runtimeStatusProbeConcurrency.feishu,
1355
+ async (accountId) => {
1356
+ try {
1357
+ const previous = runtimeStatusRegistry.get(
1358
+ keyOf("feishu", accountId),
1359
+ );
1360
+ const account = resolveAccount(cfg, accountId);
1361
+ const probeResult = await withProbeTimeout(
1362
+ probeAccount({ account }),
1363
+ runtimeStatusProbeTimeoutMs,
1364
+ `[bridges/main] feishu probeAccount timeout account=${accountId} timeoutMs=${runtimeStatusProbeTimeoutMs}`,
1365
+ );
1366
+ const ok = probeResult?.ok !== false;
1367
+ const probeError = buildDetailedProbeErrorMessage(
1368
+ probeResult as Record<string, unknown>,
1369
+ );
1370
+ const probeErrorWithLogFallback = isGenericRuntimeErrorMessage(
1371
+ probeError,
1372
+ )
1373
+ ? pickMoreSpecificErrorMessage(
1374
+ probeError,
1375
+ readRecentBridgeErrorFromLog(accountId, "feishu"),
1376
+ )
1377
+ : probeError;
1378
+ const preserveEventStatus =
1379
+ previous?.status_source === "event" &&
1380
+ (previous.link_status === "error" ||
1381
+ previous.link_status === "disconnected" ||
1382
+ previous.link_status === "degraded");
1383
+
1384
+ const nextStatus: RuntimeBotStatus["link_status"] =
1385
+ preserveEventStatus
1386
+ ? previous?.link_status || "connecting"
1387
+ : ok
1388
+ ? "connected"
1389
+ : "error";
1390
+ const nextError =
1391
+ nextStatus === "connected"
1392
+ ? null
1393
+ : pickMoreSpecificErrorMessage(
1394
+ previous?.last_error || null,
1395
+ probeErrorWithLogFallback,
1396
+ );
1397
+
1398
+ upsertRuntimeStatus({
1399
+ platform: "feishu",
1400
+ bot_account_id: accountId,
1401
+ link_status: nextStatus,
1402
+ started_at: previous?.started_at || nowIso(),
1403
+ last_heartbeat_at: nowIso(),
1404
+ last_error: nextError,
1405
+ status_source: preserveEventStatus ? "event" : "probe",
1406
+ last_probe_at: nowIso(),
1407
+ });
1408
+ } catch (err) {
1409
+ upsertRuntimeStatus({
1410
+ platform: "feishu",
1411
+ bot_account_id: accountId,
1412
+ link_status: "error",
1413
+ started_at:
1414
+ runtimeStatusRegistry.get(keyOf("feishu", accountId))
1415
+ ?.started_at || nowIso(),
1416
+ last_heartbeat_at: nowIso(),
1417
+ last_error: (err as Error).message,
1418
+ status_source: "probe",
1419
+ last_probe_at: nowIso(),
1420
+ });
1421
+ }
1422
+ },
1423
+ );
1424
+ }
1425
+ }
1426
+ } catch (err) {
1427
+ console.error("[bridges/main] feishu probe failed", (err as Error).message);
1428
+ }
1429
+
1430
+ try {
1431
+ const configuredIds = readConfiguredAccountIds(cfg, "openclaw-weixin");
1432
+ if (configuredIds.length === 0) {
1433
+ clearPlatformStatuses("weixin");
1434
+ } else {
1435
+ await runWithConcurrency(
1436
+ configuredIds,
1437
+ runtimeStatusProbeConcurrency.weixin,
1438
+ async (accountId) => {
1439
+ const previous = runtimeStatusRegistry.get(
1440
+ keyOf("weixin", accountId),
1441
+ );
974
1442
  try {
975
- const account = resolveAccount(cfg, accountId);
976
- const probeResult = await probeAccount({ account });
977
- const ok = probeResult?.ok !== false;
978
- const previous = runtimeStatusRegistry.get(
979
- keyOf("feishu", accountId),
980
- );
981
- const probeError = buildDetailedProbeErrorMessage(
982
- probeResult as Record<string, unknown>,
1443
+ const invoke = await withProbeTimeout(
1444
+ invokeGatewayMethodByChannel({
1445
+ channel: "weixin",
1446
+ method: "weixin.probe",
1447
+ params: { accountId },
1448
+ accountId,
1449
+ }),
1450
+ runtimeStatusProbeTimeoutMsWeixin,
1451
+ `[bridges/main] weixin probe timeout account=${accountId} timeoutMs=${runtimeStatusProbeTimeoutMsWeixin}`,
983
1452
  );
984
- const error = ok
985
- ? null
986
- : pickMoreSpecificErrorMessage(
987
- previous?.last_error || null,
1453
+ const probeResult =
1454
+ invoke?.result && typeof invoke.result === "object"
1455
+ ? (invoke.result as Record<string, unknown>)
1456
+ : null;
1457
+ const probeOk =
1458
+ invoke?.ok === true &&
1459
+ (!probeResult ||
1460
+ probeResult.ok === undefined ||
1461
+ probeResult.ok !== false);
1462
+ const probeError = probeResult
1463
+ ? buildDetailedProbeErrorMessage(probeResult)
1464
+ : String(invoke?.error || "").trim();
1465
+ const probeErrorWithLogFallback = isGenericRuntimeErrorMessage(
1466
+ probeError,
1467
+ )
1468
+ ? pickMoreSpecificErrorMessage(
988
1469
  probeError,
989
- );
1470
+ readRecentBridgeErrorFromLog(accountId, "weixin"),
1471
+ )
1472
+ : probeError;
1473
+ const linkStatus: RuntimeBotStatus["link_status"] = probeOk
1474
+ ? "connected"
1475
+ : weixinBridgeStarted
1476
+ ? "error"
1477
+ : "connecting";
990
1478
  upsertRuntimeStatus({
991
- platform: "feishu",
1479
+ platform: "weixin",
992
1480
  bot_account_id: accountId,
993
- link_status: ok ? "connected" : "error",
1481
+ link_status: linkStatus,
994
1482
  started_at: previous?.started_at || nowIso(),
995
1483
  last_heartbeat_at: nowIso(),
996
- last_error: error,
1484
+ last_error: probeOk
1485
+ ? null
1486
+ : pickMoreSpecificErrorMessage(
1487
+ previous?.last_error || null,
1488
+ probeErrorWithLogFallback || invoke?.error || null,
1489
+ ),
997
1490
  status_source: "probe",
998
1491
  last_probe_at: nowIso(),
999
1492
  });
1000
1493
  } catch (err) {
1001
1494
  upsertRuntimeStatus({
1002
- platform: "feishu",
1495
+ platform: "weixin",
1003
1496
  bot_account_id: accountId,
1004
- link_status: "error",
1005
- started_at:
1006
- runtimeStatusRegistry.get(keyOf("feishu", accountId))
1007
- ?.started_at || nowIso(),
1497
+ link_status: weixinBridgeStarted ? "error" : "connecting",
1498
+ started_at: previous?.started_at || nowIso(),
1008
1499
  last_heartbeat_at: nowIso(),
1009
- last_error: (err as Error).message,
1500
+ last_error: pickMoreSpecificErrorMessage(
1501
+ previous?.last_error || null,
1502
+ (err as Error).message,
1503
+ ),
1010
1504
  status_source: "probe",
1011
1505
  last_probe_at: nowIso(),
1012
1506
  });
1013
1507
  }
1014
- }
1015
- }
1508
+ },
1509
+ );
1016
1510
  }
1017
1511
  } catch (err) {
1018
- console.error("[bridges/main] feishu probe failed", (err as Error).message);
1512
+ console.error("[bridges/main] weixin probe failed", (err as Error).message);
1019
1513
  }
1514
+ }
1020
1515
 
1021
- try {
1022
- const configuredIds = readConfiguredAccountIds(cfg, "openclaw-weixin");
1023
- if (configuredIds.length === 0) {
1024
- clearPlatformStatuses("weixin");
1025
- } else {
1026
- for (const accountId of configuredIds) {
1027
- const previous = runtimeStatusRegistry.get(keyOf("weixin", accountId));
1028
- const invoke = await invokeGatewayMethodByChannel({
1029
- channel: "weixin",
1030
- method: "weixin.probe",
1031
- params: { accountId },
1032
- accountId,
1033
- });
1034
- const probeResult =
1035
- invoke?.result && typeof invoke.result === "object"
1036
- ? (invoke.result as Record<string, unknown>)
1037
- : null;
1038
- const probeOk =
1039
- invoke?.ok === true &&
1040
- (!probeResult || probeResult.ok === undefined || probeResult.ok !== false);
1041
- const probeError = probeResult
1042
- ? buildDetailedProbeErrorMessage(probeResult)
1043
- : String(invoke?.error || "").trim();
1044
- const probeErrorWithLogFallback = isGenericRuntimeErrorMessage(probeError)
1045
- ? pickMoreSpecificErrorMessage(
1046
- probeError,
1047
- readRecentBridgeErrorFromLog(accountId, "weixin"),
1048
- )
1049
- : probeError;
1050
- const linkStatus: RuntimeBotStatus["link_status"] = probeOk
1051
- ? "connected"
1052
- : weixinBridgeStarted
1053
- ? "error"
1054
- : "connecting";
1055
- upsertRuntimeStatus({
1056
- platform: "weixin",
1057
- bot_account_id: accountId,
1058
- link_status: linkStatus,
1059
- started_at: previous?.started_at || nowIso(),
1060
- last_heartbeat_at: nowIso(),
1061
- last_error: probeOk
1062
- ? null
1063
- : pickMoreSpecificErrorMessage(
1064
- previous?.last_error || null,
1065
- probeErrorWithLogFallback || invoke?.error || null,
1066
- ),
1067
- status_source: "probe",
1068
- last_probe_at: nowIso(),
1069
- });
1070
- }
1071
- }
1072
- } catch (err) {
1073
- console.error("[bridges/main] weixin probe failed", (err as Error).message);
1516
+ async function probeAndRefreshStatuses(
1517
+ options: ProbeRefreshOptions = {},
1518
+ ): Promise<void> {
1519
+ const forceProbe = options.force === true;
1520
+
1521
+ if (!forceProbe && isProbeFresh()) {
1522
+ runtimeStatusCacheHitTotal += 1;
1523
+ return;
1524
+ }
1525
+
1526
+ if (probeInFlight) {
1527
+ runtimeStatusSingleflightReusedTotal += 1;
1528
+ await probeInFlight;
1529
+ return;
1074
1530
  }
1531
+
1532
+ const startedAt = Date.now();
1533
+ lastProbeStartedAt = new Date(startedAt).toISOString();
1534
+
1535
+ probeInFlight = (async () => {
1536
+ await runFullProbeAndRefreshStatuses();
1537
+ })().finally(() => {
1538
+ const finishedAt = Date.now();
1539
+ lastProbeFinishedAt = new Date(finishedAt).toISOString();
1540
+ lastProbeDurationMs = Math.max(0, finishedAt - startedAt);
1541
+ probeInFlight = null;
1542
+ });
1543
+
1544
+ await probeInFlight;
1075
1545
  }
1076
1546
 
1077
1547
  function startProbeLoop(): void {
1078
- const intervalMs = Number(
1079
- process.env.RUNTIME_STATUS_PROBE_INTERVAL_MS || "15000",
1080
- );
1548
+ const intervalMs = runtimeStatusProbeIntervalMs;
1081
1549
  if (intervalMs <= 0) return;
1082
1550
  setInterval(() => {
1083
- void probeAndRefreshStatuses();
1551
+ void probeAndRefreshStatuses({ reason: "loop" });
1084
1552
  }, intervalMs);
1085
1553
  }
1086
1554
 
@@ -1148,11 +1616,16 @@ async function pullRuntimeConfigFromPython(forceFull = false): Promise<{
1148
1616
 
1149
1617
  const notModified = Boolean(data?.not_modified);
1150
1618
  if (notModified) {
1619
+ const pulledVersion = String(data?.version || "").trim();
1620
+ if (pulledVersion) {
1621
+ configVersionHash = pulledVersion;
1622
+ configVersionUpdatedAt = nowIso();
1623
+ }
1151
1624
  return {
1152
1625
  ok: true,
1153
1626
  pulled: false,
1154
1627
  not_modified: true,
1155
- version: String(data?.version || configVersionHash || "unknown"),
1628
+ version: pulledVersion || configVersionHash || "unknown",
1156
1629
  };
1157
1630
  }
1158
1631
 
@@ -1182,10 +1655,13 @@ async function pullRuntimeConfigFromPython(forceFull = false): Promise<{
1182
1655
  loadedConfigForRuntime,
1183
1656
  loadedConfigPathForRuntime,
1184
1657
  );
1185
- updateConfigVersion(normalizedResult.normalized);
1658
+ updateConfigVersion(
1659
+ normalizedResult.normalized,
1660
+ String(data?.version || "").trim() || undefined,
1661
+ );
1186
1662
  applyRemoteRuntimeConfigToPluginHome();
1187
1663
  markConfiguredBotsAsConnecting(normalizedResult.normalized);
1188
- await probeAndRefreshStatuses();
1664
+ await probeAndRefreshStatuses({ force: true, reason: "config_pull" });
1189
1665
 
1190
1666
  if (runtimeConfigPullPersist) {
1191
1667
  const targetPath =
@@ -1280,7 +1756,7 @@ async function waitRestartCompletion(
1280
1756
  const deadline = Date.now() + Math.max(1000, timeoutMs);
1281
1757
  const interval = Math.max(200, pollIntervalMs);
1282
1758
  while (Date.now() < deadline) {
1283
- await probeAndRefreshStatuses();
1759
+ await probeAndRefreshStatuses({ force: true, reason: "restart_wait" });
1284
1760
  const summary = summarizeBots();
1285
1761
  const unsettled =
1286
1762
  summary.connecting + summary.degraded + summary.disconnected;
@@ -1345,10 +1821,11 @@ function readEffectiveWhitelistFromConfig(
1345
1821
  "allowFrom",
1346
1822
  "groupPolicy",
1347
1823
  "requireMention",
1824
+ "scope_kind",
1825
+ "scopeKind",
1348
1826
  "scopeType",
1349
1827
  "projectId",
1350
1828
  "scope_type",
1351
- "type",
1352
1829
  "project_id",
1353
1830
  ],
1354
1831
  },
@@ -1371,10 +1848,11 @@ function readEffectiveWhitelistFromConfig(
1371
1848
  "dmPolicy",
1372
1849
  "allowFrom",
1373
1850
  "groupPolicy",
1851
+ "scope_kind",
1852
+ "scopeKind",
1374
1853
  "scopeType",
1375
1854
  "projectId",
1376
1855
  "scope_type",
1377
- "type",
1378
1856
  "project_id",
1379
1857
  ],
1380
1858
  },
@@ -1389,10 +1867,11 @@ function readEffectiveWhitelistFromConfig(
1389
1867
  "uploadHost",
1390
1868
  ],
1391
1869
  runtimeReadonlyFields: [
1870
+ "scope_kind",
1871
+ "scopeKind",
1392
1872
  "scopeType",
1393
1873
  "projectId",
1394
1874
  "scope_type",
1395
- "type",
1396
1875
  "project_id",
1397
1876
  ],
1398
1877
  },
@@ -1505,8 +1984,12 @@ function calcSchemaHash(rawSchema: unknown): string {
1505
1984
  return calcSchemaHashFromRuntime(rawSchema);
1506
1985
  }
1507
1986
 
1508
- function updateConfigVersion(config: Record<string, unknown>): void {
1509
- configVersionHash = calcSchemaHash(config);
1987
+ function updateConfigVersion(
1988
+ config: Record<string, unknown>,
1989
+ preferredVersion?: string,
1990
+ ): void {
1991
+ const normalizedPreferredVersion = String(preferredVersion || "").trim();
1992
+ configVersionHash = normalizedPreferredVersion || calcSchemaHash(config);
1510
1993
  configVersionUpdatedAt = nowIso();
1511
1994
  }
1512
1995
 
@@ -1674,7 +2157,11 @@ async function startInternalApiServer(): Promise<void> {
1674
2157
  req.method === "GET" &&
1675
2158
  reqUrl.pathname === "/internal/runtime/bots/status"
1676
2159
  ) {
1677
- await probeAndRefreshStatuses();
2160
+ const forceProbe = parseBoolFlag(reqUrl.searchParams.get("force_probe"));
2161
+ await probeAndRefreshStatuses({
2162
+ force: forceProbe,
2163
+ reason: forceProbe ? "status_force" : "status_read",
2164
+ });
1678
2165
  const bots = Array.from(runtimeStatusRegistry.values());
1679
2166
  const summary = summarizeBots();
1680
2167
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -1685,6 +2172,22 @@ async function startInternalApiServer(): Promise<void> {
1685
2172
  runtime_state: runtimeState,
1686
2173
  version: configVersionHash || "unknown",
1687
2174
  generated_at: nowIso(),
2175
+ probe_meta: {
2176
+ ttl_ms: runtimeStatusProbeTtlMs,
2177
+ per_account_timeout_ms: runtimeStatusProbeTimeoutMs,
2178
+ per_account_timeout_ms_weixin: runtimeStatusProbeTimeoutMsWeixin,
2179
+ concurrency: runtimeStatusProbeConcurrency,
2180
+ force_probe: forceProbe,
2181
+ in_flight: Boolean(probeInFlight),
2182
+ is_fresh: isProbeFresh(),
2183
+ last_started_at: lastProbeStartedAt,
2184
+ last_finished_at: lastProbeFinishedAt,
2185
+ last_duration_ms: lastProbeDurationMs,
2186
+ cache_hit_total: runtimeStatusCacheHitTotal,
2187
+ singleflight_reused_total: runtimeStatusSingleflightReusedTotal,
2188
+ timeout_total: runtimeStatusProbeTimeoutTotal,
2189
+ },
2190
+ runtime_heartbeat: buildRuntimeHeartbeatSnapshot(),
1688
2191
  summary,
1689
2192
  bots,
1690
2193
  }),
@@ -1708,6 +2211,18 @@ async function startInternalApiServer(): Promise<void> {
1708
2211
  );
1709
2212
  return;
1710
2213
  }
2214
+ if (
2215
+ req.method === "GET" &&
2216
+ reqUrl.pathname === "/internal/runtime/logs/download"
2217
+ ) {
2218
+ const maxBytesRaw = String(
2219
+ reqUrl.searchParams.get("max_bytes") ||
2220
+ reqUrl.searchParams.get("maxBytes") ||
2221
+ "",
2222
+ ).trim();
2223
+ streamRuntimeLogDownload(res, { maxBytesRaw });
2224
+ return;
2225
+ }
1711
2226
  if (
1712
2227
  req.method === "GET" &&
1713
2228
  reqUrl.pathname === "/internal/runtime/plugins/config-schema"
@@ -1776,85 +2291,229 @@ async function startInternalApiServer(): Promise<void> {
1776
2291
  );
1777
2292
  return;
1778
2293
  }
1779
- if (
1780
- req.method === "POST" &&
1781
- reqUrl.pathname === "/internal/runtime/config/reload"
1782
- ) {
1783
- let pullDroppedFields: Array<{
1784
- channel: string;
1785
- accountId: string;
1786
- field: string;
1787
- }> = [];
1788
- const canPull = Boolean(runtimeConfigPullUrl);
1789
- const canReadLocal = Boolean(
1790
- loadedConfigPathForRuntime && fs.existsSync(loadedConfigPathForRuntime),
1791
- );
1792
- if (!canPull && !canReadLocal && !loadedConfigForRuntime) {
2294
+ const botControlAction =
2295
+ req.method === "POST" && reqUrl.pathname === "/internal/runtime/bot/start"
2296
+ ? "start"
2297
+ : req.method === "POST" &&
2298
+ reqUrl.pathname === "/internal/runtime/bot/stop"
2299
+ ? "stop"
2300
+ : req.method === "POST" &&
2301
+ reqUrl.pathname === "/internal/runtime/bot/restart"
2302
+ ? "restart"
2303
+ : null;
2304
+ if (botControlAction) {
2305
+ const body = await readRequestJson(req);
2306
+ const platform = normalizeRuntimePlatform(body.platform || body.channel);
2307
+ const botAccountId = String(
2308
+ body.bot_account_id || body.account_id || body.accountId || "",
2309
+ ).trim();
2310
+ const reason =
2311
+ String(body.reason || "").trim() || `manual_${botControlAction}`;
2312
+
2313
+ if (!platform || !botAccountId) {
1793
2314
  res.writeHead(400, { "Content-Type": "application/json" });
1794
2315
  res.end(
1795
2316
  JSON.stringify({
1796
2317
  ok: false,
1797
- error: "no runtime config source available",
2318
+ error: "platform and bot_account_id are required",
1798
2319
  }),
1799
2320
  );
1800
2321
  return;
1801
2322
  }
1802
- if (runtimeConfigPullUrl) {
1803
- const pullResult = await pullRuntimeConfigFromPython(true);
1804
- if (!pullResult.ok) {
1805
- res.writeHead(500, { "Content-Type": "application/json" });
1806
- res.end(
1807
- JSON.stringify({
1808
- ok: false,
1809
- error: `reload pull failed: ${pullResult.error || "unknown"}`,
1810
- }),
1811
- );
1812
- return;
1813
- }
1814
- if ((pullResult.dropped_fields || []).length > 0) {
1815
- pullDroppedFields = pullResult.dropped_fields || [];
1816
- console.log(
1817
- `[bridges/main] reload dropped fields from pull: ${pullResult.dropped_fields
1818
- ?.map((item) => `${item.channel}/${item.accountId}:${item.field}`)
1819
- .join(", ")}`,
1820
- );
1821
- }
2323
+
2324
+ if (runtimeState === "restarting") {
2325
+ res.writeHead(409, { "Content-Type": "application/json" });
2326
+ res.end(
2327
+ JSON.stringify({
2328
+ ok: false,
2329
+ error:
2330
+ "runtime is restarting, bot control is temporarily unavailable",
2331
+ }),
2332
+ );
2333
+ return;
1822
2334
  }
1823
- if (
1824
- loadedConfigPathForRuntime &&
1825
- fs.existsSync(loadedConfigPathForRuntime)
1826
- ) {
1827
- try {
1828
- const content = fs.readFileSync(loadedConfigPathForRuntime, "utf-8");
1829
- const parsed = JSON.parse(content) as Record<string, unknown>;
1830
- const normalizedResult = normalizeRuntimeConfigByWhitelist(parsed);
1831
- loadedConfigForRuntime = normalizedResult.normalized;
1832
- updateConfigVersion(normalizedResult.normalized);
1833
- } catch (err) {
1834
- res.writeHead(500, { "Content-Type": "application/json" });
1835
- res.end(
1836
- JSON.stringify({
1837
- ok: false,
1838
- error: `reload failed: ${(err as Error).message}`,
1839
- }),
1840
- );
1841
- return;
1842
- }
2335
+
2336
+ const channelKey = platformToChannelKey(platform);
2337
+ if (!isChannelEnabled(channelKey)) {
2338
+ res.writeHead(400, { "Content-Type": "application/json" });
2339
+ res.end(
2340
+ JSON.stringify({
2341
+ ok: false,
2342
+ error: `channel disabled: ${channelKey}`,
2343
+ }),
2344
+ );
2345
+ return;
1843
2346
  }
1844
- if (loadedConfigForRuntime) {
1845
- markConfiguredBotsAsConnecting(loadedConfigForRuntime);
1846
- await probeAndRefreshStatuses();
2347
+ if (!isConfiguredRuntimeAccount(platform, botAccountId)) {
2348
+ res.writeHead(400, { "Content-Type": "application/json" });
2349
+ res.end(
2350
+ JSON.stringify({
2351
+ ok: false,
2352
+ error: `bot account not configured: ${platform}/${botAccountId}`,
2353
+ }),
2354
+ );
2355
+ return;
2356
+ }
2357
+
2358
+ const inFlightKey = keyOf(platform, botAccountId);
2359
+ if (botControlInFlight.has(inFlightKey)) {
2360
+ res.writeHead(409, { "Content-Type": "application/json" });
2361
+ res.end(
2362
+ JSON.stringify({
2363
+ ok: false,
2364
+ error: "bot control operation already in progress",
2365
+ platform,
2366
+ bot_account_id: botAccountId,
2367
+ }),
2368
+ );
2369
+ return;
2370
+ }
2371
+ botControlInFlight.add(inFlightKey);
2372
+
2373
+ const control = resolveRuntimeBridgeControl(platform);
2374
+ if (!control) {
2375
+ botControlInFlight.delete(inFlightKey);
2376
+ res.writeHead(500, { "Content-Type": "application/json" });
2377
+ res.end(
2378
+ JSON.stringify({
2379
+ ok: false,
2380
+ error: `${platform} bridge control not available`,
2381
+ }),
2382
+ );
2383
+ return;
2384
+ }
2385
+
2386
+ const operationId = generateOperationId(`bot_${botControlAction}`);
2387
+ const key = keyOf(platform, botAccountId);
2388
+ const previous = runtimeStatusRegistry.get(key);
2389
+ const now = nowIso();
2390
+
2391
+ try {
2392
+ if (botControlAction === "start") {
2393
+ if (typeof control.startAccount !== "function") {
2394
+ res.writeHead(501, { "Content-Type": "application/json" });
2395
+ res.end(
2396
+ JSON.stringify({
2397
+ ok: false,
2398
+ error: `${platform} bridge startAccount not implemented`,
2399
+ }),
2400
+ );
2401
+ return;
2402
+ }
2403
+ upsertRuntimeStatus({
2404
+ platform,
2405
+ bot_account_id: botAccountId,
2406
+ link_status: "connecting",
2407
+ started_at: previous?.started_at || now,
2408
+ last_heartbeat_at: now,
2409
+ last_error: null,
2410
+ reconnect_count: previous?.reconnect_count || 0,
2411
+ last_event: "manual_start_request",
2412
+ status_source: "manual",
2413
+ last_probe_at: previous?.last_probe_at || null,
2414
+ });
2415
+ await control.startAccount(botAccountId);
2416
+ } else if (botControlAction === "stop") {
2417
+ if (typeof control.stopAccount !== "function") {
2418
+ res.writeHead(501, { "Content-Type": "application/json" });
2419
+ res.end(
2420
+ JSON.stringify({
2421
+ ok: false,
2422
+ error: `${platform} bridge stopAccount not implemented`,
2423
+ }),
2424
+ );
2425
+ return;
2426
+ }
2427
+ await control.stopAccount(botAccountId);
2428
+ upsertRuntimeStatus({
2429
+ platform,
2430
+ bot_account_id: botAccountId,
2431
+ link_status: "disconnected",
2432
+ started_at: previous?.started_at || now,
2433
+ last_heartbeat_at: now,
2434
+ last_error: null,
2435
+ reconnect_count: previous?.reconnect_count || 0,
2436
+ last_event: "manual_stop_request",
2437
+ status_source: "manual",
2438
+ last_probe_at: previous?.last_probe_at || null,
2439
+ });
2440
+ } else {
2441
+ if (typeof control.restartAccount !== "function") {
2442
+ res.writeHead(501, { "Content-Type": "application/json" });
2443
+ res.end(
2444
+ JSON.stringify({
2445
+ ok: false,
2446
+ error: `${platform} bridge restartAccount not implemented`,
2447
+ }),
2448
+ );
2449
+ return;
2450
+ }
2451
+ upsertRuntimeStatus({
2452
+ platform,
2453
+ bot_account_id: botAccountId,
2454
+ link_status: "connecting",
2455
+ started_at: previous?.started_at || now,
2456
+ last_heartbeat_at: now,
2457
+ last_error: null,
2458
+ reconnect_count: previous?.reconnect_count || 0,
2459
+ last_event: "manual_restart_request",
2460
+ status_source: "manual",
2461
+ last_probe_at: previous?.last_probe_at || null,
2462
+ });
2463
+ await control.restartAccount(botAccountId);
2464
+ }
2465
+
2466
+ const bot = runtimeStatusRegistry.get(key) || null;
2467
+ res.writeHead(200, { "Content-Type": "application/json" });
2468
+ res.end(
2469
+ JSON.stringify({
2470
+ ok: true,
2471
+ status: "accepted",
2472
+ action: botControlAction,
2473
+ operation_id: operationId,
2474
+ platform,
2475
+ bot_account_id: botAccountId,
2476
+ reason,
2477
+ runtime_instance_id: runtimeInstanceId,
2478
+ runtime_state: runtimeState,
2479
+ issued_at: now,
2480
+ bot,
2481
+ }),
2482
+ );
2483
+ } catch (err) {
2484
+ const message =
2485
+ err instanceof Error ? err.message : "bot control failed";
2486
+ upsertRuntimeStatus({
2487
+ platform,
2488
+ bot_account_id: botAccountId,
2489
+ link_status: "error",
2490
+ started_at: previous?.started_at || now,
2491
+ last_heartbeat_at: now,
2492
+ last_error: message,
2493
+ reconnect_count: previous?.reconnect_count || 0,
2494
+ last_event: `manual_${botControlAction}_failed`,
2495
+ status_source: "manual",
2496
+ last_probe_at: previous?.last_probe_at || null,
2497
+ });
2498
+ res.writeHead(500, { "Content-Type": "application/json" });
2499
+ res.end(
2500
+ JSON.stringify({
2501
+ ok: false,
2502
+ status: "fail",
2503
+ action: botControlAction,
2504
+ operation_id: operationId,
2505
+ platform,
2506
+ bot_account_id: botAccountId,
2507
+ reason,
2508
+ error: message,
2509
+ runtime_instance_id: runtimeInstanceId,
2510
+ runtime_state: runtimeState,
2511
+ issued_at: now,
2512
+ }),
2513
+ );
2514
+ } finally {
2515
+ botControlInFlight.delete(inFlightKey);
1847
2516
  }
1848
- res.writeHead(200, { "Content-Type": "application/json" });
1849
- res.end(
1850
- JSON.stringify({
1851
- ok: true,
1852
- reloaded: true,
1853
- version: configVersionHash || "unknown",
1854
- updated_at: configVersionUpdatedAt,
1855
- dropped_fields: pullDroppedFields,
1856
- }),
1857
- );
1858
2517
  return;
1859
2518
  }
1860
2519
  if (
@@ -1864,6 +2523,7 @@ async function startInternalApiServer(): Promise<void> {
1864
2523
  const body = await readRequestJson(req);
1865
2524
  const config = body.config as Record<string, unknown> | undefined;
1866
2525
  const persist = body.persist !== false;
2526
+ const requestedVersion = String(body.version || "").trim();
1867
2527
  if (!config || typeof config !== "object") {
1868
2528
  res.writeHead(400, { "Content-Type": "application/json" });
1869
2529
  res.end(JSON.stringify({ ok: false, error: "config is required" }));
@@ -1893,9 +2553,13 @@ async function startInternalApiServer(): Promise<void> {
1893
2553
  loadedConfigForRuntime,
1894
2554
  loadedConfigPathForRuntime,
1895
2555
  );
1896
- updateConfigVersion(normalizedResult.normalized);
2556
+ updateConfigVersion(
2557
+ normalizedResult.normalized,
2558
+ requestedVersion || undefined,
2559
+ );
2560
+ applyRemoteRuntimeConfigToPluginHome();
1897
2561
  markConfiguredBotsAsConnecting(normalizedResult.normalized);
1898
- await probeAndRefreshStatuses();
2562
+ await probeAndRefreshStatuses({ force: true, reason: "config_apply" });
1899
2563
 
1900
2564
  if (persist) {
1901
2565
  const targetPath =
@@ -1929,6 +2593,7 @@ async function startInternalApiServer(): Promise<void> {
1929
2593
  status: "applied",
1930
2594
  persisted: persist,
1931
2595
  configPath: loadedConfigPathForRuntime,
2596
+ version: configVersionHash || "unknown",
1932
2597
  }),
1933
2598
  );
1934
2599
  return;
@@ -1937,6 +2602,31 @@ async function startInternalApiServer(): Promise<void> {
1937
2602
  req.method === "POST" &&
1938
2603
  reqUrl.pathname === "/internal/runtime/restart-all"
1939
2604
  ) {
2605
+ if (runtimeState === "restarting") {
2606
+ res.writeHead(409, { "Content-Type": "application/json" });
2607
+ res.end(
2608
+ JSON.stringify({
2609
+ ok: false,
2610
+ status: "busy",
2611
+ error: "runtime is already restarting",
2612
+ runtime_instance_id: runtimeInstanceId,
2613
+ }),
2614
+ );
2615
+ return;
2616
+ }
2617
+ if (botControlInFlight.size > 0) {
2618
+ res.writeHead(409, { "Content-Type": "application/json" });
2619
+ res.end(
2620
+ JSON.stringify({
2621
+ ok: false,
2622
+ status: "busy",
2623
+ error: "bot control operations are in progress",
2624
+ in_flight_count: botControlInFlight.size,
2625
+ runtime_instance_id: runtimeInstanceId,
2626
+ }),
2627
+ );
2628
+ return;
2629
+ }
1940
2630
  const body = await readRequestJson(req);
1941
2631
  const restartModeRaw = String(body.mode || "")
1942
2632
  .trim()
@@ -2277,12 +2967,14 @@ async function startInternalApiServer(): Promise<void> {
2277
2967
  const platform = String(body.platform || "").trim();
2278
2968
  const botAccountId = String(body.bot_account_id || "").trim();
2279
2969
  const linkStatus = String(body.link_status || "").trim();
2970
+ const eventName = String(body.event_name || "").trim() || null;
2971
+ const isHeartbeatEvent = isHeartbeatOnlyEvent(eventName);
2280
2972
  if (
2281
2973
  (platform !== "dingtalk" &&
2282
2974
  platform !== "feishu" &&
2283
2975
  platform !== "weixin") ||
2284
2976
  !botAccountId ||
2285
- !linkStatus
2977
+ (!isHeartbeatEvent && !linkStatus)
2286
2978
  ) {
2287
2979
  res.writeHead(400, { "Content-Type": "application/json" });
2288
2980
  res.end(JSON.stringify({ ok: false, error: "invalid payload" }));
@@ -2303,6 +2995,18 @@ async function startInternalApiServer(): Promise<void> {
2303
2995
  );
2304
2996
  return;
2305
2997
  }
2998
+ const heartbeatAt = String(body.last_heartbeat_at || nowIso());
2999
+ upsertRuntimeHeartbeat({
3000
+ platform: p,
3001
+ bot_account_id: botAccountId,
3002
+ last_heartbeat_at: heartbeatAt,
3003
+ last_event: eventName,
3004
+ });
3005
+ if (isHeartbeatEvent) {
3006
+ res.writeHead(200, { "Content-Type": "application/json" });
3007
+ res.end(JSON.stringify({ ok: true, heartbeat_only: true }));
3008
+ return;
3009
+ }
2306
3010
  const normalizedStatus = [
2307
3011
  "connecting",
2308
3012
  "connected",
@@ -2313,7 +3017,6 @@ async function startInternalApiServer(): Promise<void> {
2313
3017
  ? (linkStatus as RuntimeBotStatus["link_status"])
2314
3018
  : "degraded";
2315
3019
  const prev = runtimeStatusRegistry.get(keyOf(p, botAccountId));
2316
- const eventName = String(body.event_name || "").trim() || null;
2317
3020
  const reconnectCountRaw = body.reconnect_count;
2318
3021
  const reconnectCountFromBody =
2319
3022
  typeof reconnectCountRaw === "number"
@@ -2330,7 +3033,7 @@ async function startInternalApiServer(): Promise<void> {
2330
3033
  bot_account_id: botAccountId,
2331
3034
  link_status: normalizedStatus,
2332
3035
  started_at: String(body.started_at || prev?.started_at || nowIso()),
2333
- last_heartbeat_at: String(body.last_heartbeat_at || nowIso()),
3036
+ last_heartbeat_at: heartbeatAt,
2334
3037
  last_error: String(body.last_error || "") || null,
2335
3038
  reconnect_count: reconnectCount,
2336
3039
  last_event: eventName,
@@ -2484,6 +3187,7 @@ function printConfigBootstrapGuide(): void {
2484
3187
  }
2485
3188
 
2486
3189
  async function main(): Promise<void> {
3190
+ debugger;
2487
3191
  const logFilePath = setupBridgeLogger("bridges-main");
2488
3192
  console.log("[bridges/main] log file:", logFilePath);
2489
3193