lark-relay 0.4.8 → 0.4.9

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/README.md CHANGED
@@ -25,16 +25,13 @@ Lark 事件是**流式**的 -- 进程不在的时刻,消息**永久丢失**,不
25
25
  停摆的每一秒都在丢消息。
26
26
 
27
27
  分离到**可执行文件**这一层,不是两个子命令:采集是常驻服务,服务管理器
28
- (systemd / launchd)展示的是可执行文件名,与单元名同名才认得出是谁。
29
-
30
- | | |
31
- | --- | --- |
32
- | 长命的 | AI 会话(人设、上下文连续) |
33
- | 短命的 | 消息(来一批处理一批) |
34
- | 谁在等谁 | AI 等消息 |
35
- | 控制权 | AI 手里(自己决定何时再取) |
36
- | 谁能干 | 任何 agent(Claude Code / Codex / 手敲) |
37
- | 配置 | 纯参数,无文件 |
28
+ (launchd)展示的是可执行文件名,与 label 同名才认得出是谁。
29
+
30
+ `take` 的形态:
31
+
32
+ - **长命的是 AI 会话**(人设、上下文连续),**短命的是消息**(来一批处理一批)
33
+ - **AI 等消息**,不是消息推给 AI —— 控制权在 AI 手里,自己决定何时再取
34
+ - 任何 agent 都能干(Claude Code / Codex / 手敲),纯参数、无配置文件
38
35
 
39
36
  ## 命令
40
37
 
@@ -4,10 +4,10 @@
4
4
  // lark-relay-collect —— 事件采集底座,常驻服务专用入口。
5
5
  //
6
6
  // 为什么独立成一个可执行文件、不做 `lark-relay collect` 子命令:
7
- // 这是个 LaunchDaemon / systemd 单元,服务管理器展示的是 ProgramArguments[0]。
7
+ // 这是个 LaunchDaemon,服务管理器展示的是 ProgramArguments[0]。
8
8
  // 挤在通用 CLI 里时 `launchctl print` 的 program 栏是 `lark-relay`、`ps` 的 COMM
9
9
  // 是裸 `node` —— 看不出这是常驻服务。独立后 bin 名与 launchd label
10
- // (com.adaex.lark-relay-collect)、systemd 单元名三处同名,好搜好认。
10
+ // (com.adaex.lark-relay-collect)同名,好搜好认。
11
11
  //
12
12
  // 采集逻辑全在 lib/collect.js,本文件只做参数解析与进程身份。
13
13
 
@@ -17,8 +17,9 @@ require('../lib/utf8').ensureUtf8()
17
17
  // 排障第一步常是 `ps | grep`,进程认不出来会先浪费一轮
18
18
  process.title = 'lark-relay-collect'
19
19
 
20
- const { parseArgs, num, list, rejectUnknown } = require('../lib/args')
20
+ const { parseArgs, numStrict, list, rejectUnknown } = require('../lib/args')
21
21
  const help = require('../lib/help')
22
+ const store = require('../lib/store')
22
23
 
23
24
  const VERSION = require('../package.json').version
24
25
  const PROG = 'lark-relay-collect'
@@ -63,19 +64,33 @@ async function main() {
63
64
  return 2
64
65
  }
65
66
 
67
+ // retain 必须落在消费侧的扫描窗口内,否则事件留在盘上却永远扫不到 =
68
+ // 静默丢消息(正是 store.js 那条注释警告的)。两个值在两个进程里,
69
+ // 只能在这里把不变量钉死:装完新版直接起不来,好过悄悄丢
70
+ const retain = numStrict(a.retain, 3)
71
+ if (retain === null || !(retain >= 1) || retain > store.SCAN_DAYS) {
72
+ process.stderr.write(
73
+ `--retain 需要 1~${store.SCAN_DAYS} 之间(消费侧扫描窗口 ${store.SCAN_DAYS} 天),当前:${a.retain}\n` +
74
+ `要保留更久得同时调大 LARK_RELAY_SCAN_DAYS,否则超窗口的事件留着也扫不到\n`,
75
+ )
76
+ return 2
77
+ }
78
+
66
79
  const { runCollect } = require('../lib/collect')
67
80
  return await runCollect({
68
81
  exclude: list(a.exclude),
69
- retain: num(a.retain, 3),
82
+ retain,
70
83
  })
71
84
  }
72
85
 
73
86
  main()
