ylib-syim 0.0.27 → 0.0.29

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
@@ -22,6 +22,8 @@ import {
22
22
  saveRuntimeStateSnapshot,
23
23
  } from "./runtime/runtime-state-store.ts";
24
24
 
25
+ process.env.TZ = "Asia/Shanghai";
26
+
25
27
  // bridges/main 是 Node 侧运行时中枢:
26
28
  // 1) 维护配置与 bridge 进程生命周期;
27
29
  // 2) 统一汇聚 status/heartbeat/diagnostic/probe 四类运行态;
@@ -1267,6 +1269,12 @@ function nowIso(): string {
1267
1269
  return new Date().toISOString();
1268
1270
  }
1269
1271
 
1272
+ /** 将 UTC ISO 字符串转为东八区 ISO 字符串(+08:00)。用于「最近探测」等前台展示字段。 */
1273
+ function toChina8Iso(utcIso: string): string {
1274
+ const ms = new Date(utcIso).getTime() + 8 * 60 * 60 * 1000;
1275
+ return new Date(ms).toISOString().replace('Z', '+08:00');
1276
+ }
1277
+
1270
1278
  // rotateRuntimeInstanceId: 刷新运行实例标识。
1271
1279
  function rotateRuntimeInstanceId(): string {
1272
1280
  // 用于标识一次新的运行代际(例如 restart-all 后)。
@@ -1597,6 +1605,10 @@ function resolveRuntimeRunningForUpdate(params: {
1597
1605
  nextLastEvent: string | null | undefined;
1598
1606
  nextRestartPending: boolean;
1599
1607
  }): boolean {
1608
+ // running 推导规则:
1609
+ // 1) 优先尊重显式 hintedRunning;
1610
+ // 2) event 断链(非 stop 生命周期)不等价于“进程停止”,避免误判;
1611
+ // 3) manual stop / stopped lifecycle / restart_pending 才会收敛为 running=false。
1600
1612
  const {
1601
1613
  previous,
1602
1614
  hintedRunning,
@@ -1651,6 +1663,10 @@ function resolveRuntimeRestartPendingForUpdate(params: {
1651
1663
  nextLinkStatus: RuntimeBotStatus["link_status"];
1652
1664
  nextStatusSource?: RuntimeBotStatus["status_source"] | undefined;
1653
1665
  }): boolean {
1666
+ // restart_pending 推导规则:
1667
+ // - 显式 hint 优先;
1668
+ // - 无显式 hint 时,仅 probe/connected 能清 pending;
1669
+ // - event/manual 的 connected 仍可能是过渡态,保持 pending=true。
1654
1670
  const {
1655
1671
  previous,
1656
1672
  hintedRestartPending,
@@ -1713,6 +1729,7 @@ function resolveStatusProtectWindowMs(entry: RuntimeBotStatus): number {
1713
1729
  function isProbeDowngradeLinkStatus(
1714
1730
  status: RuntimeBotStatus["link_status"],
1715
1731
  ): boolean {
1732
+ // 这些状态都属于“probe 视角的降级态”,可能被新鲜 event 保护逻辑拦截。
1716
1733
  return (
1717
1734
  status === "error" ||
1718
1735
  status === "disconnected" ||
@@ -1723,6 +1740,8 @@ function isProbeDowngradeLinkStatus(
1723
1740
  function resolveRuntimeLifecycleAnchorMsForGuard(
1724
1741
  status: RuntimeBotStatus,
1725
1742
  ): number | null {
1743
+ // 生命周期锚点用于计算保护窗口年龄:
1744
+ // started_at 与 transition_at 取较新值,避免老时间戳误伤新生命周期。
1726
1745
  const startedAtMs = parseIsoTimeMs(
1727
1746
  String(status.started_at || "").trim() || null,
1728
1747
  );
@@ -1739,6 +1758,7 @@ function isWithinRuntimeConnectGraceForGuard(
1739
1758
  status: RuntimeBotStatus,
1740
1759
  nowMs = Date.now(),
1741
1760
  ): boolean {
1761
+ // connect grace 内允许“保守展示”,不急于接受 probe 降级结论。
1742
1762
  const lifecycleAnchorMs = resolveRuntimeLifecycleAnchorMsForGuard(status);
1743
1763
  if (lifecycleAnchorMs == null) return false;
1744
1764
  const lifecycleAgeMs = Math.max(0, nowMs - lifecycleAnchorMs);
@@ -1750,6 +1770,7 @@ function hasFreshRuntimeEventLivenessForProbeGuard(
1750
1770
  nowMs = Date.now(),
1751
1771
  lifecycleAnchorMs: number | null = null,
1752
1772
  ): boolean {
1773
+ // 事件活性保护:只有在生命周期内且事件足够新鲜,才允许覆盖 probe 降级。
1753
1774
  const eventLiveness = runtimeEventLivenessRegistry.get(key);
1754
1775
  const eventAtMs = parseIsoTimeMs(
1755
1776
  String(eventLiveness?.last_event_at || "").trim() || null,
@@ -1765,6 +1786,7 @@ function hasFreshRuntimeEventLivenessForProbeGuard(
1765
1786
  function isConnectedRuntimeEventLivenessName(
1766
1787
  eventName: string | null | undefined,
1767
1788
  ): boolean {
1789
+ // 这些事件被视为“数据面已连通”的强证据。
1768
1790
  const normalized = String(eventName || "")
1769
1791
  .trim()
1770
1792
  .toLowerCase();
@@ -1783,6 +1805,7 @@ function hasFreshConnectedRuntimeEventLivenessForRecovery(
1783
1805
  key: string,
1784
1806
  nowMs = Date.now(),
1785
1807
  ): boolean {
1808
+ // 仅当最近事件本身是 connected 类强证据,才允许作为恢复旁路依据。
1786
1809
  const eventLiveness = runtimeEventLivenessRegistry.get(key);
1787
1810
  if (!isConnectedRuntimeEventLivenessName(eventLiveness?.last_event)) {
1788
1811
  return false;
@@ -1800,6 +1823,7 @@ function shouldGuardProbeDowngradeByFreshEvent(params: {
1800
1823
  prev: RuntimeBotStatus;
1801
1824
  next: RuntimeBotStatus;
1802
1825
  }): boolean {
1826
+ // 规则:connected -> probe 降级时,如果有新鲜 event 事实,则先抑制降级抖动。
1803
1827
  const { key, prev, next } = params;
1804
1828
  if (next.status_source !== "probe") return false;
1805
1829
  if (!isProbeDowngradeLinkStatus(next.link_status)) return false;
@@ -1819,6 +1843,7 @@ function shouldGuardProbeConnectedByEventBroken(params: {
1819
1843
  prev: RuntimeBotStatus;
1820
1844
  next: RuntimeBotStatus;
1821
1845
  }): boolean {
1846
+ // 规则:event 明确 broken 后,probe 的 connected 需要 event 新鲜度二次确认。
1822
1847
  const { key, prev, next } = params;
1823
1848
  const prevSource = prev.status_source || "probe";
1824
1849
  const nextSource = next.status_source || "probe";
@@ -1842,6 +1867,10 @@ type RuntimeConvergenceEvidence = "manual" | "event" | "probe";
1842
1867
  type RuntimeConvergenceSignal = "success" | "failure" | "transient";
1843
1868
 
1844
1869
  function resolveRuntimeConvergencePhase(status: RuntimeBotStatus): RuntimeConvergencePhase {
1870
+ // 收敛阶段:
1871
+ // pending: restart_pending=true;
1872
+ // stopped: manual/disconnected 或 stop 生命周期事件;
1873
+ // steady: 其余状态。
1845
1874
  if (status.restart_pending === true) return "pending";
1846
1875
  if (status.status_source === "manual" && status.link_status === "disconnected") {
1847
1876
  return "stopped";
@@ -1861,6 +1890,7 @@ function resolveRuntimeConvergencePhase(status: RuntimeBotStatus): RuntimeConver
1861
1890
  function resolveRuntimeConvergenceEvidence(
1862
1891
  source: RuntimeBotStatus["status_source"],
1863
1892
  ): RuntimeConvergenceEvidence {
1893
+ // 证据源优先级由写入端控制,这里仅做枚举归一化。
1864
1894
  if (source === "manual") return "manual";
1865
1895
  if (source === "event") return "event";
1866
1896
  return "probe";
@@ -1869,6 +1899,8 @@ function resolveRuntimeConvergenceEvidence(
1869
1899
  function resolveRuntimeConvergenceSignal(
1870
1900
  entry: RuntimeBotStatus,
1871
1901
  ): RuntimeConvergenceSignal {
1902
+ // 信号语义:
1903
+ // success=connected;failure=probe/event 报告的明确失败;其余为 transient。
1872
1904
  if (entry.link_status === "connected") return "success";
1873
1905
  const evidence = resolveRuntimeConvergenceEvidence(entry.status_source);
1874
1906
  if (
@@ -1887,6 +1919,7 @@ function shouldGuardProbeConnectedWhilePendingWithoutEvent(params: {
1887
1919
  prev: RuntimeBotStatus;
1888
1920
  next: RuntimeBotStatus;
1889
1921
  }): boolean {
1922
+ // pending 生命周期里,probe/connected 若缺少新鲜 connected event 证据则先抑制。
1890
1923
  const { key, prev, next } = params;
1891
1924
  const prevPhase = resolveRuntimeConvergencePhase(prev);
1892
1925
  if (prevPhase !== "pending") return false;
@@ -1957,6 +1990,7 @@ function logSuppressedTransition(params: {
1957
1990
  function projectRuntimeBotStatusFromSnapshot(
1958
1991
  snapshot: RuntimeChannelSnapshotEntry,
1959
1992
  ): RuntimeBotStatus {
1993
+ // 快照结构 -> 运行态结构的单点投影,避免多处手工映射字段。
1960
1994
  return {
1961
1995
  platform: snapshot.platform,
1962
1996
  bot_account_id: snapshot.accountId,
@@ -1983,6 +2017,7 @@ function projectRuntimeBotStatusFromSnapshot(
1983
2017
  }
1984
2018
 
1985
2019
  function syncRuntimeChannelSnapshotFromStatus(status: RuntimeBotStatus): void {
2020
+ // 每次状态写入后同步快照镜像,保证对外输出(含重启恢复)稳定可复现。
1986
2021
  const key = keyOf(status.platform, status.bot_account_id);
1987
2022
  runtimeChannelSnapshotRegistry.set(key, {
1988
2023
  key,
@@ -2015,12 +2050,14 @@ function syncRuntimeChannelSnapshotFromStatus(status: RuntimeBotStatus): void {
2015
2050
  }
2016
2051
 
2017
2052
  function getRuntimeBotStatusByKey(key: string): RuntimeBotStatus | undefined {
2053
+ // 优先读 channel snapshot(包含更多持久化字段),fallback 到内存 registry。
2018
2054
  const snapshot = runtimeChannelSnapshotRegistry.get(key);
2019
2055
  if (snapshot) return projectRuntimeBotStatusFromSnapshot(snapshot);
2020
2056
  return runtimeStatusRegistry.get(key);
2021
2057
  }
2022
2058
 
2023
2059
  function listRuntimeBotStatuses(): RuntimeBotStatus[] {
2060
+ // 对外列表统一按 platform+bot_account_id 排序,避免接口结果抖动。
2024
2061
  if (runtimeChannelSnapshotRegistry.size > 0) {
2025
2062
  return Array.from(runtimeChannelSnapshotRegistry.values())
2026
2063
  .map((item) => projectRuntimeBotStatusFromSnapshot(item))
@@ -4496,7 +4533,12 @@ function markRuntimeAccountsConnecting(
4496
4533
  platform: RuntimePlatform,
4497
4534
  accountIds: string[],
4498
4535
  lastEvent = "config_loaded",
4536
+ options?: {
4537
+ preserveExisting?: boolean;
4538
+ },
4499
4539
  ): string[] {
4540
+ // 配置收敛期把指定账号打到 manual/connecting。
4541
+ // preserveExisting=true 时只标记“新增账号”,不覆盖已存在账号当前态。
4500
4542
  const normalizedIds = Array.from(
4501
4543
  new Set(
4502
4544
  accountIds
@@ -4506,7 +4548,12 @@ function markRuntimeAccountsConnecting(
4506
4548
  );
4507
4549
  if (normalizedIds.length === 0) return [];
4508
4550
  const startedAt = nowIso();
4551
+ const preserveExisting = options?.preserveExisting === true;
4509
4552
  for (const accountId of normalizedIds) {
4553
+ if (preserveExisting) {
4554
+ const existing = getRuntimeBotStatusByKey(keyOf(platform, accountId));
4555
+ if (existing) continue;
4556
+ }
4510
4557
  clearRuntimeAccountManualStop(platform, accountId);
4511
4558
  upsertRuntimeStatus({
4512
4559
  platform,
@@ -4625,6 +4672,11 @@ async function reconcileRuntimeStateForConfigDelta(params: {
4625
4672
  nextConfig: Record<string, unknown>;
4626
4673
  trigger: string;
4627
4674
  }): Promise<void> {
4675
+ // 增量 reconcile:
4676
+ // 1) 按平台计算 added/removed;
4677
+ // 2) removed 账号尝试 stop 并清理状态;
4678
+ // 3) added 账号打 connecting(preserveExisting=true);
4679
+ // 4) 输出结构化审计日志。
4628
4680
  const previousAccounts = collectConfiguredAccountIdsByPlatform(
4629
4681
  params.previousConfig,
4630
4682
  );
@@ -4662,7 +4714,9 @@ async function reconcileRuntimeStateForConfigDelta(params: {
4662
4714
  clearPlatformStatuses(platform);
4663
4715
  } else {
4664
4716
  prunePlatformStatusesByAccounts(platform, nextIds, params.nextConfig);
4665
- markRuntimeAccountsConnecting(platform, added);
4717
+ markRuntimeAccountsConnecting(platform, added, "config_delta_added", {
4718
+ preserveExisting: true,
4719
+ });
4666
4720
  }
4667
4721
 
4668
4722
  for (const accountId of added) {
@@ -5129,6 +5183,7 @@ function normalizeRuntimePlatform(input: unknown): RuntimePlatform | null {
5129
5183
  }
5130
5184
 
5131
5185
  function normalizeRuntimeLinkStatus(input: unknown): RuntimeBotStatus["link_status"] | null {
5186
+ // status sink 入站时的状态白名单解析;未知值直接忽略。
5132
5187
  const raw = String(input || "")
5133
5188
  .trim()
5134
5189
  .toLowerCase();
@@ -5148,6 +5203,8 @@ function resolveLinkStatusFromSinkPayload(
5148
5203
  payload: RuntimeStatusSinkPayload,
5149
5204
  previous: RuntimeBotStatus | undefined,
5150
5205
  ): RuntimeBotStatus["link_status"] | null {
5206
+ // sink 状态决议顺序:
5207
+ // explicit link_status > connected 布尔 > running=false > previous.link_status。
5151
5208
  const explicit = normalizeRuntimeLinkStatus(payload.link_status);
5152
5209
  if (explicit) return explicit;
5153
5210
  if (payload.connected === true) return "connected";
@@ -5157,11 +5214,16 @@ function resolveLinkStatusFromSinkPayload(
5157
5214
  }
5158
5215
 
5159
5216
  function applyRuntimeStatusSinkPayload(payload: RuntimeStatusSinkPayload): void {
5217
+ // status sink 是“外部状态事件入站”通道:
5218
+ // 1) 先做账号合法性与配置态校验;
5219
+ // 2) 再推导 link_status/restart_pending/running;
5220
+ // 3) 最后统一走 upsertRuntimeStatus 写入状态机。
5160
5221
  const platform = normalizeRuntimePlatform(payload.platform);
5161
5222
  const botAccountId = String(payload.bot_account_id || "").trim();
5162
5223
  if (!platform || !botAccountId) return;
5163
5224
  const normalizedAccountId = normalizeRuntimeAccountId(platform, botAccountId);
5164
5225
  if (!isConfiguredRuntimeAccount(platform, normalizedAccountId || botAccountId)) {
5226
+ // 未配置账号直接丢弃,避免历史脏事件或删除后迟到事件污染状态面板。
5165
5227
  logRuntimeStatusDebug(
5166
5228
  `status sink ignored: unconfigured account platform=${platform} account=${normalizedAccountId || botAccountId}`,
5167
5229
  );
@@ -5177,6 +5239,7 @@ function applyRuntimeStatusSinkPayload(payload: RuntimeStatusSinkPayload): void
5177
5239
  statusSourceRaw === "manual" || statusSourceRaw === "probe" || statusSourceRaw === "event"
5178
5240
  ? (statusSourceRaw as RuntimeBotStatus["status_source"])
5179
5241
  : "event";
5242
+ // restart_pending / running 都由统一推导函数收敛,避免多处规则分叉。
5180
5243
  const nextRestartPending = resolveRuntimeRestartPendingForUpdate({
5181
5244
  previous,
5182
5245
  hintedRestartPending:
@@ -5203,6 +5266,7 @@ function applyRuntimeStatusSinkPayload(payload: RuntimeStatusSinkPayload): void
5203
5266
  });
5204
5267
 
5205
5268
  if (statusSource === "event") {
5269
+ // 只有 event 来源才更新 event liveness 真源,probe/manual 不写这条链路。
5206
5270
  upsertRuntimeEventLiveness({
5207
5271
  platform,
5208
5272
  bot_account_id: normalizedAccountId || botAccountId,
@@ -5348,7 +5412,7 @@ async function invokeGatewayMethodByChannel(args: {
5348
5412
  function parseOptionalBoolFlag(input: unknown): boolean | null {
5349
5413
  // 支持多种布尔表达,无法识别时返回 null 交给上层兜底。
5350
5414
  if (input == null) return null;
5351
- const normalized = String(input || "")
5415
+ const normalized = String(input)
5352
5416
  .trim()
5353
5417
  .toLowerCase();
5354
5418
  if (!normalized) return null;
@@ -5592,7 +5656,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5592
5656
  upsertRuntimeProbe({
5593
5657
  platform: "dingtalk",
5594
5658
  bot_account_id: accountId,
5595
- last_probe_at: probeAt,
5659
+ last_probe_at: toChina8Iso(probeAt),
5596
5660
  ok,
5597
5661
  error,
5598
5662
  result: (probeResult as Record<string, unknown>) || null,
@@ -5627,7 +5691,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5627
5691
  runtimeHints.last_run_activity_at || probeAt,
5628
5692
  mode: runtimeHints.mode || previous?.mode || null,
5629
5693
  status_source: "probe",
5630
- last_probe_at: probeAt,
5694
+ last_probe_at: toChina8Iso(probeAt),
5631
5695
  });
5632
5696
  } catch (err) {
5633
5697
  const probeAt = nowIso();
@@ -5638,7 +5702,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5638
5702
  upsertRuntimeProbe({
5639
5703
  platform: "dingtalk",
5640
5704
  bot_account_id: accountId,
5641
- last_probe_at: probeAt,
5705
+ last_probe_at: toChina8Iso(probeAt),
5642
5706
  ok: false,
5643
5707
  error: probeError,
5644
5708
  result: null,
@@ -5662,7 +5726,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5662
5726
  last_run_activity_at: probeAt,
5663
5727
  mode: previous?.mode || null,
5664
5728
  status_source: "probe",
5665
- last_probe_at: probeAt,
5729
+ last_probe_at: toChina8Iso(probeAt),
5666
5730
  });
5667
5731
  }
5668
5732
  },
@@ -5725,7 +5789,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5725
5789
  upsertRuntimeProbe({
5726
5790
  platform: "dingtalk",
5727
5791
  bot_account_id: accountId,
5728
- last_probe_at: probeAt,
5792
+ last_probe_at: toChina8Iso(probeAt),
5729
5793
  ok,
5730
5794
  error,
5731
5795
  result: (probeResult as Record<string, unknown>) || null,
@@ -5764,7 +5828,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5764
5828
  runtimeHints.last_run_activity_at || probeAt,
5765
5829
  mode: runtimeHints.mode || previous?.mode || null,
5766
5830
  status_source: "probe",
5767
- last_probe_at: probeAt,
5831
+ last_probe_at: toChina8Iso(probeAt),
5768
5832
  });
5769
5833
  } catch (err) {
5770
5834
  const probeAt = nowIso();
@@ -5775,7 +5839,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5775
5839
  upsertRuntimeProbe({
5776
5840
  platform: "dingtalk",
5777
5841
  bot_account_id: accountId,
5778
- last_probe_at: probeAt,
5842
+ last_probe_at: toChina8Iso(probeAt),
5779
5843
  ok: false,
5780
5844
  error: probeError,
5781
5845
  result: null,
@@ -5799,7 +5863,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5799
5863
  last_run_activity_at: probeAt,
5800
5864
  mode: previous?.mode || null,
5801
5865
  status_source: "probe",
5802
- last_probe_at: probeAt,
5866
+ last_probe_at: toChina8Iso(probeAt),
5803
5867
  });
5804
5868
  }
5805
5869
  },
@@ -5913,7 +5977,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5913
5977
  upsertRuntimeProbe({
5914
5978
  platform: "feishu",
5915
5979
  bot_account_id: accountId,
5916
- last_probe_at: probeAt,
5980
+ last_probe_at: toChina8Iso(probeAt),
5917
5981
  ok,
5918
5982
  error: nextError,
5919
5983
  result: (probeResult as Record<string, unknown>) || null,
@@ -5949,7 +6013,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5949
6013
  runtimeHints.last_run_activity_at || probeAt,
5950
6014
  mode: runtimeHints.mode || previous?.mode || null,
5951
6015
  status_source: "probe",
5952
- last_probe_at: probeAt,
6016
+ last_probe_at: toChina8Iso(probeAt),
5953
6017
  });
5954
6018
  } catch (err) {
5955
6019
  const probeAt = nowIso();
@@ -5957,7 +6021,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5957
6021
  upsertRuntimeProbe({
5958
6022
  platform: "feishu",
5959
6023
  bot_account_id: accountId,
5960
- last_probe_at: probeAt,
6024
+ last_probe_at: toChina8Iso(probeAt),
5961
6025
  ok: false,
5962
6026
  error: probeError || null,
5963
6027
  result: null,
@@ -5985,7 +6049,7 @@ async function runFullProbeAndRefreshStatuses(params: {
5985
6049
  last_run_activity_at: probeAt,
5986
6050
  mode: previous?.mode || null,
5987
6051
  status_source: "probe",
5988
- last_probe_at: probeAt,
6052
+ last_probe_at: toChina8Iso(probeAt),
5989
6053
  });
5990
6054
  }
5991
6055
  },
@@ -6095,7 +6159,7 @@ async function runFullProbeAndRefreshStatuses(params: {
6095
6159
  upsertRuntimeProbe({
6096
6160
  platform: "weixin",
6097
6161
  bot_account_id: accountId,
6098
- last_probe_at: probeAt,
6162
+ last_probe_at: toChina8Iso(probeAt),
6099
6163
  ok,
6100
6164
  error,
6101
6165
  result: (probeResult as Record<string, unknown>) || null,
@@ -6130,7 +6194,7 @@ async function runFullProbeAndRefreshStatuses(params: {
6130
6194
  runtimeHints.last_run_activity_at || probeAt,
6131
6195
  mode: runtimeHints.mode || previous?.mode || null,
6132
6196
  status_source: "probe",
6133
- last_probe_at: probeAt,
6197
+ last_probe_at: toChina8Iso(probeAt),
6134
6198
  });
6135
6199
  } catch (err) {
6136
6200
  const probeAt = nowIso();
@@ -6141,7 +6205,7 @@ async function runFullProbeAndRefreshStatuses(params: {
6141
6205
  upsertRuntimeProbe({
6142
6206
  platform: "weixin",
6143
6207
  bot_account_id: accountId,
6144
- last_probe_at: probeAt,
6208
+ last_probe_at: toChina8Iso(probeAt),
6145
6209
  ok: false,
6146
6210
  error: probeError,
6147
6211
  result: null,
@@ -6165,7 +6229,7 @@ async function runFullProbeAndRefreshStatuses(params: {
6165
6229
  last_run_activity_at: probeAt,
6166
6230
  mode: previous?.mode || null,
6167
6231
  status_source: "probe",
6168
- last_probe_at: probeAt,
6232
+ last_probe_at: toChina8Iso(probeAt),
6169
6233
  });
6170
6234
  }
6171
6235
  },
@@ -6226,7 +6290,7 @@ async function probeAndRefreshStatuses(
6226
6290
  }
6227
6291
 
6228
6292
  const startedAt = Date.now();
6229
- lastProbeStartedAt = new Date(startedAt).toISOString();
6293
+ lastProbeStartedAt = toChina8Iso(new Date(startedAt).toISOString());
6230
6294
 
6231
6295
  const sweepTimeouts = resolveProbeTimeoutsFromRequest(
6232
6296
  options.timeoutMs == null ? undefined : Number(options.timeoutMs),
@@ -6242,7 +6306,7 @@ async function probeAndRefreshStatuses(
6242
6306
  });
6243
6307
  })().finally(() => {
6244
6308
  const finishedAt = Date.now();
6245
- lastProbeFinishedAt = new Date(finishedAt).toISOString();
6309
+ lastProbeFinishedAt = toChina8Iso(new Date(finishedAt).toISOString());
6246
6310
  lastProbeDurationMs = Math.max(0, finishedAt - startedAt);
6247
6311
  probeInFlight = null;
6248
6312
  probeInFlightMeta = null;
@@ -6643,7 +6707,7 @@ async function runRuntimeHealthGuardCycle(): Promise<void> {
6643
6707
  }
6644
6708
 
6645
6709
  const startedAtMs = Date.now();
6646
- runtimeHealthGuardLastStartedAt = new Date(startedAtMs).toISOString();
6710
+ runtimeHealthGuardLastStartedAt = toChina8Iso(new Date(startedAtMs).toISOString());
6647
6711
 
6648
6712
  try {
6649
6713
  await probeAndRefreshStatuses({ reason: "health_guard" });
@@ -6710,7 +6774,7 @@ async function runRuntimeHealthGuardCycle(): Promise<void> {
6710
6774
  }
6711
6775
  } finally {
6712
6776
  const finishedAtMs = Date.now();
6713
- runtimeHealthGuardLastFinishedAt = new Date(finishedAtMs).toISOString();
6777
+ runtimeHealthGuardLastFinishedAt = toChina8Iso(new Date(finishedAtMs).toISOString());
6714
6778
  runtimeHealthGuardLastDurationMs = Math.max(0, finishedAtMs - startedAtMs);
6715
6779
  }
6716
6780
  }
@@ -7447,7 +7511,7 @@ async function startInternalApiServer(): Promise<void> {
7447
7511
  JSON.stringify({
7448
7512
  ok: true,
7449
7513
  runtime_instance_id: runtimeInstanceId,
7450
- generated_at: nowIso(),
7514
+ generated_at: toChina8Iso(nowIso()),
7451
7515
  limit: clampedLimit,
7452
7516
  ...runtimeLogs,
7453
7517
  }),
@@ -7895,6 +7959,19 @@ async function startInternalApiServer(): Promise<void> {
7895
7959
  ? body.markConnecting
7896
7960
  : body.mark_connecting;
7897
7961
  const markConnecting = parseBoolFlag(markConnectingRaw, true);
7962
+ const reconcileRuntimeStateRaw =
7963
+ body.reconcile_runtime_state === undefined
7964
+ ? body.reconcileRuntimeState
7965
+ : body.reconcile_runtime_state;
7966
+ const reconcileRuntimeState = parseBoolFlag(
7967
+ reconcileRuntimeStateRaw,
7968
+ !markConnecting,
7969
+ );
7970
+ const probeAfterApplyRaw =
7971
+ body.probe_after_apply === undefined
7972
+ ? body.probeAfterApply
7973
+ : body.probe_after_apply;
7974
+ const probeAfterApply = parseBoolFlag(probeAfterApplyRaw, true);
7898
7975
  const requestedVersion = String(body.version || "").trim();
7899
7976
  if (!config || typeof config !== "object") {
7900
7977
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -7971,14 +8048,16 @@ async function startInternalApiServer(): Promise<void> {
7971
8048
  normalizedResult.normalized,
7972
8049
  "config_apply_mark_connecting",
7973
8050
  );
7974
- } else {
8051
+ } else if (reconcileRuntimeState) {
7975
8052
  await reconcileRuntimeStateForConfigDelta({
7976
8053
  previousConfig,
7977
8054
  nextConfig: normalizedResult.normalized,
7978
8055
  trigger: "config_apply",
7979
8056
  });
7980
8057
  }
7981
- await probeAndRefreshStatuses({ force: true, reason: "config_apply" });
8058
+ if (probeAfterApply) {
8059
+ await probeAndRefreshStatuses({ force: true, reason: "config_apply" });
8060
+ }
7982
8061
 
7983
8062
  res.writeHead(200, { "Content-Type": "application/json" });
7984
8063
  res.end(
@@ -7987,6 +8066,8 @@ async function startInternalApiServer(): Promise<void> {
7987
8066
  status: "applied",
7988
8067
  persisted: persist,
7989
8068
  mark_connecting: markConnecting,
8069
+ reconcile_runtime_state: reconcileRuntimeState,
8070
+ probe_after_apply: probeAfterApply,
7990
8071
  configPath: loadedConfigPathForRuntime,
7991
8072
  version: configVersionHash || "unknown",
7992
8073
  }),
@@ -19,12 +19,14 @@ export type RuntimeBotStatus = {
19
19
  };
20
20
 
21
21
  export class BotRuntimeRegistry {
22
+ // 账号级运行态内存表:key=platform:bot_account_id。
22
23
  private readonly store = new Map<string, RuntimeBotStatus>();
23
24
 
24
25
  private keyOf(
25
26
  platform: RuntimeBotStatus["platform"],
26
27
  accountId: string,
27
28
  ): string {
29
+ // 单账号状态唯一键。
28
30
  return `${platform}:${accountId}`;
29
31
  }
30
32
 
@@ -33,6 +35,7 @@ export class BotRuntimeRegistry {
33
35
  }
34
36
 
35
37
  upsert(entry: RuntimeBotStatus): void {
38
+ // 合并更新:未传字段继承旧值,便于多来源状态渐进补齐。
36
39
  const key = this.keyOf(entry.platform, entry.bot_account_id);
37
40
  const prev = this.store.get(key);
38
41
  this.store.set(key, { ...(prev || {}), ...entry });
@@ -50,6 +53,7 @@ export class BotRuntimeRegistry {
50
53
  degraded: number;
51
54
  disconnected: number;
52
55
  } {
56
+ // 汇总用于外部状态面板统计,不改写任何账号状态。
53
57
  const summary = {
54
58
  total: 0,
55
59
  connected: 0,
@@ -1,5 +1,6 @@
1
1
  export type RuntimePlatform = "dingtalk" | "feishu" | "weixin";
2
2
 
3
+ // 运行态主状态枚举(来自 Node 状态机 runtime.link_status)。
3
4
  export type RuntimeLinkStatus =
4
5
  | "connecting"
5
6
  | "connected"
@@ -9,6 +10,7 @@ export type RuntimeLinkStatus =
9
10
 
10
11
  export type RuntimeStatusSource = "event" | "probe" | "manual" | null | undefined;
11
12
 
13
+ // health.reason 是“健康判定层”的语义,不等价于 link_status。
12
14
  export type RuntimeComposedHealthReason =
13
15
  | "healthy"
14
16
  | "unmanaged"
@@ -53,6 +55,7 @@ export type RuntimeStatusLike = {
53
55
  last_event_at?: string | number | null;
54
56
  };
55
57
 
58
+ // RuntimeComposedBotLike: 统一的“状态 + 健康”输入结构,供 readiness/guard 使用。
56
59
  export type RuntimeComposedBotLike = {
57
60
  platform: RuntimePlatform;
58
61
  bot_account_id: string;
@@ -63,6 +66,7 @@ export type RuntimeComposedBotLike = {
63
66
  };
64
67
  };
65
68
 
69
+ // display 快照是最终 UI 文案/颜色输出,不反向驱动状态机。
66
70
  export type RuntimeDisplaySnapshot = {
67
71
  status: RuntimeComposedDisplayStatus;
68
72
  reason: string;
@@ -146,13 +150,17 @@ const runtimeStartupFallbackEvents = new Set([
146
150
  "config_loaded",
147
151
  ]);
148
152
 
153
+ // webhook 模式下没有持续 socket 心跳,不做 stale_socket 判定。
149
154
  const runtimeSkipStaleHeartbeatModes = new Set(["webhook"]);
155
+ // busy 状态长期无活动则视为 stuck。
150
156
  const runtimeBusyActivityStaleMsDefault = 25 * 60_000;
157
+ // 重连次数达到阈值后,健康守护可按 gave_up 分类。
151
158
  const runtimeGaveUpReconnectThreshold = 10;
152
159
 
153
160
  export function isExplicitReportedRuntimeFailure(
154
161
  status: Pick<RuntimeStatusLike, "link_status" | "status_source">,
155
162
  ): boolean {
163
+ // 只有 event/probe 上报的失败态被视为“显式失败证据”。
156
164
  return (
157
165
  (status.status_source === "probe" || status.status_source === "event") &&
158
166
  (status.link_status === "error" ||
@@ -168,6 +176,8 @@ export function shouldApplyReportedFailureOverManualConnecting(params: {
168
176
  | undefined;
169
177
  next: Pick<RuntimeStatusLike, "link_status" | "status_source">;
170
178
  }): boolean {
179
+ // manual/connecting 是控制面过渡态;
180
+ // 若后续出现 event/probe 明确失败,应允许失败覆盖 manual connecting。
171
181
  const previous = params.previous;
172
182
  const previousSource = previous?.status_source || "probe";
173
183
  return (
@@ -178,6 +188,7 @@ export function shouldApplyReportedFailureOverManualConnecting(params: {
178
188
  }
179
189
 
180
190
  function parseIsoTimeMs(value: string | null | undefined): number | null {
191
+ // ISO 时间文本解析;解析失败统一返回 null。
181
192
  const text = String(value || "").trim();
182
193
  if (!text) return null;
183
194
  const ms = Date.parse(text);
@@ -186,6 +197,7 @@ function parseIsoTimeMs(value: string | null | undefined): number | null {
186
197
  }
187
198
 
188
199
  function parseTimeMs(value: unknown): number | null {
200
+ // 通用时间解析:支持秒/毫秒数字与文本,统一返回毫秒时间戳。
189
201
  if (typeof value === "number" && Number.isFinite(value)) {
190
202
  const normalized = value > 1_000_000_000_000 ? value : value * 1000;
191
203
  return Math.max(0, Math.trunc(normalized));
@@ -201,6 +213,7 @@ function parseTimeMs(value: unknown): number | null {
201
213
  }
202
214
 
203
215
  function readNonNegativeInt(value: unknown, fallback = 0): number {
216
+ // 计数字段容错并裁剪到非负整数。
204
217
  const numeric = typeof value === "number" ? value : Number(value);
205
218
  if (!Number.isFinite(numeric)) return fallback;
206
219
  return Math.max(0, Math.trunc(numeric));
@@ -221,6 +234,9 @@ export function evaluateRuntimeHealth(params: {
221
234
  reason: RuntimeComposedHealthReason;
222
235
  evaluated_at: string;
223
236
  } {
237
+ // 健康判定优先级:
238
+ // unmanaged > not_running > busy/stuck > explicit failure > startup_grace
239
+ // > stale_socket > healthy。
224
240
  const {
225
241
  status,
226
242
  eventAtMs = null,
@@ -336,6 +352,7 @@ export function evaluateRuntimeHealth(params: {
336
352
  function normalizeRuntimeDisplayStatusFromText(
337
353
  value: unknown,
338
354
  ): RuntimeComposedDisplayStatus | null {
355
+ // 宽松文本到显示状态映射,兼容历史 status 文本。
339
356
  const text = String(value || "")
340
357
  .trim()
341
358
  .toLowerCase();
@@ -357,6 +374,7 @@ function resolveRuntimeDisplayDefaults(status: RuntimeComposedDisplayStatus): {
357
374
  color: RuntimeComposedDisplayColor;
358
375
  hint: string | null;
359
376
  } {
377
+ // 显示状态默认文案,不承担状态判定职责。
360
378
  if (status === "online") {
361
379
  return { label: "在线", color: "success", hint: null };
362
380
  }
@@ -369,9 +387,9 @@ function resolveRuntimeDisplayDefaults(status: RuntimeComposedDisplayStatus): {
369
387
  }
370
388
  if (status === "connecting") {
371
389
  return {
372
- label: "连接中",
390
+ label: "状态确认中",
373
391
  color: "processing",
374
- hint: "结构化状态显示机器人处于启动/连接阶段。",
392
+ hint: "正在同步并确认机器人在线状态。",
375
393
  };
376
394
  }
377
395
  if (status === "reconnecting") {
@@ -403,6 +421,8 @@ export function resolveRuntimeDisplayState(params: {
403
421
  healthReason: RuntimeComposedHealthReason;
404
422
  evaluatedAt: string;
405
423
  }): RuntimeDisplaySnapshot {
424
+ // 展示态判定优先级:
425
+ // restart_pending 分支 > health.reason 分支 > status 文本分支 > event fallback > unknown。
406
426
  const statusText = String(params.status.link_status || "")
407
427
  .trim()
408
428
  .toLowerCase();
@@ -433,6 +453,7 @@ export function resolveRuntimeDisplayState(params: {
433
453
 
434
454
  const linkConnected = params.status.link_status === "connected";
435
455
  if (params.status.restart_pending === true) {
456
+ // 重启生命周期下,优先输出“重连中/失败中”语义,避免过早显示在线。
436
457
  if (statusText === "error") {
437
458
  return build("error", "status:error", "status", {
438
459
  hint: "重启/启动流程中探测失败,请检查凭证与网络后重试。",
@@ -517,7 +538,8 @@ export function resolveRuntimeDisplayState(params: {
517
538
  });
518
539
  }
519
540
  return build("connecting", "health:startup_grace", "health", {
520
- hint: "启动宽限期内先显示连接中,收到连接成功事件后再切换在线。",
541
+ label: "状态确认中",
542
+ hint: "正在同步并确认机器人在线状态。",
521
543
  });
522
544
  }
523
545
 
@@ -527,7 +549,10 @@ export function resolveRuntimeDisplayState(params: {
527
549
  }
528
550
 
529
551
  if (!statusText && runtimeStartupFallbackEvents.has(lastEvent)) {
530
- return build("connecting", `event:${lastEvent}`, "event_fallback");
552
+ // status 文本时,启动事件作为连接中兜底提示。
553
+ return build("connecting", `event:${lastEvent}`, "event_fallback", {
554
+ label: "状态确认中",
555
+ });
531
556
  }
532
557
 
533
558
  if (params.healthReason === "healthy") {
@@ -538,6 +563,7 @@ export function resolveRuntimeDisplayState(params: {
538
563
  }
539
564
 
540
565
  export function isManualStopState(item: RuntimeComposedBotLike): boolean {
566
+ // 显式人工停机态:manual + disconnected。
541
567
  return (
542
568
  item.runtime.status_source === "manual" &&
543
569
  item.runtime.link_status === "disconnected"
@@ -545,12 +571,14 @@ export function isManualStopState(item: RuntimeComposedBotLike): boolean {
545
571
  }
546
572
 
547
573
  function readRuntimeReconnectCount(item: RuntimeComposedBotLike): number {
574
+ // reconnect_count 统一按非负整数读取,避免 NaN/负值影响阈值判断。
548
575
  return readNonNegativeInt(item.runtime.reconnect_count);
549
576
  }
550
577
 
551
578
  export function resolveRuntimeHealthGuardTriggerReason(
552
579
  item: RuntimeComposedBotLike,
553
580
  ): RuntimeHealthGuardTriggerReason | null {
581
+ // 健康守护触发原因仅用于“是否自动恢复”决策,不直接改主状态。
554
582
  const reconnectCount = readRuntimeReconnectCount(item);
555
583
  const isRestartPending = item.runtime.restart_pending === true;
556
584
 
@@ -582,6 +610,10 @@ export function buildRuntimeReadinessSnapshot(params: {
582
610
  runtimeState?: "running" | "restarting";
583
611
  runtimeReady?: boolean;
584
612
  }): RuntimeReadinessSnapshot {
613
+ // readiness 只读聚合:
614
+ // 1) 把账号划分为 hard/soft/ignored/pending;
615
+ // 2) 计算 ready/pending/pending_reason;
616
+ // 3) 输出可诊断 summary 与账号明细。
585
617
  const {
586
618
  botsComposed,
587
619
  generatedAt,
@@ -612,6 +644,7 @@ export function buildRuntimeReadinessSnapshot(params: {
612
644
  reconnect_count: reconnectCount,
613
645
  };
614
646
 
647
+ // pending 维度是“尚在收敛”语义,不等价于 unhealthy。
615
648
  const isPendingByHealth = item.health.reason === "startup_grace";
616
649
  const isPendingByStatus = item.runtime.link_status === "connecting";
617
650
  const isPendingByRestart = isRestartPending;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ylib-syim",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "多 IM / 多 Agent 的会话路由与上下文管理(支持 /new)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -809,7 +809,13 @@ async function startConfiguredAccounts(): Promise<void> {
809
809
  }
810
810
 
811
811
  let startedCount = 0;
812
- for (const accountId of accountIds) {
812
+ // 有界并发启动:避免账号数多时串行等待导致首次启动时间过长。
813
+ // 并发数由环境变量 BRIDGE_START_CONCURRENCY_DINGTALK 控制,默认 3。
814
+ const concurrency = parseInt(process.env.BRIDGE_START_CONCURRENCY_DINGTALK || "3", 10);
815
+ const queue = [...accountIds];
816
+ const running = new Map<string, Promise<void>>();
817
+
818
+ async function startOne(accountId: string): Promise<void> {
813
819
  try {
814
820
  await startSingleAccount(accountId, "startup");
815
821
  startedCount++;
@@ -821,6 +827,19 @@ async function startConfiguredAccounts(): Promise<void> {
821
827
  emitRuntimeEvent(accountId, "error", detail);
822
828
  }
823
829
  }
830
+
831
+ while (queue.length > 0 || running.size > 0) {
832
+ while (running.size < concurrency && queue.length > 0) {
833
+ const accountId = queue.shift()!;
834
+ const task = startOne(accountId).finally(() => {
835
+ running.delete(accountId);
836
+ });
837
+ running.set(accountId, task);
838
+ }
839
+ if (running.size > 0) {
840
+ await Promise.race(running.values());
841
+ }
842
+ }
824
843
  console.log(
825
844
  `[dingtalk-stdio-bridge] started accounts=${startedCount} total=${accountIds.length}`,
826
845
  );
@@ -897,7 +897,13 @@ async function startConfiguredAccounts(): Promise<void> {
897
897
  }
898
898
 
899
899
  let startedCount = 0;
900
- for (const accountId of accountIds) {
900
+ // 有界并发启动:避免账号数多时串行等待导致首次启动时间过长。
901
+ // 并发数由环境变量 BRIDGE_START_CONCURRENCY_LARK 控制,默认 3。
902
+ const concurrency = parseInt(process.env.BRIDGE_START_CONCURRENCY_LARK || "3", 10);
903
+ const queue = [...accountIds];
904
+ const running = new Map<string, Promise<void>>();
905
+
906
+ async function startOne(accountId: string): Promise<void> {
901
907
  try {
902
908
  await startSingleAccount(accountId, "startup");
903
909
  startedCount++;
@@ -909,6 +915,19 @@ async function startConfiguredAccounts(): Promise<void> {
909
915
  emitRuntimeEvent(accountId, "error", detail);
910
916
  }
911
917
  }
918
+
919
+ while (queue.length > 0 || running.size > 0) {
920
+ while (running.size < concurrency && queue.length > 0) {
921
+ const accountId = queue.shift()!;
922
+ const task = startOne(accountId).finally(() => {
923
+ running.delete(accountId);
924
+ });
925
+ running.set(accountId, task);
926
+ }
927
+ if (running.size > 0) {
928
+ await Promise.race(running.values());
929
+ }
930
+ }
912
931
  console.log(
913
932
  `[lark-stdio-bridge] started accounts=${startedCount} total=${accountIds.length}`,
914
933
  );
@@ -818,7 +818,13 @@ async function startConfiguredAccounts(): Promise<void> {
818
818
  }
819
819
 
820
820
  let startedCount = 0;
821
- for (const accountId of accountIds) {
821
+ // 有界并发启动:避免账号数多时串行等待导致首次启动时间过长。
822
+ // 并发数由环境变量 BRIDGE_START_CONCURRENCY_WEIXIN 控制,默认 2。
823
+ const concurrency = parseInt(process.env.BRIDGE_START_CONCURRENCY_WEIXIN || "2", 10);
824
+ const queue = [...accountIds];
825
+ const running = new Map<string, Promise<void>>();
826
+
827
+ async function startOne(accountId: string): Promise<void> {
822
828
  try {
823
829
  await startSingleAccount(accountId, "startup");
824
830
  startedCount++;
@@ -831,6 +837,19 @@ async function startConfiguredAccounts(): Promise<void> {
831
837
  }
832
838
  }
833
839
 
840
+ while (queue.length > 0 || running.size > 0) {
841
+ while (running.size < concurrency && queue.length > 0) {
842
+ const accountId = queue.shift()!;
843
+ const task = startOne(accountId).finally(() => {
844
+ running.delete(accountId);
845
+ });
846
+ running.set(accountId, task);
847
+ }
848
+ if (running.size > 0) {
849
+ await Promise.race(running.values());
850
+ }
851
+ }
852
+
834
853
  console.log(
835
854
  `[weixin-stdio-bridge] started accounts=${startedCount} total=${accountIds.length}`,
836
855
  );