@muyichengshayu/promptx 0.1.10 → 0.1.12

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.12
4
+
5
+ - Relay 新增按天聚合的租户使用统计,自动记录设备连接与真实转发请求,方便查看“今天有哪些同事实际用了 Relay”。
6
+ - 新增 Relay 管理统计页 `/relay/admin/usage` 与对应接口,可直接查看今日活跃租户、连接次数、请求次数、最近设备与最近活跃时间。
7
+ - 统计能力默认落在云端 Relay 侧,不依赖本地 PromptX client 升级;同时补充文档说明与服务端测试覆盖。
8
+
9
+ ## 0.1.11
10
+
11
+ - 加固 Relay 自动重连恢复链路:固定间隔重连升级为指数退避,并在连接成功后重置重连计数,减少 Relay 重启或网络抖动时的无效重试。
12
+ - 补齐 Relay client 健康检查与自愈能力:新增心跳超时巡检、假死连接主动重连,以及类似 Mac 休眠/唤醒后的时间跳变检测,避免远程页面断开后必须手动保存设置才能恢复。
13
+ - 修复 Relay client 旧连接 `close` 回调误伤新连接的竞态问题,避免在 Windows 等时序下反复出现“旧设备连接被新连接替换”的重连循环。
14
+ - 对 `invalid_token`、`invalid_device`、`invalid_tenant` 等不可恢复错误改为暂停自动重连,并在状态里明确暴露暂停原因,方便快速定位配置问题。
15
+ - 设置页“远程”增加“立即重连”按钮,并补充等待重连 / 已暂停重连等状态反馈,便于手动触发恢复和远程排障。
16
+ - 新增 Relay 重连相关服务端与接口测试,覆盖拒绝后暂停、重连成功清零、心跳超时自愈、休眠恢复近似场景和手动重连。
17
+ - 调整左侧任务列表为固定创建顺序,不再因运行、保存、自动刷新或 `updatedAt` 变化跳动;同时修复慢网切换任务时旧草稿标题短暂显示到新任务上的问题。
18
+
3
19
  ## 0.1.10
4
20
 
5
21
  - 重构执行链路为 `server + runner` 双进程架构:`server` 专注 HTTP API、任务/项目存储与 SSE,`runner` 专注 agent 子进程生命周期、事件流、stop/kill/超时回收,显著降低长任务拖垮主服务的风险。
@@ -9,9 +9,17 @@ import {
9
9
  sanitizeProxyHeaders,
10
10
  } from './relayProtocol.js'
11
11
 
12
- const DEFAULT_RECONNECT_DELAY_MS = 3_000
12
+ const DEFAULT_RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000]
13
+ const DEFAULT_HEALTH_CHECK_INTERVAL_MS = 10_000
14
+ const DEFAULT_HEARTBEAT_TIMEOUT_MS = 65_000
15
+ const DEFAULT_SLEEP_RESUME_THRESHOLD_MS = 45_000
13
16
  const MAX_RECENT_EVENTS = 200
14
17
  const REQUEST_CANCEL_REASON = 'relay_request_cancelled'
18
+ const NON_RETRYABLE_CLOSE_REASONS = new Set([
19
+ 'invalid_tenant',
20
+ 'invalid_token',
21
+ 'invalid_device',
22
+ ])
15
23
 