74
- .then((code) => process.exit(code || 0))
87
+ .then((code) => {
88
+ process.exitCode = code || 0
89
+ })
75
90
  .catch((err) => {
76
91
  // 注:runCollect 内部的错误走 lib/log.js 的 logLine(带时间戳,常驻服务要的)。
77
92
  // 这里只兜住它之前/之外的意外,故是裸 stderr
78
93
  if (err && err.userFacing) process.stderr.write(`${err.message}\n`)
79
94
  else process.stderr.write(`${PROG}: ${(err && err.stack) || err}\n`)
80
- process.exit(1)
95
+ process.exitCode = 1
81
96
  })
package/bin/lark-relay.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  require('../lib/utf8').ensureUtf8()
5
5
 
6
- const { parseArgs, num, suggest, rejectUnknown } = require('../lib/args')
6
+ const { parseArgs, numStrict, suggest, rejectUnknown } = require('../lib/args')
7
7
  const help = require('../lib/help')
8
8
 
9
9
  const VERSION = require('../package.json').version
@@ -129,14 +129,33 @@ async function cmdTake(argv) {
129
129
  return 3
130
130
  }
131
131
 
132
+ // --since 只有 now|all 两个取值。必须白名单校验:曾经是「跟 'now' 比一次,
133
+ // 其余全当 all」,于是 `--since Now`(大写)、`--since 乱码` 都静默变成全量重放。
134
+ // 与本仓「拼错参数当场硬失败」的铁律一致(args.js 开头那条)
132
135
  const since = a.since === true ? 'now' : a.since || 'now'
136
+ if (since !== 'now' && since !== 'all') {
137
+ process.stderr.write(`--since 只能是 now 或 all,当前:${since}\n`)
138
+ return 2
139
+ }
133
140
  if (since === 'now' && store.readCursor(name, a.app) === null) {
134
141
  store.seedCursorNow(name, a.app) // 默认不重放历史,消灭「预热游标」这一步
135
142
  }
136
143
 
137
- const debounceMs = num(a.debounce, 5) * 1000
138
- const maxWaitMs = num(a['max-wait'], 25) * 1000
139
- const timeoutMs = num(a.timeout, 12 * 3600) * 1000
144
+ // 时长参数校验:负数/非数字过去会静默退回默认值或原样生效
145
+ // (实测 --debounce -5 被接受并打印「防抖=-5s」,--timeout abc 悄悄变成 12 小时)
146
+ const dur = (val, def, label) => {
147
+ const n = numStrict(val, def)
148
+ if (n === null || !(n >= 0)) {
149
+ const e = new Error(`--${label} 需要 >= 0 的秒数,当前:${val === true ? '(没给值)' : val}`)
150
+ e.userFacing = true
151
+ e.exitCode = 2
152
+ throw e
153
+ }
154
+ return n * 1000
155
+ }
156
+ const debounceMs = dur(a.debounce, 5, 'debounce')
157
+ const maxWaitMs = dur(a['max-wait'], 25, 'max-wait')
158
+ const timeoutMs = dur(a.timeout, 12 * 3600, 'timeout')
140
159
 
141
160
  // 起来先打一行「监听中」-- 没这行就是没起来。
142
161
  // 反馈实测:进程静默退出(参数错)被当成「在监听」,漏看 bot 第一条 Working
@@ -188,9 +207,15 @@ async function cmdStatus(argv) {
188
207
  }
189
208
 
190
209
  main()
191
- .then((code) => process.exit(code || 0))
210
+ .then((code) => {
211
+ // ⚠️ 不能用 process.exit() —— stdout 是管道时它会丢掉未 flush 的缓冲。
212
+ // 实测:400 条 NDJSON 重定向到文件得 676070 字节,同样数据接管道只收到
213
+ // 65536(管道缓冲上限)且切在 JSON 中途,而 stderr 照报「400 条」。
214
+ // 设 exitCode 让事件循环自然排空,退出码语义不变
215
+ process.exitCode = code || 0
216
+ })
192
217
  .catch((err) => {
193
218
  if (err && err.userFacing) process.stderr.write(`${err.message}\n`)
194
219
  else process.stderr.write(`lark-relay: ${(err && err.stack) || err}\n`)
195
- process.exit(1)
220
+ process.exitCode = (err && err.exitCode) || 1
196
221
  })
package/lib/args.js CHANGED
@@ -49,10 +49,14 @@ function parseArgs(argv, spec = {}) {
49
49
  return out
50
50
  }
