lark-relay 0.4.11 → 0.4.13
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/bin/lark-relay.js +90 -3
- package/lib/cursors.js +168 -0
- package/lib/help.js +52 -13
- package/lib/lock.js +16 -2
- package/lib/render.js +0 -0
- package/lib/status.js +39 -36
- package/package.json +1 -1
package/bin/lark-relay.js
CHANGED
|
@@ -13,7 +13,7 @@ const VERSION = require('../package.json').version
|
|
|
13
13
|
// --chat-id 拼错 -> 打印用法退出,看着像「正常退出但没消息」,漏看 bot 第一条 Working
|
|
14
14
|
const TAKE_KEYS = ['app', 'chats', 'name', 'filter', 'debounce', 'max-wait', 'timeout', 'render', 'since']
|
|
15
15
|
|
|
16
|
-
const COMMANDS = ['take', 'status', 'guide']
|
|
16
|
+
const COMMANDS = ['take', 'status', 'cursors', 'guide']
|
|
17
17
|
|
|
18
18
|
// collect 搬家提示。能力是**搬走了不是删了**,所以不能只说「未知命令」
|
|
19
19
|
const MOVED_HINT =
|
|
@@ -26,6 +26,7 @@ const USAGE = `lark-relay ${VERSION} -- Lark 事件中继站(在场取用)
|
|
|
26
26
|
|
|
27
27
|
lark-relay take ... 在场取用:阻塞等一批 -> 输出 -> 退出
|
|
28
28
|
lark-relay status 谁在跑 / 各 app 积压 / 各游标位置
|
|
29
|
+
lark-relay cursors 游标治理:看谁还在值守 / --prune 清失效的
|
|
29
30
|
lark-relay guide 一页用法(装完先读这个)
|
|
30
31
|
|
|
31
32
|
采集底座是独立命令(常驻服务):lark-relay-collect
|
|
@@ -55,6 +56,8 @@ async function main() {
|
|
|
55
56
|
return await cmdTake(rest)
|
|
56
57
|
case 'status':
|
|
57
58
|
return await cmdStatus(rest)
|
|
59
|
+
case 'cursors':
|
|
60
|
+
return cmdCursors(rest)
|
|
58
61
|
// collect 拆成了独立可执行文件,这里专门拦一下告诉人改敲什么。
|
|
59
62
|
// 不能落到 default 分支:suggest('collect', ['take','status','guide']) 必然返回
|
|
60
63
|
// null(编辑距离都 >2、前缀 coll 也不匹配),只会打出干巴巴的「未知命令:collect」--
|
|
@@ -137,8 +140,30 @@ async function cmdTake(argv) {
|
|
|
137
140
|
process.stderr.write(`--since 只能是 now 或 all,当前:${since}\n`)
|
|
138
141
|
return 2
|
|
139
142
|
}
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
// 游标初始化 / 自愈。两种情况都落到「按 --since now 重新起头」:
|
|
144
|
+
// ① 没有游标 —— 首次跑,消灭旧架构「预热游标」那一步
|
|
145
|
+
// ② 游标已 stale —— 位置超出扫描窗口,那段消息 collect 的 gc 已经整目录删了。
|
|
146
|
+
// 续接它和 seed now **行为完全一样**(scanWindow 扫不到那些日期),
|
|
147
|
+
// 但留着会让 status 一直虚报积压。所以当场自愈,并说清楚发生了什么。
|
|
148
|
+
// ⚠️ 只动自己这一个游标,不碰别人的 —— 自愈的边界就在这里。
|
|
149
|
+
// ⚠️ --since all 时不自愈:用户显式要全量重放,尊重它
|
|
150
|
+
if (since === 'now') {
|
|
151
|
+
const existing = store.readCursor(name, a.app)
|
|
152
|
+
if (existing === null) {
|
|
153
|
+
store.seedCursorNow(name, a.app)
|
|
154
|
+
} else {
|
|
155
|
+
const { cursorDay, isStaleDay, oldestScannedDay } = require('../lib/cursors')
|
|
156
|
+
const day = cursorDay(existing)
|
|
157
|
+
if (isStaleDay(day)) {
|
|
158
|
+
// 说明行必须打在「监听中」**之前** —— 那一行是约定的启动信号
|
|
159
|
+
// (「没这行就是没起来」),不能被别的输出插到中间
|
|
160
|
+
process.stderr.write(
|
|
161
|
+
`游标 ${name} 停在 ${day},已超扫描窗口(最早 ${oldestScannedDay()})\n` +
|
|
162
|
+
`-> 那段消息已被 gc 回收,按 --since now 重新起头\n`,
|
|
163
|
+
)
|
|
164
|
+
store.seedCursorNow(name, a.app)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
142
167
|
}
|
|
143
168
|
|
|
144
169
|
// 时长参数校验:负数/非数字过去会静默退回默认值或原样生效
|
|
@@ -192,6 +217,68 @@ async function cmdTake(argv) {
|
|
|
192
217
|
return 0
|
|
193
218
|
}
|
|
194
219
|
|
|
220
|
+
function cmdCursors(argv) {
|
|
221
|
+
const CURSORS_FLAGS = ['prune', 'json', 'help']
|
|
222
|
+
const a = parseArgs(argv, { flags: CURSORS_FLAGS, keys: [] })
|
|
223
|
+
if (a._unknown.length) return rejectUnknown('lark-relay cursors', argv, a._unknown, CURSORS_FLAGS)
|
|
224
|
+
if (a.help) {
|
|
225
|
+
process.stdout.write(`${help.CURSORS_HELP}\n`)
|
|
226
|
+
return 0
|
|
227
|
+
}
|
|
228
|
+
const cursors = require('../lib/cursors')
|
|
229
|
+
|
|
230
|
+
// 只读模式:列清单。默认不删任何东西 —— 删数据必须显式 --prune
|
|
231
|
+
if (!a.prune) {
|
|
232
|
+
const rows = cursors.listCursors()
|
|
233
|
+
if (a.json) {
|
|
234
|
+
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`)
|
|
235
|
+
return 0
|
|
236
|
+
}
|
|
237
|
+
if (!rows.length) {
|
|
238
|
+
process.stdout.write('没有游标(还没跑过 take,或已全部清理)\n')
|
|
239
|
+
return 0
|
|
240
|
+
}
|
|
241
|
+
const mark = { live: '●', idle: '○', stale: '⚠' }
|
|
242
|
+
const note = {
|
|
243
|
+
live: (r) => `在跑 (pid ${r.pid})`,
|
|
244
|
+
idle: () => '暂停,可续接',
|
|
245
|
+
stale: () => '超扫描窗口,续接取不到东西 -> 可清理',
|
|
246
|
+
}
|
|
247
|
+
for (const r of rows) {
|
|
248
|
+
process.stdout.write(
|
|
249
|
+
`${mark[r.state]} ${r.name} app=${r.app} 位置=${r.day} ${note[r.state](r)}\n`,
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
const stale = rows.filter((r) => r.state === 'stale').length
|
|
253
|
+
if (stale) process.stdout.write(`\n${stale} 个 stale -> lark-relay cursors --prune\n`)
|
|
254
|
+
return 0
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --prune:位置参数是要删的身份;没给就只清 stale 的那些
|
|
258
|
+
const names = a._
|
|
259
|
+
const res = cursors.prune(names)
|
|
260
|
+
|
|
261
|
+
// removed 的元素是 `name/app` —— 删的单位是 app,一个身份可能盯多个
|
|
262
|
+
for (const n of res.removed) process.stdout.write(`已删 ${n}\n`)
|
|
263
|
+
for (const n of res.missing) process.stderr.write(`没有这个游标身份:${n}\n`)
|
|
264
|
+
|
|
265
|
+
// 在跑的一律不删 —— take 会立刻把游标写回来,删了是白删还让人以为清理生效了
|
|
266
|
+
for (const r of res.refused) {
|
|
267
|
+
process.stderr.write(
|
|
268
|
+
`跳过 ${r.name}:有 take 正在用它(pid ${r.pid})。\n` +
|
|
269
|
+
` 要清它先停掉那个 take(kill ${r.pid}),否则它会把游标立刻写回来。\n`,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
if (!res.removed.length && !res.missing.length && !res.refused.length) {
|
|
273
|
+
process.stdout.write(names.length ? '没删任何东西\n' : '没有 stale 游标,无需清理\n')
|
|
274
|
+
}
|
|
275
|
+
// 退出码:点名的目标一个都没删成(全被拒/全不存在)才算失败 ——
|
|
276
|
+
// 裸 prune 顺带跳过一个在跑的游标不是错误,那次清理是成功的
|
|
277
|
+
if (res.missing.length) return 2
|
|
278
|
+
if (res.refused.length && !res.removed.length && names.length) return 3
|
|
279
|
+
return 0
|
|
280
|
+
}
|
|
281
|
+
|
|
195
282
|
async function cmdStatus(argv) {
|
|
196
283
|
const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
|
|
197
284
|
if (a._unknown.length) return rejectUnknown('lark-relay status', argv, a._unknown, ['json'])
|
package/lib/cursors.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// cursors:游标治理 -- 判定每个游标是「在跑 / 暂停可续接 / 已失效」,并支持清理。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要这个模块:纪律「不盯了就删游标」写在 AGENTS.md 和 help 三处,
|
|
6
|
+
// 却从落地到 2026-09-06 一次都没被执行 -- 8 个游标全留着,0 个在值守。
|
|
7
|
+
// 根因不是人不自觉,是**没有反馈回路**:
|
|
8
|
+
// ① status 把死游标和活消费者渲染成同一个样子,看不出谁有人在跑
|
|
9
|
+
// ② 唯一的信号「落后 N 条」**越久越小** -- store 只留几天,gc 删掉的部分不再计入 lag,
|
|
10
|
+
// 于是越该清理的游标看起来越健康(实测 novel-watch-lianhua 从 373 条一路缩水)
|
|
11
|
+
// 所以这里给出机械判据,让状态可见、清理一键化,而不是把纪律再写一遍。
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
const path = require('path')
|
|
14
|
+
const store = require('./store')
|
|
15
|
+
const { paths, dayKeyOffset } = require('./paths')
|
|
16
|
+
const { lockPathFor, holderPid } = require('./lock')
|
|
17
|
+
|
|
18
|
+
// stale 判据的地基(**整个方案的正确性都压在这一条上**):
|
|
19
|
+
// 游标位置的日期早于扫描窗口最老的一天时,续接它和 --since now 行为**完全等价** --
|
|
20
|
+
// scanWindow 只看最近 SCAN_DAYS 天的目录,更早的日期 collect 的 gc 已经整目录删了,
|
|
21
|
+
// afterCursor 过滤后必然为空。也就是说**删掉这种游标是无损的**,不会丢任何还能取到的消息。
|
|
22
|
+
//
|
|
23
|
+
// ⚠️ 反过来说,窗口**内**的游标不能自动删 -- 那才是「关了会话、过阵子接着盯」的续接凭据,
|
|
24
|
+
// 删了等于跳过停机期已落盘的消息。这是 idle 与 stale 必须分开的全部理由
|
|
25
|
+
function oldestScannedDay() {
|
|
26
|
+
return dayKeyOffset(-(store.SCAN_DAYS - 1))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 游标值形如 `2026-09-04/<stamp>_<pid>_<seq>.json`,取日期段。
|
|
30
|
+
// YYYY-MM-DD 字典序 == 时间序,故可直接字符串比较(store.afterCursor 已在用这个性质)
|
|
31
|
+
function cursorDay(key) {
|
|
32
|
+
return String(key || '').split('/')[0] || null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isStaleDay(day) {
|
|
36
|
+
if (!day) return true // 读不出日期的游标续接不了,按失效处理
|
|
37
|
+
return day < oldestScannedDay()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 活消费者判定:复用 take 已经在用的锁,不另造注册表。
|
|
41
|
+
// take 用 `take-<name>` 作锁键(bin/lark-relay.js),锁目录里的 pid 文件就是持有者
|
|
42
|
+
function livePidOf(name) {
|
|
43
|
+
return holderPid(`take-${name}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 枚举 cursors/ 下的全部游标。
|
|
47
|
+
// 一个 name 目录下可能有多个 app 文件,外加 dedup 的 <app>.seen.json 与落盘用的 .tmp --
|
|
48
|
+
// 只有「没后缀的那些」才是游标本身,别把 dedup 文件当成一个 app
|
|
49
|
+
function listCursors() {
|
|
50
|
+
let names
|
|
51
|
+
try {
|
|
52
|
+
names = fs.readdirSync(paths.cursors).filter((n) => !n.startsWith('.'))
|
|
53
|
+
} catch {
|
|
54
|
+
return [] // 还没跑过任何 take
|
|
55
|
+
}
|
|
56
|
+
const out = []
|
|
57
|
+
for (const name of names.sort()) {
|
|
58
|
+
const dir = path.join(paths.cursors, name)
|
|
59
|
+
let files
|
|
60
|
+
try {
|
|
61
|
+
if (!fs.statSync(dir).isDirectory()) continue
|
|
62
|
+
files = fs.readdirSync(dir)
|
|
63
|
+
} catch {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
const pid = livePidOf(name)
|
|
67
|
+
for (const f of files.sort()) {
|
|
68
|
+
if (f.startsWith('.') || f.endsWith('.tmp') || f.endsWith('.seen.json')) continue
|
|
69
|
+
const key = store.readCursor(name, f)
|
|
70
|
+
if (!key) continue
|
|
71
|
+
const day = cursorDay(key)
|
|
72
|
+
// live 优先于 stale:take 正在跑就以它为准 -- 它马上会把游标推到当前位置,
|
|
73
|
+
// 此刻位置旧只说明它刚起来还没追上,不代表失效
|
|
74
|
+
const state = pid ? 'live' : isStaleDay(day) ? 'stale' : 'idle'
|
|
75
|
+
out.push({ name, app: f, cursor: key, day, state, pid })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 删一个身份下的**指定 app** 游标,连同它的 dedup 记录。
|
|
82
|
+
// ⚠️ 必须连 <app>.seen.json 一起删 -- 留下 dedup 残留会让重挂后的头几条消息
|
|
83
|
+
// 被误判成重复而静默丢掉(TTL 6 小时,见 dedup.js)。
|
|
84
|
+
//
|
|
85
|
+
// ⚠️ 粒度必须是 app 而不是整个身份目录:一个 --name 可以同时盯多个 app
|
|
86
|
+
// (cursors/<name>/appA、<name>/appB),而 stale 是**逐 app 判定**的。
|
|
87
|
+
// 早先这里 rm -rf 整个 name 目录,于是「appA 已过期、appB 还在窗口内」时
|
|
88
|
+
// 清 appA 会把 appB 那个**还能续接**的游标一起删掉(实测复现)。
|
|
89
|
+
function removeCursorApp(name, app) {
|
|
90
|
+
const dir = path.join(paths.cursors, name)
|
|
91
|
+
let ok = false
|
|
92
|
+
for (const f of [app, `${app}.seen.json`, `${app}.tmp`]) {
|
|
93
|
+
try {
|
|
94
|
+
fs.rmSync(path.join(dir, f), { force: true })
|
|
95
|
+
ok = true
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
// 目录空了才删目录 -- 还有别的 app 就留着
|
|
99
|
+
try {
|
|
100
|
+
if (!fs.readdirSync(dir).length) fs.rmdirSync(dir)
|
|
101
|
+
} catch {}
|
|
102
|
+
return ok
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 顺带清掉该身份的锁目录(若持有者已死)。
|
|
106
|
+
// prune 删了游标却留着 take-<name>.lock,会在 run/ 里积累孤儿锁 --
|
|
107
|
+
// 不致命(下次抢锁会夺走陈旧锁),但既然在清理就一并收干净
|
|
108
|
+
function removeStaleLock(name) {
|
|
109
|
+
if (holderPid(`take-${name}`)) return // 还活着,不碰
|
|
110
|
+
try {
|
|
111
|
+
fs.rmSync(lockPathFor(`take-${name}`), { recursive: true, force: true })
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 清理游标。
|
|
117
|
+
* @param {string[]} names 指定要删的身份(该身份下全部 app);空数组 = 只删 stale 的那些 app
|
|
118
|
+
* @returns {{removed:string[], refused:Array<{name:string,pid:number}>, missing:string[]}}
|
|
119
|
+
* removed 的元素形如 `name/app` -- 删的单位是 app,不是整个身份
|
|
120
|
+
*/
|
|
121
|
+
function prune(names = []) {
|
|
122
|
+
const all = listCursors()
|
|
123
|
+
// 一个 name 可能有多个 app 行,按身份归并 -- live 判定是按身份的(锁以 name 为键)
|
|
124
|
+
const byName = new Map()
|
|
125
|
+
for (const c of all) {
|
|
126
|
+
if (!byName.has(c.name)) byName.set(c.name, [])
|
|
127
|
+
byName.get(c.name).push(c)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const removed = []
|
|
131
|
+
const refused = []
|
|
132
|
+
const missing = []
|
|
133
|
+
|
|
134
|
+
const targets = names.length ? names : [...byName.keys()]
|
|
135
|
+
|
|
136
|
+
for (const name of targets) {
|
|
137
|
+
const rows = byName.get(name)
|
|
138
|
+
if (!rows) {
|
|
139
|
+
missing.push(name)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
// live 一律不删,即使显式点名:take 还在跑,它会立刻把游标写回来 --
|
|
143
|
+
// 删了是白删,还会让人以为清理没生效
|
|
144
|
+
const live = rows.find((r) => r.state === 'live')
|
|
145
|
+
if (live) {
|
|
146
|
+
refused.push({ name, pid: live.pid })
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
// 显式点名 = 清该身份的全部 app(用户明确不盯了);
|
|
150
|
+
// 裸 prune = 只清 stale 的那些 app,窗口内的留着续接
|
|
151
|
+
const victims = names.length ? rows : rows.filter((r) => r.state === 'stale')
|
|
152
|
+
for (const r of victims) {
|
|
153
|
+
if (removeCursorApp(name, r.app)) removed.push(`${name}/${r.app}`)
|
|
154
|
+
}
|
|
155
|
+
if (victims.length) removeStaleLock(name)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { removed, refused, missing }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
listCursors,
|
|
163
|
+
prune,
|
|
164
|
+
removeCursorApp,
|
|
165
|
+
cursorDay,
|
|
166
|
+
isStaleDay,
|
|
167
|
+
oldestScannedDay,
|
|
168
|
+
}
|
package/lib/help.js
CHANGED
|
@@ -48,16 +48,16 @@ function takeHelp(apps) {
|
|
|
48
48
|
最佳实践
|
|
49
49
|
· 一直等不到消息 -> 九成是 bot 不在群,回第二步验(最常见故障)
|
|
50
50
|
· 多个消费者盯同一 app 用不同 --name,游标互不干扰
|
|
51
|
-
·
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
· 不盯了就清游标:lark-relay cursors --prune
|
|
52
|
+
引擎不主动删你的续接凭据(它是跨会话接着盯的凭据)。留着会在 status 里
|
|
53
|
+
一直挂「落后 N 条」,而 store 只保留几天 -- N 会随过期删除而变小,
|
|
54
54
|
看着像追上了,其实是消息没了`
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const COLLECT_HELP = `把全部 lark-cli profile 的事件收下来,原子落盘。零参数、零配置。
|
|
58
58
|
|
|
59
59
|
lark-relay-collect # 前台跑(调试)
|
|
60
|
-
常驻:macOS 用 launchd(
|
|
60
|
+
常驻:macOS 用 launchd(见下「常驻部署」)
|
|
61
61
|
|
|
62
62
|
独立可执行文件而非 \`lark-relay\` 的子命令 -- 它是常驻服务,服务管理器展示的是
|
|
63
63
|
可执行文件名,与 launchd label(com.adaex.lark-relay-collect)同名才认得出。
|
|
@@ -86,20 +86,56 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
|
|
|
86
86
|
|
|
87
87
|
环境变量
|
|
88
88
|
LARK_RELAY_LOG_MAX_MB 单个日志文件上限(默认 16),超了轮转
|
|
89
|
-
LARK_RELAY_LOG_KEEP 保留几代旧日志(默认 3)
|
|
89
|
+
LARK_RELAY_LOG_KEEP 保留几代旧日志(默认 3)
|
|
90
|
+
|
|
91
|
+
常驻部署(macOS / launchd)
|
|
92
|
+
要点只有三条,plist 自己写十来行就够,不必找模板:
|
|
93
|
+
· 用 **LaunchDaemon**(装 /Library/LaunchDaemons/)+ \`UserName\` 降权到你自己,
|
|
94
|
+
不用 LaunchAgent -- Agent 要 GUI 登录才加载、登出即停,而事件零回放,停摆=丢消息
|
|
95
|
+
· \`ProgramArguments\` 写 \`lark-relay-collect\` 的**绝对路径**(\`which lark-relay-collect\`),
|
|
96
|
+
并显式设 \`EnvironmentVariables\` 里的 \`HOME\` 与 \`PATH\` --
|
|
97
|
+
launchd 不展开 ~ 和 $VAR,也不继承你的 shell 环境
|
|
98
|
+
· \`KeepAlive\` 用 \`SuccessfulExit=false\`(只在非零退出时重启);
|
|
99
|
+
\`StandardErrorPath\` 指到一个**父目录已存在**的路径,launchd 不建目录
|
|
100
|
+
装:sudo launchctl bootstrap system /Library/LaunchDaemons/<label>.plist
|
|
101
|
+
改了 plist 必须 bootout + bootstrap -- \`kickstart -k\` 只重启进程、不重读 plist`
|
|
90
102
|
|
|
91
103
|
const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪。
|
|
92
104
|
|
|
93
105
|
lark-relay status # 概览
|
|
94
106
|
lark-relay status --json # 机器可读
|
|
95
107
|
|
|
96
|
-
|
|
108
|
+
消费者三态:
|
|
109
|
+
● 在跑 有 take 持锁,pid 是它
|
|
110
|
+
○ 暂停 没人在跑,但位置还在扫描窗口内 -- 再起一个 take 能接着盯
|
|
111
|
+
⚠ stale 位置已超扫描窗口,那段消息 gc 已回收,续接取不到东西 -> 该清理
|
|
112
|
+
|
|
113
|
+
排障入口:「○ 暂停」而你以为它在盯 -> 它的 take 没起来或早退了;
|
|
97
114
|
「无消费者」= 白采,可考虑 --exclude。
|
|
98
|
-
|
|
99
|
-
rm -rf ~/.lark-relay/cursors/<name>
|
|
115
|
+
stale 游标清理:lark-relay cursors --prune
|
|
100
116
|
collect 行不是 running -> tail -f ~/.lark-relay/logs/collect.err.log
|
|
101
117
|
(launchd 不进统一日志),collect 停摆的每一秒都在丢消息。`
|
|
102
118
|
|
|
119
|
+
const CURSORS_HELP = `游标治理:看清哪些还在值守,清掉已失效的。
|
|
120
|
+
|
|
121
|
+
lark-relay cursors # 列出全部游标及状态
|
|
122
|
+
lark-relay cursors --prune # 清掉全部 stale 的
|
|
123
|
+
lark-relay cursors --prune <name>... # 清指定身份的全部 app(在跑的会跳过)
|
|
124
|
+
|
|
125
|
+
三态:● 在跑 / ○ 暂停(窗口内,可续接) / ⚠ stale(超窗口,续接无意义)
|
|
126
|
+
|
|
127
|
+
删的单位是 **app**,不是身份:一个 --name 可以同时盯多个 app,
|
|
128
|
+
裸 --prune 只清其中 stale 的那些,窗口内的留着续接(点名才整个身份清)。
|
|
129
|
+
连带删该 app 的 dedup 记录(<app>.seen.json),不留会让重挂后头几条被误判重复。
|
|
130
|
+
|
|
131
|
+
为什么 stale 能放心删:消费侧只扫最近几天的目录,更早的日期 collect 的 gc
|
|
132
|
+
已整目录删掉。**续接一个超窗口的游标和 --since now 行为完全一样** --
|
|
133
|
+
删它不会丢任何还取得到的消息,留着只会让 status 虚报积压。
|
|
134
|
+
反过来,窗口内的「○ 暂停」不自动删 -- 那是「关了会话、过阵子接着盯」的续接凭据。
|
|
135
|
+
|
|
136
|
+
退出码:0 正常(含「跳过了在跑的那个」),2 参数错或点名的身份不存在,
|
|
137
|
+
3 点名的目标全在跑、一个都没清掉`
|
|
138
|
+
|
|
103
139
|
const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来取。
|
|
104
140
|
|
|
105
141
|
\`lark-relay-collect\` 把事件收下来落盘(常驻服务),\`lark-relay take\` 在你需要时取走一批。
|
|
@@ -109,7 +145,7 @@ const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来
|
|
|
109
145
|
前置:采集必须在跑
|
|
110
146
|
lark-relay status # collect 行应为 running
|
|
111
147
|
进程不在的时刻消息永久丢失,不是延迟送达。
|
|
112
|
-
不在跑 -> 起常驻服务(macOS launchd
|
|
148
|
+
不在跑 -> 起常驻服务(macOS launchd,要点见 \`lark-relay-collect --help\`;
|
|
113
149
|
或 \`lark-relay-collect\` 前台调试)
|
|
114
150
|
|
|
115
151
|
take 四步(照做)
|
|
@@ -121,9 +157,12 @@ take 四步(照做)
|
|
|
121
157
|
起来后 stderr 有一行「监听中」-- 没有就是没起来,看 stderr。
|
|
122
158
|
一批处理完再起一个。不要自己写 while 循环 -- 进程退出会通知你。
|
|
123
159
|
退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
|
|
124
|
-
4. 不盯了收尾
|
|
125
|
-
|
|
126
|
-
|
|
160
|
+
4. 不盯了收尾 lark-relay cursors --prune
|
|
161
|
+
游标为「关了会话、过阵子接着盯」而存在,引擎不主动删你的续接凭据。
|
|
162
|
+
临时监听、验完的排查、不再值守的任务,收尾时自己清 --
|
|
163
|
+
不清的代价:status 里一直挂着假积压(数字会随 store 过期变小,
|
|
164
|
+
看着像追上了,其实是消息永久没了)。
|
|
165
|
+
显式清掉某个身份:lark-relay cursors --prune <name>
|
|
127
166
|
|
|
128
167
|
常见错误
|
|
129
168
|
· 拿不到消息 -> 九成是 bot 不在群。验 bot 必须 --as user,
|
|
@@ -137,4 +176,4 @@ take 四步(照做)
|
|
|
137
176
|
|
|
138
177
|
各命令详情:lark-relay <命令> --help`
|
|
139
178
|
|
|
140
|
-
module.exports = { takeHelp, COLLECT_HELP, STATUS_HELP, GUIDE }
|
|
179
|
+
module.exports = { takeHelp, COLLECT_HELP, STATUS_HELP, CURSORS_HELP, GUIDE }
|
package/lib/lock.js
CHANGED
|
@@ -19,7 +19,7 @@ const { paths, ensureDir } = require('./paths')
|
|
|
19
19
|
function acquireExclusive(key) {
|
|
20
20
|
const safe = sanitize(key)
|
|
21
21
|
const runDir = ensureDir(paths.run)
|
|
22
|
-
const dir =
|
|
22
|
+
const dir = lockPathFor(key)
|
|
23
23
|
const pidFile = path.join(dir, 'pid')
|
|
24
24
|
const me = String(process.pid)
|
|
25
25
|
|
|
@@ -114,4 +114,18 @@ function sanitize(s) {
|
|
|
114
114
|
return String(s).replace(/[^A-Za-z0-9._-]/g, '_')
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
// 锁目录位置。**外部判活必须走这个函数**,不能自己拼路径 ——
|
|
118
|
+
// sanitize() 会把非 [A-Za-z0-9._-] 换成 `_`,自己拼会让含特殊字符的 key 匹配不上
|
|
119
|
+
function lockPathFor(key) {
|
|
120
|
+
return path.join(paths.run, `${sanitize(key)}.lock`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 谁持有这把锁(活着才算)。没锁、锁陈旧(持有者已死)都返回 null。
|
|
124
|
+
// 复用上面的 readPid/alive —— 与抢锁时的判活口径完全一致
|
|
125
|
+
function holderPid(key) {
|
|
126
|
+
const pid = readPid(path.join(lockPathFor(key), 'pid'))
|
|
127
|
+
if (!pid) return null
|
|
128
|
+
return alive(pid) ? pid : null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { acquireExclusive, lockPathFor, holderPid }
|
package/lib/render.js
CHANGED
|
Binary file
|
package/lib/status.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
// status:一眼看清全局 —— 谁在跑、积压多少、游标在哪。
|
|
4
|
-
const fs = require('fs')
|
|
5
|
-
const path = require('path')
|
|
6
4
|
const { execFileSync } = require('child_process')
|
|
7
5
|
const store = require('./store')
|
|
8
|
-
const { paths } = require('./paths')
|
|
9
6
|
const larkcli = require('./larkcli')
|
|
7
|
+
const cursors = require('./cursors')
|
|
10
8
|
|
|
11
9
|
function ago(ms) {
|
|
12
10
|
if (!ms) return '—'
|
|
@@ -52,32 +50,10 @@ function collectHint() {
|
|
|
52
50
|
return `sudo launchctl bootstrap system /Library/LaunchDaemons/${COLLECT_LABEL}.plist`
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
// 消费者 = cursors/
|
|
53
|
+
// 消费者 = cursors/ 下的目录。枚举与三态判定都在 lib/cursors.js,这里只做展示 ——
|
|
54
|
+
// 「哪些游标存在、谁还活着」是治理逻辑,status 和 cursors 子命令必须用同一份口径
|
|
56
55
|
function listConsumers() {
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
names = fs.readdirSync(paths.cursors).filter((n) => !n.startsWith('.'))
|
|
60
|
-
} catch {
|
|
61
|
-
return []
|
|
62
|
-
}
|
|
63
|
-
const out = []
|
|
64
|
-
for (const name of names) {
|
|
65
|
-
const dir = path.join(paths.cursors, name)
|
|
66
|
-
let files
|
|
67
|
-
try {
|
|
68
|
-
if (!fs.statSync(dir).isDirectory()) continue
|
|
69
|
-
files = fs.readdirSync(dir)
|
|
70
|
-
} catch {
|
|
71
|
-
continue
|
|
72
|
-
}
|
|
73
|
-
for (const f of files) {
|
|
74
|
-
if (f.endsWith('.tmp') || f.endsWith('.seen.json')) continue
|
|
75
|
-
const key = store.readCursor(name, f)
|
|
76
|
-
if (!key) continue
|
|
77
|
-
out.push({ name, app: f, cursor: key })
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return out
|
|
56
|
+
return cursors.listCursors()
|
|
81
57
|
}
|
|
82
58
|
|
|
83
59
|
// 落后多少条:游标之后还剩几个事件没消费
|
|
@@ -114,8 +90,10 @@ async function buildStatus() {
|
|
|
114
90
|
latestMs = Number(ev?.timestamp || ev?.create_time) || null
|
|
115
91
|
}
|
|
116
92
|
const cs = (byApp.get(app) || []).map((c) => {
|
|
117
|
-
|
|
118
|
-
|
|
93
|
+
// stale 的 lag 不算也不显示 —— 那个数字是误导的来源:gc 删掉的部分不再计入,
|
|
94
|
+
// 于是越久没消费看起来越「追上了」,而实际是消息永久没了
|
|
95
|
+
const lag = c.state === 'stale' ? null : lagOf(app, c.cursor)
|
|
96
|
+
return { name: c.name, lag, state: c.state, pid: c.pid, cursor: c.cursor, day: c.day }
|
|
119
97
|
})
|
|
120
98
|
rows.push({ app, today: store.countToday(app), latestMs, consumers: cs })
|
|
121
99
|
}
|
|
@@ -149,13 +127,38 @@ function formatStatus(s) {
|
|
|
149
127
|
}
|
|
150
128
|
const w = (str, n) => String(str) + ' '.repeat(Math.max(1, n - dispWidth(str)))
|
|
151
129
|
out.push(`${w('app', 8)}${w('今日事件', 10)}${w('最新事件', 13)}消费者`)
|
|
130
|
+
let staleCount = 0
|
|
152
131
|
for (const r of s.apps) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
132
|
+
// 消费者各占一行:三态要一眼分得清,挤在一行里 ●/○/⚠ 会被淹掉。
|
|
133
|
+
// 「谁在跑」是排障第一问,以前得靠猜(全都长成 `name(落后 N 条)`)
|
|
134
|
+
if (!r.consumers.length) {
|
|
135
|
+
out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}—(无消费者)`)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}`.trimEnd())
|
|
139
|
+
for (const c of r.consumers) {
|
|
140
|
+
const lagTxt = c.lag === 0 ? '游标追平' : `落后 ${c.lag} 条`
|
|
141
|
+
let mark
|
|
142
|
+
let note
|
|
143
|
+
if (c.state === 'live') {
|
|
144
|
+
mark = '●'
|
|
145
|
+
note = `在跑 (pid ${c.pid}, ${lagTxt})`
|
|
146
|
+
} else if (c.state === 'stale') {
|
|
147
|
+
staleCount++
|
|
148
|
+
mark = '⚠'
|
|
149
|
+
// 不给 lag:它已经不代表「还能取到多少」。给位置和原因才可行动
|
|
150
|
+
note = `stale (游标停在 ${c.day || '?'},已超 ${cursors.oldestScannedDay()} 的扫描窗口,续接取不到东西)`
|
|
151
|
+
} else {
|
|
152
|
+
mark = '○'
|
|
153
|
+
note = `暂停 (${lagTxt},可续接)`
|
|
154
|
+
}
|
|
155
|
+
out.push(` ${w(c.name, 24)}${mark} ${note}`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (staleCount) {
|
|
159
|
+
out.push('')
|
|
160
|
+
out.push(`⚠ ${staleCount} 个 stale 游标 —— 那段消息 gc 已回收,留着只会虚报积压`)
|
|
161
|
+
out.push(' 清理:lark-relay cursors --prune')
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
return out.join('\n')
|