16
24
  function createDisabledStatus() {
17
25
  return {
@@ -27,6 +35,9 @@ function createDisabledStatus() {
27
35
  lastCloseReason: '',
28
36
  lastError: '',
29
37
  reconnectCount: 0,
38
+ reconnectPaused: false,
39
+ reconnectPausedReason: '',
40
+ nextReconnectDelayMs: 0,
30
41
  recentEvents: [],
31
42
  }
32
43
  }
@@ -51,6 +62,22 @@ function normalizeCloseReason(reason = '') {
51
62
  return reasonMap[normalized] || normalized
52
63
  }
53
64
 
65
+ function parseCloseReason(reason = '') {
66
+ const rawReason = Buffer.isBuffer(reason)
67
+ ? reason.toString('utf8').trim()
68
+ : String(reason || '').trim()
69
+
70
+ return {
71
+ rawReason,
72
+ closeReason: normalizeCloseReason(rawReason),
73
+ }
74
+ }
75
+
76
+ function getReconnectDelayMs(reconnectCount = 0) {
77
+ const normalizedCount = Math.max(1, Number(reconnectCount) || 1)
78
+ return DEFAULT_RECONNECT_DELAYS_MS[Math.min(normalizedCount - 1, DEFAULT_RECONNECT_DELAYS_MS.length - 1)]
79
+ }
80
+
54
81
  function readRelayClientConfig({
55
82
  relayUrl = process.env.PROMPTX_RELAY_URL,
56
83
  deviceId = process.env.PROMPTX_RELAY_DEVICE_ID,
@@ -85,6 +112,12 @@ function createRelayClient({
85
112
  localBaseUrl,
86
113
  logger = console,
87
114
  appVersion = '0.0.0',
115
+ createWebSocket = (url) => new WebSocket(url),
116
+ reconnectDelayStrategy = getReconnectDelayMs,
117
+ healthCheckIntervalMs = DEFAULT_HEALTH_CHECK_INTERVAL_MS,
118
+ heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS,
119
+ sleepResumeThresholdMs = DEFAULT_SLEEP_RESUME_THRESHOLD_MS,
120
+ getNow = () => Date.now(),
88
121
  } = {}) {
89
122
  let config = readRelayClientConfig({
90
123
  relayUrl,
@@ -104,8 +137,12 @@ function createRelayClient({
104
137
  let socket = null
105
138
  let stopped = false
106
139
  let reconnectTimer = null
140
+ let healthCheckTimer = null
107
141
  let requestMap = new Map()
108
142
  let authenticated = false
143
+ let pendingReconnectSource = ''
144
+ let lastHealthCheckTickAt = 0
145
+ let connectionSequence = 0
109
146
 
110
147
  function updateStatus(patch = {}) {
111
148
  Object.assign(status, patch)
@@ -113,7 +150,7 @@ function createRelayClient({
113
150
 
114
151
  function appendRecentEvent(type, extra = {}) {
115
152
  const nextEvent = {
116
- at: new Date().toISOString(),
153
+ at: new Date(getNow()).toISOString(),
117
154
  type: String(type || '').trim() || 'unknown',
118
155
  ...extra,
119
156
  }
@@ -163,6 +200,60 @@ function createRelayClient({
163
200
  logger.error?.(message)
164
201
  }
165
202
 
203
+ function nowIso() {
204
+ return new Date(getNow()).toISOString()
205
+ }
206
+
207
+ function clearReconnectTimer() {
208
+ if (reconnectTimer) {
209
+ clearTimeout(reconnectTimer)
210
+ reconnectTimer = null
211
+ }
212
+ }
213
+
214
+ function clearHealthCheckTimer() {
215
+ if (healthCheckTimer) {
216
+ clearInterval(healthCheckTimer)
217
+ healthCheckTimer = null
218
+ }
219
+ }
220
+
221
+ function resetReconnectState() {
222
+ updateStatus({
223
+ reconnectCount: 0,
224
+ reconnectPaused: false,
225
+ reconnectPausedReason: '',
226
+ nextReconnectDelayMs: 0,
227
+ })
228
+ }
229
+
230
+ function getHeartbeatAgeMs(now = getNow()) {
231
+ const lastHeartbeatAt = Date.parse(status.lastHeartbeatAt || status.lastConnectedAt || '')
232
+ if (!Number.isFinite(lastHeartbeatAt)) {
233
+ return Number.POSITIVE_INFINITY
234
+ }
235
+ return Math.max(0, now - lastHeartbeatAt)
236
+ }
237
+
238
+ function shouldPauseReconnect(rawReason = '') {
239
+ return NON_RETRYABLE_CLOSE_REASONS.has(String(rawReason || '').trim())
240
+ }
241
+
242
+ function pauseReconnect(rawReason = '') {
243
+ const reason = normalizeCloseReason(rawReason)
244
+ updateStatus({
245
+ reconnectPaused: true,
246
+ reconnectPausedReason: reason || String(rawReason || '').trim(),
247
+ nextReconnectDelayMs: 0,
248
+ })
249
+ appendRecentEvent('reconnect_paused', {
250
+ reason: reason || String(rawReason || '').trim() || 'unknown',
251
+ })
252
+ logWarn('[relay] 检测到不可重试错误,已暂停自动重连', getLogContext({
253
+ reason: reason || String(rawReason || '').trim() || 'unknown',
254
+ }))
255
+ }
256
+
166
257
  syncStatusFromConfig()
167
258
 
168
259
  function sendFrame(payload = {}) {
@@ -313,43 +404,142 @@ function createRelayClient({
313
404
  }
314
405
  }
315
406
 
316
- function scheduleReconnect() {
317
- if (stopped || reconnectTimer || !config.enabled) {
407
+ function connectWithRetry(source = 'start') {
408
+ return connect().catch((error) => {
409
+ updateStatus({
410
+ lastError: error?.message || 'Relay 连接失败。',
411
+ })
412
+ appendRecentEvent('connect_failed', {
413
+ source,
414
+ reconnectCount: Number(status.reconnectCount || 0),
415
+ error: error?.message || String(error || ''),
416
+ })
417
+ logError('[relay] 连接失败', getLogContext({
418
+ source,
419
+ reconnectCount: Number(status.reconnectCount || 0),
420
+ error: error?.message || String(error || ''),
421
+ }))
422
+ scheduleReconnect({ source })
423
+ })
424
+ }
425
+
426
+ function scheduleReconnect({ source = 'close' } = {}) {
427
+ if (stopped || reconnectTimer || !config.enabled || status.reconnectPaused) {
318
428
  return
319
429
  }
320
430
 
321
431
  const nextReconnectCount = Number(status.reconnectCount || 0) + 1
432
+ const reconnectInMs = Math.max(100, Number(reconnectDelayStrategy(nextReconnectCount)) || getReconnectDelayMs(nextReconnectCount))
322
433
  updateStatus({
323
434
  reconnectCount: nextReconnectCount,
435
+ nextReconnectDelayMs: reconnectInMs,
324
436
  })
325
437
  appendRecentEvent('reconnect_scheduled', {
326
- reconnectInMs: DEFAULT_RECONNECT_DELAY_MS,
438
+ reconnectInMs,
327
439
  reconnectCount: nextReconnectCount,
328
440
  authenticated,
441
+ source,
329
442
  })
330
443
  logWarn('[relay] 已计划重连', getLogContext({
331
- reconnectInMs: DEFAULT_RECONNECT_DELAY_MS,
444
+ reconnectInMs,
332
445
  reconnectCount: nextReconnectCount,
333
446
  authenticated,
447
+ source,
334
448
  }))
335
449
 
336
450
  reconnectTimer = setTimeout(() => {
337
451
  reconnectTimer = null
338
- connect().catch((error) => {
339
- updateStatus({
340
- lastError: error?.message || 'Relay 连接失败。',
452
+ connectWithRetry(`reconnect:${source}`)
453
+ }, reconnectInMs)
454
+ reconnectTimer.unref?.()
455
+ }
456
+
457
+ function triggerReconnect(source = 'manual') {
458
+ if (stopped || !config.enabled) {
459
+ return false
460
+ }
461
+
462
+ clearReconnectTimer()
463
+ updateStatus({
464
+ reconnectPaused: false,
465
+ reconnectPausedReason: '',
466
+ nextReconnectDelayMs: 0,
467
+ lastError: '',
468
+ })
469
+ pendingReconnectSource = source
470
+ appendRecentEvent('reconnect_requested', {
471
+ source,
472
+ socketReadyState: socket?.readyState ?? 3,
473
+ })
474
+ logInfo('[relay] 收到重连请求', getLogContext({
475
+ source,
476
+ socketReadyState: socket?.readyState ?? 3,
477
+ }))
478
+
479
+ if (!socket || socket.readyState === WebSocket.CLOSED) {
480
+ pendingReconnectSource = ''
481
+ connectWithRetry(source)
482
+ return true
483
+ }
484
+
485
+ try {
486
+ if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) {
487
+ socket.terminate()
488
+ } else {
489
+ socket.close(1012, `${source}_reconnect`)
490
+ }
491
+ } catch {
492
+ socket = null
493
+ authenticated = false
494
+ pendingReconnectSource = ''
495
+ connectWithRetry(source)
496
+ }
497
+
498
+ return true
499
+ }
500
+
501
+ function runHealthCheck() {
502
+ if (stopped || !config.enabled) {
503
+ return
504
+ }
505
+
506
+ const now = getNow()
507
+ const previousTickAt = lastHealthCheckTickAt
508
+ lastHealthCheckTickAt = now
509
+
510
+ if (previousTickAt > 0) {
511
+ const elapsedSinceLastTick = now - previousTickAt
512
+ if (elapsedSinceLastTick > sleepResumeThresholdMs) {
513
+ appendRecentEvent('system_resume_detected', {
514
+ elapsedMs: elapsedSinceLastTick,
341
515
  })
342
- appendRecentEvent('reconnect_failed', {
343
- reconnectCount: Number(status.reconnectCount || 0),
344
- error: error?.message || String(error || ''),
516
+ logInfo('[relay] 检测到系统挂起/恢复,开始检查连接健康状态', getLogContext({
517
+ elapsedMs: elapsedSinceLastTick,
518
+ socketReadyState: socket?.readyState ?? 3,
519
+ connected: status.connected,
520
+ }))
521
+ }
522
+ }
523
+
524
+ if (socket?.readyState === WebSocket.OPEN && authenticated) {
525
+ const heartbeatAgeMs = getHeartbeatAgeMs(now)
526
+ if (heartbeatAgeMs > heartbeatTimeoutMs) {
527
+ appendRecentEvent('heartbeat_stale', {
528
+ heartbeatAgeMs,
529
+ heartbeatTimeoutMs,
345
530
  })
346
- logError('[relay] 重连失败', getLogContext({
347
- reconnectCount: Number(status.reconnectCount || 0),
348
- error: error?.message || String(error || ''),
531
+ logWarn('[relay] 心跳已过期,准备主动重连', getLogContext({
532
+ heartbeatAgeMs,
533
+ heartbeatTimeoutMs,
349
534
  }))
350
- scheduleReconnect()
351
- })
352
- }, DEFAULT_RECONNECT_DELAY_MS)
535
+ triggerReconnect('heartbeat_timeout')
536
+ return
537
+ }
538
+ }
539
+
540
+ if (!socket && !reconnectTimer && !status.connected && !status.reconnectPaused) {
541
+ scheduleReconnect({ source: 'health_check_idle' })
542
+ }
353
543
  }
354
544
 
355
545
  async function connect() {
@@ -362,10 +552,15 @@ function createRelayClient({
362
552
 
363
553
  appendRecentEvent('connect_start')
364
554
  logInfo('[relay] 开始连接', getLogContext())
365
- socket = new WebSocket(config.websocketUrl)
555
+ const currentSocket = createWebSocket(config.websocketUrl)
556
+ const connectionId = ++connectionSequence
557
+ socket = currentSocket
366
558
  authenticated = false
367
559
 
368
- socket.on('open', () => {
560
+ currentSocket.on('open', () => {
561
+ if (socket !== currentSocket) {
562
+ return
563
+ }
369
564
  appendRecentEvent('ws_open')
370
565
  sendFrame({
371
566
  type: 'hello',
@@ -373,10 +568,15 @@ function createRelayClient({
373
568
  deviceToken: config.deviceToken,
374
569
  version: appVersion,
375
570
  })
376
- logInfo('[relay] WebSocket 已建立,等待设备认证', getLogContext())
571
+ logInfo('[relay] WebSocket 已建立,等待设备认证', getLogContext({
572
+ connectionId,
573
+ }))
377
574
  })
378
575
 
379
- socket.on('message', (payload, isBinary) => {
576
+ currentSocket.on('message', (payload, isBinary) => {
577
+ if (socket !== currentSocket) {
578
+ return
579
+ }
380
580
  if (isBinary) {
381
581
  return
382
582
  }
@@ -390,10 +590,11 @@ function createRelayClient({
390
590
 
391
591
  if (message?.type === 'hello.ack') {
392
592
  authenticated = true
593
+ resetReconnectState()
393
594
  updateStatus({
394
595
  connected: true,
395
- lastConnectedAt: new Date().toISOString(),
396
- lastHeartbeatAt: new Date().toISOString(),
596
+ lastConnectedAt: nowIso(),
597
+ lastHeartbeatAt: nowIso(),
397
598
  lastCloseCode: 0,
398
599
  lastCloseReason: '',
399
600
  lastError: '',
@@ -401,10 +602,12 @@ function createRelayClient({
401
602
  appendRecentEvent('auth_ok', {
402
603
  tenantKey: String(message?.tenantKey || '').trim(),
403
604
  reconnectCount: Number(status.reconnectCount || 0),
605
+ connectionId,
404
606
  })
405
607
  logInfo('[relay] 设备认证成功,连接已就绪', getLogContext({
406
608
  tenantKey: String(message?.tenantKey || '').trim(),
407
609
  reconnectCount: Number(status.reconnectCount || 0),
610
+ connectionId,
408
611
  }))
409
612
  return
410
613
  }
@@ -412,28 +615,49 @@ function createRelayClient({
412
615
  handleIncomingFrame(JSON.stringify(message))
413
616
  })
414
617
 
415
- socket.on('ping', () => {
618
+ currentSocket.on('ping', () => {
619
+ if (socket !== currentSocket) {
620
+ return
621
+ }
416
622
  updateStatus({
417
- lastHeartbeatAt: new Date().toISOString(),
623
+ lastHeartbeatAt: nowIso(),
418
624
  })
419
625
  })
420
626
 
421
- socket.on('pong', () => {
627
+ currentSocket.on('pong', () => {
628
+ if (socket !== currentSocket) {
629
+ return
630
+ }
422
631
  updateStatus({
423
- lastHeartbeatAt: new Date().toISOString(),
632
+ lastHeartbeatAt: nowIso(),
424
633
  })
425
634
  })
426
635
 
427
- socket.on('close', (code, reason) => {
636
+ currentSocket.on('close', (code, reason) => {
637
+ if (socket !== currentSocket) {
638
+ appendRecentEvent('stale_close_ignored', {
639
+ code: Number(code || 0),
640
+ reason: parseCloseReason(reason).closeReason || '',
641
+ connectionId,
642
+ })
643
+ logInfo('[relay] 已忽略旧连接的 close 事件', getLogContext({
644
+ code: Number(code || 0),
645
+ connectionId,
646
+ }))
647
+ return
648
+ }
649
+
428
650
  const wasAuthenticated = authenticated
429
- const closeReason = normalizeCloseReason(reason?.toString('utf8'))
651
+ const reconnectSource = pendingReconnectSource
652
+ pendingReconnectSource = ''
653
+ const { rawReason, closeReason } = parseCloseReason(reason)
430
654
  const nextError = closeReason && closeReason !== '配置已更新,正在重连'
431
655
  ? `${wasAuthenticated ? 'Relay 已断开' : 'Relay 连接被拒绝'}:${closeReason}`
432
656
  : (!wasAuthenticated && code && code !== 1000 ? `Relay 连接已关闭(code=${code})` : '')
433
657
 
434
658
  updateStatus({
435
659
  connected: false,
436
- lastDisconnectedAt: new Date().toISOString(),
660
+ lastDisconnectedAt: nowIso(),
437
661
  lastCloseCode: Number(code || 0),
438
662
  lastCloseReason: closeReason,
439
663
  ...(nextError ? { lastError: nextError } : {}),
@@ -441,7 +665,10 @@ function createRelayClient({
441
665
  appendRecentEvent('close', {
442
666
  code: Number(code || 0),
443
667
  reason: closeReason || '',
668
+ rawReason: rawReason || '',
444
669
  authenticated: wasAuthenticated,
670
+ reconnectSource: reconnectSource || '',
671
+ connectionId,
445
672
  })
446
673
  socket = null
447
674
  authenticated = false
@@ -449,19 +676,37 @@ function createRelayClient({
449
676
  code: Number(code || 0),
450
677
  reason: closeReason || 'none',
451
678
  authenticated: wasAuthenticated,
679
+ reconnectSource: reconnectSource || '',
680
+ connectionId,
452
681
  }))
453
- scheduleReconnect()
682
+
683
+ if (reconnectSource) {
684
+ connectWithRetry(reconnectSource)
685
+ return
686
+ }
687
+
688
+ if (shouldPauseReconnect(rawReason)) {
689
+ pauseReconnect(rawReason)
690
+ return
691
+ }
692
+
693
+ scheduleReconnect({ source: rawReason || 'close' })
454
694
  })
455
695
 
456
- socket.on('error', (error) => {
696
+ currentSocket.on('error', (error) => {
697
+ if (socket !== currentSocket) {
698
+ return
699
+ }
457
700
  updateStatus({
458
701
  lastError: error?.message || 'Relay 连接失败。',
459
702
  })
460
703
  appendRecentEvent('error', {
461
704
  error: error?.message || String(error || ''),
705
+ connectionId,
462
706
  })
463
707
  logWarn('[relay] 连接异常', getLogContext({
464
708
  error: error?.message || String(error || ''),
709
+ connectionId,
465
710
  }))
466
711
  })
467
712
  }
@@ -469,6 +714,8 @@ function createRelayClient({
469
714
  return {
470
715
  start() {
471
716
  stopped = false
717
+ clearReconnectTimer()
718
+ clearHealthCheckTimer()
472
719
  if (!config.enabled) {
473
720
  syncStatusFromConfig()
474
721
  appendRecentEvent('disabled')
@@ -476,34 +723,29 @@ function createRelayClient({
476
723
  return
477
724
  }
478
725
 
479
- connect().catch((error) => {
480
- updateStatus({
481
- lastError: error?.message || 'Relay 连接失败。',
482
- })
483
- appendRecentEvent('connect_failed', {
484
- error: error?.message || String(error || ''),
485
- })
486
- logError('[relay] 初次连接失败', getLogContext({
487
- error: error?.message || String(error || ''),
488
- }))
489
- scheduleReconnect()
490
- })
726
+ lastHealthCheckTickAt = getNow()
727
+ healthCheckTimer = setInterval(() => {
728
+ runHealthCheck()
729
+ }, Math.max(20, Number(healthCheckIntervalMs) || DEFAULT_HEALTH_CHECK_INTERVAL_MS))
730
+ healthCheckTimer.unref?.()
731
+
732
+ connectWithRetry('start')
491
733
  },
492
734
  stop() {
493
735
  stopped = true
494
- if (reconnectTimer) {
495
- clearTimeout(reconnectTimer)
496
- reconnectTimer = null
497
- }
736
+ clearReconnectTimer()
737
+ clearHealthCheckTimer()
498
738
  requestMap.forEach((record) => {
499
739
  record.controller?.abort()
500
740
  })
501
741
  requestMap = new Map()
742
+ pendingReconnectSource = ''
502
743
  socket?.close()
503
744
  socket = null
504
745
  authenticated = false
505
746
  updateStatus({
506
747
  connected: false,
748
+ nextReconnectDelayMs: 0,
507
749
  })
508
750
  appendRecentEvent('stopped')
509
751
  logInfo('[relay] 已停止', getLogContext())
@@ -517,6 +759,9 @@ function createRelayClient({
517
759
  syncStatusFromConfig()
518
760
  updateStatus({
519
761
  lastError: '',
762
+ reconnectPaused: false,
763
+ reconnectPausedReason: '',
764
+ nextReconnectDelayMs: 0,
520
765
  })
521
766
  appendRecentEvent('config_updated', {
522
767
  previousEnabled,
@@ -543,6 +788,9 @@ function createRelayClient({
543
788
  this.start()
544
789
  }
545
790
  },
791
+ reconnect() {
792
+ return triggerReconnect('manual')
793
+ },
546
794
  getStatus() {
547
795
  return {
548
796
  ...status,
@@ -557,5 +805,6 @@ function createRelayClient({
557
805
  export {
558
806
  createDisabledStatus,
559
807
  createRelayClient,
808
+ getReconnectDelayMs,
560
809
  readRelayClientConfig,
561
810
  }