51
51
 
52
- function num(v, dflt) {
53
- if (v === undefined || v === true) return dflt
52
+ // 数值参数:省略 -> 默认值;给了但不是有效数字 -> null,由调用方硬失败。
53
+ // ⚠️ 不要退化成「非法值悄悄用默认值」—— 那样 `--timeout abc` 就成了
54
+ // 「跑起来但行为不对」,与本仓「拼错当场失败」的铁律相悖(实测它会静默变成 12 小时)
55
+ function numStrict(v, dflt) {
56
+ if (v === undefined) return dflt
57
+ if (v === true) return null // `--timeout` 后面没跟值
54
58
  const n = Number(v)
55
- return Number.isFinite(n) ? n : dflt
59
+ return Number.isFinite(n) ? n : null
56
60
  }
57
61
 
58
62
  function list(v) {
@@ -160,4 +164,4 @@ function rewriteArgv(argv, unknown, allowed) {
160
164
  return out
161
165
  }
162
166
 
163
- module.exports = { parseArgs, num, list, suggest, rejectUnknown, rewriteArgv }
167
+ module.exports = { parseArgs, numStrict, list, suggest, rejectUnknown, rewriteArgv }
package/lib/collect.js CHANGED
@@ -117,7 +117,7 @@ async function runCollect(opts) {
117
117
  ensureDir(paths.store)
118
118
  ensureDir(paths.cursors)
119
119
  // 日志接管要最早做 —— 下面的启动横幅、profile 报错都该带上时间戳。
120
- // 非 launchd 场景(journald / 前台)是 no-op,logLine 退化成裸 stderr 直写
120
+ // 非 launchd 场景(前台调试)是 no-op,logLine 退化成裸 stderr 直写
121
121
  logInit()
122
122
 
123
123
  const exclude = new Set(opts.exclude || [])
@@ -132,7 +132,7 @@ async function runCollect(opts) {
132
132
  `collect 依赖 lark-cli 提供认证与事件总线。检查:\n` +
133
133
  ` which ${larkcli.CLI} # 装了吗、在 PATH 里吗\n` +
134
134
  ` ${larkcli.CLI} profile list # 能跑吗\n` +
135
- `常驻服务(systemd 单元/launchd plist)的 PATH 要用 fnm default alias 的稳定路径,不能用会话级的 multishell 路径`,
135
+ `常驻服务(launchd plist)的 PATH 要用 fnm default alias 的稳定路径,不能用会话级的 multishell 路径`,
136
136
  )
137
137
  return 1
138
138
  }
package/lib/filter.js CHANGED
@@ -39,7 +39,7 @@ function applyFilter(events, expr) {
39
39
  } catch (err) {
40
40
  // 保守放行 + warn,不抛 —— 抛出去会让常驻消费者崩溃循环:
41
41
  // 表达式对某类事件报错(如 `.mentions[].id` 遇到没有 mentions 的消息)时,
42
- // 那条消息永远卡在队首,游标推不动,systemd 5s 重启一次,该任务彻底停摆。
42
+ // 那条消息永远卡在队首,游标推不动,服务每 5s 重启一次,该任务彻底停摆。
43
43
  // 放行的代价是模型多看几条本该滤掉的消息,远小于停摆
44
44
  process.stderr.write(
45
45
  `warn: --filter 求值失败,本批不过滤(${events.length} 条放行)。表达式:${expr}\n` +
package/lib/help.js CHANGED
@@ -53,10 +53,10 @@ function takeHelp(apps) {
53
53
  const COLLECT_HELP = `把全部 lark-cli profile 的事件收下来,原子落盘。零参数、零配置。
54
54
 
55
55
  lark-relay-collect # 前台跑(调试)
56
- 常驻:Linux 用 systemd,macOS 用 launchd(单元模板见仓库 systemd//launchd 目录)
56
+ 常驻:macOS 用 launchd(plist 模板见仓库 launchd/ 目录)
57
57
 
58
58
  独立可执行文件而非 \`lark-relay\` 的子命令 —— 它是常驻服务,服务管理器展示的是
59
- 可执行文件名,与 launchd label(com.adaex.lark-relay-collect)、systemd 单元名同名才认得出。
59
+ 可执行文件名,与 launchd label(com.adaex.lark-relay-collect)同名才认得出。
60
60
 
61
61
  app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需改配置。
62
62
  每 app 一个子进程;某个挂了单独重启,不影响其他。
@@ -104,7 +104,7 @@ const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来
104
104
  前置:采集必须在跑
105
105
  lark-relay status # collect 行应为 running
106
106
  进程不在的时刻消息永久丢失,不是延迟送达。
107
- 不在跑 -> 起常驻服务(Linux systemd / macOS launchd,模板见仓库;
107
+ 不在跑 -> 起常驻服务(macOS launchd,plist 模板见仓库;
108
108
  或 \`lark-relay-collect\` 前台调试)
109
109
 
110
110
  take 三步(照做)
package/lib/log.js CHANGED
@@ -4,8 +4,8 @@
4
4
  // CLI 的用户可见输出(take 的 NDJSON 载荷、status --json)绝不能过这里 --
5
5
  // 加前缀会破坏下游解析契约。
6
6
  //
7
- // 为什么需要:Linux journald 免费给时间戳和轮转,macOS launchd 只有
8
- // StandardErrorPath 直写文件 -- 无时间戳、无轮转、无分级。而本项目的核心排查
7
+ // 为什么需要:launchd 只有 StandardErrorPath 直写文件 -- 无时间戳、无轮转、
8
+ // 无分级(journald 那种免费给时间戳和轮转的东西这边没有)。而本项目的核心排查
9
9
  // 问题恰是「**什么时候**断线、断了多久、那个窗口丢了几条」,时间戳是唯一依据。
10
10
  const fs = require('fs')
11
11
  const path = require('path')
@@ -13,11 +13,10 @@ const { paths } = require('./paths')
13
13
 
14
14
  // 激活判据:stderr 是普通文件 = launchd 直写场景。
15
15
  // 实测 fs.fstatSync(2):pipe -> isFile=false/isFIFO=true;重定向到文件 -> isFile=true。
16
- // 这一个判据同时分开了三种场景:
16
+ // 这一个判据同时分开了两种场景:
17
17
  // launchd 直写文件 -> 要时间戳、要轮转(本模块接管)
18
- // systemd journald -> 不要(journald 自带时间戳,加了会重复;socket 不是 file)
19
18
  // 前台调试 tty/pipe -> 不要(人眼看,时间戳是噪音)
20
- // 所以不需要按平台分支,也不需要环境变量开关 -- 判据天然对齐。
19
+ // 所以不需要环境变量开关 -- 判据天然对齐。
21
20
  function stderrIsFile() {
22
21
  try {
23
22
  return fs.fstatSync(2).isFile()
package/lib/status.js CHANGED
@@ -17,29 +17,7 @@ function ago(ms) {
17
17
  return `${Math.round(s / 86400)}d ago`
18
18
  }
19
19
 
20
- function unitState(unit) {
21
- try {
22
- const out = execFileSync('systemctl', ['is-active', unit], { encoding: 'utf8' }).trim()
23
- return out
24
- } catch (err) {
25
- return (err.stdout || '').trim() || 'inactive'
26
- }
27
- }
28
-
29
- function unitMainPid(unit) {
30
- try {
31
- const out = execFileSync('systemctl', ['show', unit, '-p', 'MainPID', '--value'], {
32
- encoding: 'utf8',
33
- }).trim()
34
- const n = Number(out)
35
- return n > 0 ? n : null
36
- } catch {
37
- return null
38
- }
39
- }
40
-
41
- // 服务管理器按平台分治:Linux systemd / macOS launchd。
42
- // 单元名/label 是部署约定,与 systemd 单元、launchd plist 同源;
20
+ // 服务管理器:launchd。label 是部署约定,与 launchd plist 同源;
43
21
  // LR_COLLECT_LABEL 是改名部署的逃生舱。
44
22
  const COLLECT_LABEL = process.env.LR_COLLECT_LABEL || 'com.adaex.lark-relay-collect'
45
23
 
@@ -55,9 +33,8 @@ function launchctlState(label) {
55
33
  // 输出 `state = not running`,(\S+) 只吃到 "not",status 会显示成
56
34
  // 「collect ○ not」。而「collect 没在跑」正是最要命的状态,偏偏这时不可读
57
35
  //
58
- // 只把 running 归一成 active(供上层判活),其余保留 launchd 原话不映射成
59
- // systemd 的 inactive —— 用户接着要跑 `launchctl print`,看到的就是
60
- // `not running`,术语一致比跨平台统一更有用
36
+ // 只把 running 归一成 active(供上层判活),其余保留 launchd 原话 ——
37
+ // 用户接着要跑 `launchctl print`,看到的就是 `not running`,术语一致才好排查
61
38
  const state = (out.match(/^\s*state\s*=\s*(.+)$/m) || [])[1]
62
39
  const pid = Number((out.match(/^\s*pid\s*=\s*(\d+)/m) || [])[1]) || null
63
40
  const s = (state || '').trim()
@@ -68,15 +45,11 @@ function launchctlState(label) {
68
45
  }
69
46
 
70
47
  function serviceState() {
71
- if (process.platform === 'darwin') return launchctlState(COLLECT_LABEL)
72
- const unit = 'lark-relay-collect.service'
73
- return { state: unitState(unit), pid: unitMainPid(unit) }
48
+ return launchctlState(COLLECT_LABEL)
74
49
  }
75
50
 
76
51
  function collectHint() {
77
- return process.platform === 'darwin'
78
- ? `sudo launchctl bootstrap system /Library/LaunchDaemons/${COLLECT_LABEL}.plist`
79
- : 'systemctl enable --now lark-relay-collect'
52
+ return `sudo launchctl bootstrap system /Library/LaunchDaemons/${COLLECT_LABEL}.plist`
80
53
  }
81
54
 
82
55
  // 消费者 = cursors/ 下的目录。每个记录它在各 app 上的游标位置。
package/lib/store.js CHANGED
@@ -8,7 +8,9 @@ const path = require('path')
8
8
  const { paths, ensureDir, eventFilename, dayKey, dayKeyOffset } = require('./paths')
9
9
 
10
10
  // scan 窗口天数。必须 >= store 保留天数,否则「保留着却扫不到」= 静默丢消息。
11
- // 只扫窗口内是为了避免全目录排序(旧架构每轮全扫 4467 个文件)
11
+ // 只扫窗口内是为了避免全目录排序(旧架构每轮全扫 4467 个文件)
12
+ // ⚠️ 与 collect 的 --retain 是两个进程里的两个值,没法在运行时互相读取,
13
+ // 故由 collect 启动时校验(见 bin/lark-relay-collect.js),这里导出给它用
12
14
  const SCAN_DAYS = Number(process.env.LARK_RELAY_SCAN_DAYS || 4)
13
15
 
14
16
  // 原子落盘:同目录 写 .tmp → rename。
@@ -68,7 +70,7 @@ function afterCursor(entry, cursor) {
68
70
  return cmpName(entry.name, cName) > 0
69
71
  }
70
72
 
71
- // 只扫今天 + 昨天…… 的窗口跟随 retain,避免「保留 3 天却只扫 2 天」:
73
+ // 只扫今天 + 昨天…… 的窗口必须覆盖 store 保留天数,避免「保留 3 天却只扫 2 天」:
72
74
  // 消费者停机超过 1 天后,盘上还在的事件会永久扫不到(游标停在更早的日期,
73
75
  // 而窗口已经不覆盖它 → 静默丢失)。实测:游标停在 D-2、第三天启动即复现
74
76
  function scanWindow(app, days = SCAN_DAYS) {
@@ -176,6 +178,7 @@ function gcStore(retainDays) {
176
178
  }
177
179
 
178
180
  module.exports = {
181
+ SCAN_DAYS,
179
182
  writeEvent,
180
183
  scanWindow,
181
184
  readEvent,
package/lib/utf8.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- // UTF-8 兜底:systemd / launchd 环境都无 LANG -> C locale。虽然 Node 的字符串处理
3
+ // UTF-8 兜底:launchd 环境无 LANG -> C locale。虽然 Node 的字符串处理
4
4
  // 不像 bash 那样按字节切片,但子进程(lark-cli)会继承 locale,中文仍会被切坏。
5
5
  //
6
6
  // 两个 bin(lark-relay / lark-relay-collect)都要,故抽成模块 —— 复制两份会漂移。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",
@@ -26,10 +26,8 @@
26
26
  "README.md"
27
27
  ],
28
28
  "scripts": {
29
- "test": "node --test test/*.test.js"
30
- },
31
- "repository": {
32
- "type": "git",
33
- "url": "git+https://github.com/adaex/lark-relay.git"
29
+ "test": "node --test test/*.test.js",
30
+ "prepublishOnly": "node --test test/*.test.js && npm run check-pack",
31
+ "check-pack": "node scripts/check-pack.js"
34
32
  }
35
33
  }