@geoly-ai/social-hub-cli 0.3.4 → 0.3.6

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cmd-manifest.json +22 -2
  3. package/dist/cmd-manifest.test.js +28 -0
  4. package/dist/cmd-manifest.test.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +23 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/permissions-gates-notify.d.ts +26 -0
  9. package/dist/permissions-gates-notify.d.ts.map +1 -0
  10. package/dist/permissions-gates-notify.js +118 -0
  11. package/dist/permissions-gates-notify.js.map +1 -0
  12. package/dist/permissions-gates-notify.test.d.ts +2 -0
  13. package/dist/permissions-gates-notify.test.d.ts.map +1 -0
  14. package/dist/permissions-gates-notify.test.js +107 -0
  15. package/dist/permissions-gates-notify.test.js.map +1 -0
  16. package/dist/permissions.d.ts.map +1 -1
  17. package/dist/permissions.js +2 -0
  18. package/dist/permissions.js.map +1 -1
  19. package/dist/postinstall.js +1 -1
  20. package/dist/postinstall.js.map +1 -1
  21. package/dist/register-extensions.d.ts.map +1 -1
  22. package/dist/register-extensions.js +5 -9
  23. package/dist/register-extensions.js.map +1 -1
  24. package/dist/register-notify.d.ts +172 -0
  25. package/dist/register-notify.d.ts.map +1 -0
  26. package/dist/register-notify.js +1148 -0
  27. package/dist/register-notify.js.map +1 -0
  28. package/dist/register-notify.test.d.ts +2 -0
  29. package/dist/register-notify.test.d.ts.map +1 -0
  30. package/dist/register-notify.test.js +309 -0
  31. package/dist/register-notify.test.js.map +1 -0
  32. package/dist/skill-update-check.d.ts +15 -0
  33. package/dist/skill-update-check.d.ts.map +1 -1
  34. package/dist/skill-update-check.js +22 -3
  35. package/dist/skill-update-check.js.map +1 -1
  36. package/dist/skill-update-check.test.js +19 -6
  37. package/dist/skill-update-check.test.js.map +1 -1
  38. package/package.json +3 -3
  39. package/skills/README.md +10 -7
  40. package/skills/manifest.json +6 -1
  41. package/skills/social-hub-cli/SKILL.md +3 -2
  42. package/skills/social-hub-notifications/SKILL.md +351 -0
  43. package/skills/social-hub-posts/SKILL.md +36 -0
  44. package/skills/social-hub-shared/SKILL.md +9 -9
  45. package/dist/register-subreddit-post-style.d.ts +0 -8
  46. package/dist/register-subreddit-post-style.d.ts.map +0 -1
  47. package/dist/register-subreddit-post-style.js +0 -161
  48. package/dist/register-subreddit-post-style.js.map +0 -1
@@ -0,0 +1,1148 @@
1
+ import { renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { SocialHubHttpError, compareNotificationSeqStrings, } from "@geoly-ai/social-hub-sdk";
3
+ import { requireClient, resolveTeamId } from "./client.js";
4
+ import { assertApplyOrDryRun } from "./dry-run.js";
5
+ import { resolveJsonArg, JsonArgError } from "./lib/json-arg.js";
6
+ /**
7
+ * `social-hub notify …` —— 事件订阅通知的消费面(设计见
8
+ * `docs/notification-event-subscription-design.md`,契约见
9
+ * `packages/contracts/src/notifications.ts`)。
10
+ *
11
+ * 使用场景:用户自己机器上的 agent 从 Hub 拉「帖子发布后 2h/6h/24h/2d/7d」这类节点事件,
12
+ * 拿到后自己去开浏览器截图、发飞书群。**Hub 只通知,完全不管执行**。因此本命令组的设计
13
+ * 目标是「AI agent 的工具调用」:机器友好 + 崩溃可恢复。
14
+ *
15
+ * ## 五条不可协商的形态约束
16
+ *
17
+ * 1. **默认 `--limit 1`**(不是 20)。没有 delivery 表,ack 是**连续 watermark** 而不是
18
+ * 单条确认:`cursorSeq = N` 表示该订阅所有匹配事件中 `seq ≤ N` 的**均已完成**。
19
+ * 一次拉 20 条、第 5 条失败,后面 15 条就再也无法安全 ack。
20
+ * 2. **`--follow` 的背压固定为 maxUnacked=1**:输出一条后轮询自己的 subscription,
21
+ * 等 `cursorSeq >= pendingSeq` 才拉下一条。`--follow` 是便利模式,**不是新的消费协议**。
22
+ * 3. **不提供 `--auto-ack`**。CLI 写完 stdout 不代表下游 agent 已经截完图、发完飞书;
23
+ * 此时崩溃事件就丢了。ack 必须由真正完成副作用的一方显式发起。
24
+ * 4. **只原样输出,绝不「补齐」缺失字段**。envelope 带四层版本 + `includedFields`,
25
+ * 形状随配置变化是设计不是 bug。
26
+ * 5. **`seq` 全程字符串**(bigint)。任何 `Number(seq)` 都是精度炸弹;比较走
27
+ * {@link compareNotificationSeqStrings}(BigInt)。
28
+ *
29
+ * ## 退出码矩阵(脚本/supervisor 据此分流)
30
+ *
31
+ * | 码 | 含义 |
32
+ * | -- | ---- |
33
+ * | 0 | 成功(含「无消息」、`--follow` 被 SIGINT/SIGTERM 正常中断) |
34
+ * | 1 | 参数 / 鉴权 / 权限 / 协议错误,或服务端**非重试**错误(含 pull 的 410 stale) |
35
+ * | 2 | 可重试的网络 / 服务不可用;或 `--fail-if-empty` 且无消息 |
36
+ * | 3 | ack 的 cursor / receipt 冲突(409 CAS 冲突、410 游标过期) |
37
+ * | 4 | **保留**:未来批量操作部分成功。Phase 1 不产生该退出码 |
38
+ */
39
+ export const NOTIFY_EXIT = {
40
+ OK: 0,
41
+ /** 参数/鉴权/权限/协议或服务端非重试错误。 */
42
+ FATAL: 1,
43
+ /** 可重试的网络/服务不可用,或 --fail-if-empty 且无消息。 */
44
+ RETRYABLE: 2,
45
+ /** ack 的 cursor/receipt 冲突(409/410)。 */
46
+ CURSOR_CONFLICT: 3,
47
+ /** 保留:批量操作部分成功(Phase 1 不产生)。 */
48
+ PARTIAL: 4,
49
+ };
50
+ const CURSOR_SEQ_RE = /^(0|[1-9][0-9]{0,18})$/;
51
+ const EVENT_SEQ_RE = /^[1-9][0-9]{0,18}$/;
52
+ /** 长轮询上限(契约 NOTIFICATION_PULL_WAIT_SECONDS_MAX)。 */
53
+ const PULL_WAIT_SECONDS_MAX = 30;
54
+ /** 单次 pull 上限(契约 NOTIFICATION_PULL_LIMIT_MAX)。 */
55
+ const PULL_LIMIT_MAX = 50;
56
+ /** `--follow` 未显式给 `--wait` 时的默认长轮询秒数(留 5s 余量给服务端)。 */
57
+ const FOLLOW_DEFAULT_WAIT_SECONDS = 25;
58
+ /** 网络/5xx 退避上限(秒)。 */
59
+ const NETWORK_BACKOFF_MAX_SECONDS = 30;
60
+ /** 尊重 `Retry-After` 但要封顶,避免异常响应让进程睡几天。 */
61
+ const RETRY_AFTER_CAP_SECONDS = 300;
62
+ /** 等待游标推进时的 stderr 心跳间隔(毫秒)。 */
63
+ const HEARTBEAT_INTERVAL_MS = 30_000;
64
+ // ---------------------------------------------------------------------------
65
+ // 错误分类(纯函数,可单测)
66
+ // ---------------------------------------------------------------------------
67
+ /**
68
+ * 可重试的 HTTP 状态:超时/过早/限流/服务端故障。
69
+ * 4xx 里只有 408 / 425 / 429 是「等一会儿再来」,其余都是调用方自己的问题。
70
+ */
71
+ export function isRetryableStatus(status) {
72
+ // 显式白名单而不是「所有 5xx」:501 Not Implemented / 505 不会自行恢复,
73
+ // 归成可重试会让 `--follow` 无限打同一个必然失败的请求。
74
+ return [408, 425, 429, 500, 502, 503, 504].includes(status);
75
+ }
76
+ /** 传输层失败(DNS / 连接拒绝 / socket 断开 / TLS)——一律当可重试。 */
77
+ export function isNetworkError(e) {
78
+ if (e instanceof SocialHubHttpError)
79
+ return false;
80
+ if (!(e instanceof Error))
81
+ return false;
82
+ const text = `${e.message} ${String(e.cause ?? "")}`;
83
+ // 注意**不含 EPIPE**:那通常是本进程 stdout 的下游关了管道,不是 Hub 不可达。
84
+ // 归成「可重试」会让 supervisor 反复重启一个其实已经没人读输出的消费者。
85
+ return /fetch failed|ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|socket hang up|network|certificate|aborted/i.test(text);
86
+ }
87
+ /**
88
+ * 把任意错误映射成退出码。
89
+ *
90
+ * 关键取舍:
91
+ * - **pull 的 410(`SUBSCRIPTION_CURSOR_EXPIRED`)判 1 而不是 2**。它需要人工确认后
92
+ * `notify subscriptions reset`;若判成「可重试」,supervisor 会对同一个 stale 订阅
93
+ * 无限重启。
94
+ * - **ack 的 409 与 410 都判 3**,方便调用方统一进入「游标恢复分支」;具体动作靠
95
+ * stderr 里的 `code` 区分(409 → 重读订阅/重新 pull 拿新 receipt;410 → 人工 reset)。
96
+ * - **`PULL_RECEIPT_INVALID` 判 1**:签名/epoch/filterVersion 已变,重试同一 receipt
97
+ * 永远不会成功,必须重新 pull。
98
+ */
99
+ export function classifyNotifyError(e, phase) {
100
+ if (e instanceof SocialHubHttpError) {
101
+ const base = {
102
+ status: e.status,
103
+ code: e.code,
104
+ message: e.message,
105
+ details: e.details,
106
+ retryAfterSeconds: e.retryAfterSeconds,
107
+ };
108
+ if (isRetryableStatus(e.status)) {
109
+ return { ...base, exitCode: NOTIFY_EXIT.RETRYABLE, retryable: true };
110
+ }
111
+ if (phase === "ack" && (e.status === 409 || e.status === 410)) {
112
+ return {
113
+ ...base,
114
+ exitCode: NOTIFY_EXIT.CURSOR_CONFLICT,
115
+ retryable: false,
116
+ hint: e.status === 409
117
+ ? "游标已被推进或 receipt 已失效:先 `notify subscriptions get` 看 cursorSeq;若 cursorSeq >= throughSeq 说明上次 ack 其实成功了(响应丢失),不要重放副作用;否则重新 pull 拿新 receipt。"
118
+ : "游标已落后于保留 floor(订阅 stale):人工确认丢事件风险后执行 `notify subscriptions reset --start earliest|now --apply`。",
119
+ };
120
+ }
121
+ if (phase === "pull" && e.status === 410) {
122
+ return {
123
+ ...base,
124
+ exitCode: NOTIFY_EXIT.FATAL,
125
+ retryable: false,
126
+ hint: "订阅已 stale(游标落后于保留 floor)。这不是临时错误,重试无用:人工确认丢事件风险后执行 `notify subscriptions reset --start earliest|now --apply`。",
127
+ };
128
+ }
129
+ return {
130
+ ...base,
131
+ exitCode: NOTIFY_EXIT.FATAL,
132
+ retryable: false,
133
+ hint: e.status === 401 || e.status === 403
134
+ ? "鉴权/权限问题:`social-hub auth whoami` 确认身份;字段权限不足会整个订阅 403(不做字段级脱敏)。"
135
+ : undefined,
136
+ };
137
+ }
138
+ if (isNetworkError(e)) {
139
+ return {
140
+ exitCode: NOTIFY_EXIT.RETRYABLE,
141
+ retryable: true,
142
+ message: e instanceof Error ? e.message : String(e),
143
+ };
144
+ }
145
+ return {
146
+ exitCode: NOTIFY_EXIT.FATAL,
147
+ retryable: false,
148
+ message: e instanceof Error ? e.message : String(e),
149
+ };
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // 输出(stdout 只出事件;一切日志走 stderr)
153
+ // ---------------------------------------------------------------------------
154
+ function logErr(obj) {
155
+ process.stderr.write(`${JSON.stringify(obj)}\n`);
156
+ }
157
+ function reportError(c, phase) {
158
+ logErr({
159
+ level: "error",
160
+ phase,
161
+ exitCode: c.exitCode,
162
+ status: c.status,
163
+ code: c.code,
164
+ message: c.message,
165
+ details: c.details,
166
+ hint: c.hint,
167
+ });
168
+ }
169
+ /**
170
+ * 写一行 JSON Lines 到 stdout,并在内核缓冲区满时**等 drain**。
171
+ * 直接 `console.log` 在下游管道慢时会让内存无上限增长。
172
+ *
173
+ * drain 等待必须同时监听 `error`/`close` 与 stop 信号:下游停止读取(比如 `head -1`
174
+ * 之后管道关闭)时,只等 `drain` 会永久挂住。
175
+ */
176
+ async function writeStdoutLine(line, stop) {
177
+ if (process.stdout.write(`${line}\n`))
178
+ return;
179
+ await new Promise((resolve, reject) => {
180
+ const cleanup = () => {
181
+ process.stdout.off("drain", onDrain);
182
+ process.stdout.off("error", onError);
183
+ process.stdout.off("close", onClose);
184
+ stop?.off(onStop);
185
+ };
186
+ // 🔴 只有 drain(真的写出去了)或 stop(人为中断)才算成功。
187
+ // 把 error/close 也当成功 = 下游其实没收到这条 envelope,CLI 却进入等 ack 态,
188
+ // 于是永久挂死在一条谁也不知道存在的事件上。
189
+ const onDrain = () => {
190
+ cleanup();
191
+ resolve();
192
+ };
193
+ const onStop = () => {
194
+ cleanup();
195
+ resolve();
196
+ };
197
+ const onError = (e) => {
198
+ cleanup();
199
+ reject(e instanceof Error ? e : new Error(`stdout write failed: ${String(e)}`));
200
+ };
201
+ const onClose = () => {
202
+ cleanup();
203
+ if (stop?.requested)
204
+ return resolve();
205
+ reject(new Error("stdout closed before the event line was flushed"));
206
+ };
207
+ process.stdout.once("drain", onDrain);
208
+ process.stdout.once("error", onError);
209
+ process.stdout.once("close", onClose);
210
+ stop?.on(onStop);
211
+ });
212
+ }
213
+ function printJson(data) {
214
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
215
+ }
216
+ export function buildReceiptSidecar(res) {
217
+ const last = res.events.at(-1);
218
+ if (!last)
219
+ return null;
220
+ return {
221
+ subscriptionId: res.receipt.subscriptionId,
222
+ cursorSeq: res.cursorSeq,
223
+ throughSeq: last.seq,
224
+ eventIds: res.events.map((e) => e.eventId),
225
+ receipt: res.receipt,
226
+ };
227
+ }
228
+ let sidecarCounter = 0;
229
+ /**
230
+ * 原子落盘:先写临时文件再 rename,避免 agent 读到半截 JSON。
231
+ * 权限 0600 —— receipt 是签名凭据,虽有 TTL 但不该对同机其它用户可读。
232
+ */
233
+ export function writeReceiptSidecarAtomically(path, sidecar) {
234
+ const tmp = `${path}.tmp-${process.pid}-${++sidecarCounter}`;
235
+ writeFileSync(tmp, `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
236
+ renameSync(tmp, path);
237
+ }
238
+ // ---------------------------------------------------------------------------
239
+ // 退避 / 睡眠
240
+ // ---------------------------------------------------------------------------
241
+ /** 指数退避 + ±20% 抖动,封顶 maxMs。 */
242
+ export function nextBackoffMs(attempt, baseMs, maxMs, rand = Math.random) {
243
+ const raw = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt - 1));
244
+ const jitter = 1 + (rand() * 0.4 - 0.2);
245
+ return Math.max(0, Math.round(Math.min(maxMs, raw * jitter)));
246
+ }
247
+ /** 可被信号打断的 sleep(否则 Ctrl-C 后还要等满一个退避周期)。 */
248
+ function sleep(ms, stop) {
249
+ return new Promise((resolve) => {
250
+ if (stop.requested)
251
+ return resolve();
252
+ const finish = () => {
253
+ clearTimeout(timer);
254
+ stop.off(onStop);
255
+ resolve();
256
+ };
257
+ const timer = setTimeout(finish, ms);
258
+ function onStop() {
259
+ finish();
260
+ }
261
+ stop.on(onStop);
262
+ });
263
+ }
264
+ function createStopSignal() {
265
+ const listeners = new Set();
266
+ const controller = new AbortController();
267
+ const state = {
268
+ requested: false,
269
+ signal: controller.signal,
270
+ on(fn) {
271
+ listeners.add(fn);
272
+ },
273
+ off(fn) {
274
+ listeners.delete(fn);
275
+ },
276
+ dispose() {
277
+ process.off("SIGINT", trigger);
278
+ process.off("SIGTERM", trigger);
279
+ },
280
+ };
281
+ function trigger() {
282
+ if (state.requested)
283
+ return;
284
+ state.requested = true;
285
+ logErr({ level: "info", event: "shutdown", message: "signal received" });
286
+ // abort 在途的 long-poll fetch —— 只置布尔位的话,Ctrl-C 后还要等满 25 秒。
287
+ controller.abort();
288
+ for (const fn of [...listeners])
289
+ fn();
290
+ }
291
+ process.on("SIGINT", trigger);
292
+ process.on("SIGTERM", trigger);
293
+ return state;
294
+ }
295
+ /** 被 stop 信号 abort 掉的 fetch 不是错误,而是「正常收工」。 */
296
+ function isAbortFromStop(e, stop) {
297
+ return (stop.requested &&
298
+ e instanceof Error &&
299
+ (e.name === "AbortError" || /abort/i.test(e.message)));
300
+ }
301
+ // ---------------------------------------------------------------------------
302
+ // 订阅引用解析(name | uuid)
303
+ // ---------------------------------------------------------------------------
304
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
305
+ /**
306
+ * `--subscription` 收 name 或 uuid。
307
+ *
308
+ * SDK 侧**只认 uuid**(pull/ack 是消费热路径,不该藏一次隐式 list);name 解析是 CLI
309
+ * 的便利层,且 `--follow` 只在启动时解析一次。
310
+ */
311
+ export async function resolveSubscription(client, teamId, ref,
312
+ /** `--follow` 启动期解析也必须能被 Ctrl-C 打断,否则要一直等到网络超时。 */
313
+ signal) {
314
+ if (UUID_RE.test(ref)) {
315
+ try {
316
+ return await client.getNotificationSubscription(teamId, ref, signal ? { signal } : {});
317
+ }
318
+ catch (e) {
319
+ if (!(e instanceof SocialHubHttpError && e.status === 404))
320
+ throw e;
321
+ // uuid 形状但不存在 → 继续按 name 找(理论上 name 也可能长得像 uuid)。
322
+ }
323
+ }
324
+ const list = await client.listNotificationSubscriptions(teamId, signal ? { signal } : {});
325
+ const matches = (list.items ?? []).filter((s) => s.name === ref && s.status !== "deleted");
326
+ if (matches.length === 1)
327
+ return matches[0];
328
+ if (matches.length === 0) {
329
+ throw new Error(`找不到订阅 "${ref}"(team ${teamId})。用 \`social-hub notify subscriptions list -t ${teamId}\` 查看。`);
330
+ }
331
+ throw new Error(`订阅名 "${ref}" 在 team ${teamId} 下匹配到 ${matches.length} 条,请改用 uuid。`);
332
+ }
333
+ /**
334
+ * `--follow` 每轮 GET subscription 后的判定。
335
+ *
336
+ * 只比 cursor 是不够的 —— 下列情形会让「等 cursor 推进」永久挂死,必须当场退出:
337
+ * - `paused` / `stale` / `deleted`:这些状态下 ack 根本不会成功;
338
+ * - `receiptEpoch` / `filterVersion` 变了而 cursor 还没越过 pendingSeq:
339
+ * 在途 receipt 已作废(reset / 改 filter / 权限变化),这条事件再也 ack 不上去。
340
+ *
341
+ * cursor 已越过 pendingSeq 视为**已被别处推进**,直接继续下一轮,**不重复输出**该事件。
342
+ */
343
+ export function evaluateCursorWait(args) {
344
+ const s = args.subscription;
345
+ if (compareNotificationSeqStrings(s.cursorSeq, args.pendingSeq) >= 0) {
346
+ return { kind: "advanced" };
347
+ }
348
+ if (s.status === "paused") {
349
+ return {
350
+ kind: "abort",
351
+ reason: "subscription_paused",
352
+ hint: "订阅被 pause,暂停期间无法 ack。`notify subscriptions resume` 后重启 --follow。",
353
+ };
354
+ }
355
+ if (s.status === "stale") {
356
+ return {
357
+ kind: "abort",
358
+ reason: "subscription_stale",
359
+ hint: "订阅已 stale(游标落后于保留 floor)。人工确认后 `notify subscriptions reset --apply`。",
360
+ };
361
+ }
362
+ if (s.status === "deleted") {
363
+ return {
364
+ kind: "abort",
365
+ reason: "subscription_deleted",
366
+ hint: "订阅已删除。",
367
+ };
368
+ }
369
+ if (s.receiptEpoch !== args.receiptEpoch) {
370
+ return {
371
+ kind: "abort",
372
+ reason: "receipt_epoch_changed",
373
+ hint: "receiptEpoch 已变(reset / 权限或匹配面变化),在途 receipt 已作废:这条事件永远 ack 不上去。重启 --follow 会重新拉到它。",
374
+ };
375
+ }
376
+ if (s.filterVersion !== args.filterVersion) {
377
+ return {
378
+ kind: "abort",
379
+ reason: "filter_version_changed",
380
+ hint: "filterVersion 已变(sourceIds/filters/requiredFields 被改),在途 receipt 已作废。重启 --follow。",
381
+ };
382
+ }
383
+ return { kind: "wait" };
384
+ }
385
+ // ---------------------------------------------------------------------------
386
+ // 参数解析小工具
387
+ // ---------------------------------------------------------------------------
388
+ /** 逗号分隔列表 → 去重 + ASCII 升序(契约要求 sourceIds / 字段路径升序去重)。 */
389
+ export function parseSortedList(raw) {
390
+ if (raw === undefined)
391
+ return undefined;
392
+ const items = raw
393
+ .split(",")
394
+ .map((s) => s.trim())
395
+ .filter((s) => s.length > 0);
396
+ if (items.length === 0)
397
+ return undefined;
398
+ return [...new Set(items)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
399
+ }
400
+ function collectRepeatable(value, prev = []) {
401
+ return [...prev, value];
402
+ }
403
+ function parseIntOption(raw, label, min, max) {
404
+ if (raw === undefined)
405
+ return undefined;
406
+ const n = Number(raw);
407
+ if (!Number.isInteger(n) || n < min || n > max) {
408
+ fail(`${label} 须为 ${min}..${max} 的整数(收到 ${raw})。`);
409
+ }
410
+ return n;
411
+ }
412
+ /**
413
+ * 参数错误。
414
+ *
415
+ * 抛而不是 `process.exit()`:stdout/stderr 走管道时是**异步**的,`process.exit()`
416
+ * 会把已排队但未 flush 的内容直接截断(空 pull 打完 JSON 紧接着 exit 2 就会得到半截 JSON)。
417
+ * 统一由 {@link runNotify} 设 `process.exitCode` 后正常返回,让 Node 自己 flush。
418
+ */
419
+ class NotifyArgError extends Error {
420
+ constructor(message) {
421
+ super(message);
422
+ this.name = "NotifyArgError";
423
+ }
424
+ }
425
+ function fail(message) {
426
+ throw new NotifyArgError(message);
427
+ }
428
+ function assertSeq(value, label, allowZero) {
429
+ const re = allowZero ? CURSOR_SEQ_RE : EVENT_SEQ_RE;
430
+ if (!re.test(value)) {
431
+ fail(`${label} 须为十进制 seq 字符串${allowZero ? "(允许 0)" : "(正整数)"},收到 "${value}"。seq 是 bigint,禁止科学计数/浮点。`);
432
+ }
433
+ return value;
434
+ }
435
+ /**
436
+ * 命令边界:把任何错误映射成退出码,且**只设 `process.exitCode`**(见 {@link fail})。
437
+ * **不复用** `withCliErrorBoundary` —— 那个恒定退出 1,会抹掉 2/3 的分流价值。
438
+ *
439
+ * 也兜住本地 I/O 异常(sidecar 落盘失败、stdout 被下游关闭),否则 `--follow` 会变成
440
+ * unhandled rejection 而不是稳定的退出码 + 结构化 stderr。
441
+ */
442
+ async function runNotify(phase, run) {
443
+ try {
444
+ await run();
445
+ }
446
+ catch (e) {
447
+ if (e instanceof JsonArgError || e instanceof NotifyArgError) {
448
+ logErr({
449
+ level: "error",
450
+ phase: "args",
451
+ exitCode: NOTIFY_EXIT.FATAL,
452
+ message: e.message,
453
+ });
454
+ process.exitCode = NOTIFY_EXIT.FATAL;
455
+ return;
456
+ }
457
+ const c = classifyNotifyError(e, phase);
458
+ reportError(c, phase);
459
+ process.exitCode = c.exitCode;
460
+ }
461
+ }
462
+ async function runPullOnce(client, teamId, subscriptionId, limit, waitSeconds, signal) {
463
+ return client.pullNotificationEvents(teamId, { subscriptionId, limit, waitSeconds }, signal ? { signal } : {});
464
+ }
465
+ async function pullOneShot(client, teamId, sub, opts) {
466
+ const res = await runPullOnce(client, teamId, sub.id, opts.limit, opts.waitSeconds);
467
+ const sidecar = buildReceiptSidecar(res);
468
+ if (opts.receiptFile) {
469
+ if (sidecar) {
470
+ // 先落 receipt 再出事件:反过来的话 agent 可能拿到一条永远 ack 不了的事件。
471
+ writeReceiptSidecarAtomically(opts.receiptFile, sidecar);
472
+ }
473
+ else {
474
+ // 空 pull 必须**清掉**上一轮的 sidecar:留着会让「以文件存在为信号」的 agent
475
+ // 拿旧 receipt 去 ack 一条它这轮根本没处理的事件。
476
+ rmSync(opts.receiptFile, { force: true });
477
+ }
478
+ }
479
+ if (opts.format === "jsonl") {
480
+ for (const event of res.events) {
481
+ await writeStdoutLine(JSON.stringify(event));
482
+ }
483
+ }
484
+ else {
485
+ printJson(res);
486
+ }
487
+ if (res.events.length === 0 && opts.failIfEmpty) {
488
+ logErr({
489
+ level: "error",
490
+ phase: "pull",
491
+ exitCode: NOTIFY_EXIT.RETRYABLE,
492
+ message: "no events (--fail-if-empty)",
493
+ cursorSeq: res.cursorSeq,
494
+ latestSeq: res.latestSeq,
495
+ });
496
+ process.exitCode = NOTIFY_EXIT.RETRYABLE;
497
+ }
498
+ }
499
+ /**
500
+ * `--follow`:常驻监听,**maxUnacked = 1**。
501
+ *
502
+ * 每轮:pull(limit=1) → 落 receipt sidecar → stdout 一行 envelope → 轮询自己的
503
+ * subscription 直到 `cursorSeq >= pendingSeq` → 再拉下一条。
504
+ *
505
+ * 为什么用「轮询 subscription」而不是「重复 pull 同一条直到 seq 变了」:pull 是幂等的
506
+ * (不推进游标),重复 pull 会不断签发新 receipt、刷 `lastPulledAt`,而且要么重复输出
507
+ * 同一 envelope(破坏 JSON Lines 语义),要么在 CLI 内部做去重(等于把 subscription
508
+ * 状态在客户端重建一遍)。GET subscription 才是游标的权威状态探针。
509
+ *
510
+ * **未知 payloadSchemaVersion 的 poison pill**:agent 拒绝处理且不 ack 时,本命令会
511
+ * 永久停在等待态(这是安全设计,不是 bug —— 跳过就等于静默丢事件)。stderr 心跳会一直
512
+ * 报 pendingSeq,人工介入后 SIGINT 退出 0。
513
+ */
514
+ async function pullFollow(client, teamId, subscriptionRef, opts) {
515
+ // 🔴 信号处理器**先于**订阅解析安装:解析期间 Ctrl-C 否则会走 Node 默认信号退出码,
516
+ // 与「--follow 被正常中断 → 0」的约定矛盾。
517
+ const stop = createStopSignal();
518
+ try {
519
+ let sub;
520
+ try {
521
+ sub = await resolveSubscription(client, teamId, subscriptionRef, stop.signal);
522
+ }
523
+ catch (e) {
524
+ if (isAbortFromStop(e, stop))
525
+ return NOTIFY_EXIT.OK;
526
+ const c = classifyNotifyError(e, "pull");
527
+ reportError(c, "pull");
528
+ return c.exitCode;
529
+ }
530
+ if (stop.requested)
531
+ return NOTIFY_EXIT.OK;
532
+ let networkAttempt = 0;
533
+ while (!stop.requested) {
534
+ let res;
535
+ try {
536
+ res = await runPullOnce(client, teamId, sub.id, 1, opts.waitSeconds, stop.signal);
537
+ networkAttempt = 0;
538
+ }
539
+ catch (e) {
540
+ if (isAbortFromStop(e, stop))
541
+ break;
542
+ const c = classifyNotifyError(e, "pull");
543
+ if (!c.retryable) {
544
+ reportError(c, "pull");
545
+ return c.exitCode;
546
+ }
547
+ networkAttempt += 1;
548
+ const retryAfterMs = c.retryAfterSeconds !== undefined
549
+ ? Math.min(c.retryAfterSeconds, RETRY_AFTER_CAP_SECONDS) * 1000
550
+ : undefined;
551
+ const delay = retryAfterMs ??
552
+ nextBackoffMs(networkAttempt, 1000, NETWORK_BACKOFF_MAX_SECONDS * 1000);
553
+ logErr({
554
+ level: "warn",
555
+ phase: "pull",
556
+ event: "retry",
557
+ attempt: networkAttempt,
558
+ delayMs: delay,
559
+ status: c.status,
560
+ message: c.message,
561
+ });
562
+ await sleep(delay, stop);
563
+ continue;
564
+ }
565
+ if (res.events.length === 0)
566
+ continue;
567
+ const sidecar = buildReceiptSidecar(res);
568
+ if (!sidecar)
569
+ continue;
570
+ writeReceiptSidecarAtomically(opts.receiptFile, sidecar);
571
+ const event = res.events[0];
572
+ await writeStdoutLine(JSON.stringify(event), stop);
573
+ const decision = await waitForCursorAdvance(client, teamId, sub.id, {
574
+ pendingSeq: event.seq,
575
+ receiptEpoch: res.receipt.receiptEpoch,
576
+ filterVersion: res.receipt.filterVersion,
577
+ pollIntervalMs: opts.pollIntervalMs,
578
+ maxPollIntervalMs: opts.maxPollIntervalMs,
579
+ stop,
580
+ });
581
+ if (decision.kind === "abort") {
582
+ logErr({
583
+ level: "error",
584
+ phase: "pull",
585
+ exitCode: NOTIFY_EXIT.FATAL,
586
+ event: "follow_abort",
587
+ reason: decision.reason,
588
+ pendingSeq: event.seq,
589
+ hint: decision.hint,
590
+ });
591
+ return NOTIFY_EXIT.FATAL;
592
+ }
593
+ if (decision.kind === "fatal") {
594
+ reportError(decision.classification, "pull");
595
+ return decision.classification.exitCode;
596
+ }
597
+ // advanced / stopped → 继续下一轮(stopped 由 while 条件收口)。
598
+ }
599
+ return NOTIFY_EXIT.OK;
600
+ }
601
+ finally {
602
+ stop.dispose();
603
+ }
604
+ }
605
+ async function waitForCursorAdvance(client, teamId, subscriptionId, args) {
606
+ const startedAt = Date.now();
607
+ let lastHeartbeat = startedAt;
608
+ let attempt = 0;
609
+ let networkAttempt = 0;
610
+ while (!args.stop.requested) {
611
+ await sleep(networkAttempt > 0
612
+ ? nextBackoffMs(networkAttempt, 1000, NETWORK_BACKOFF_MAX_SECONDS * 1000)
613
+ : nextBackoffMs(attempt + 1, args.pollIntervalMs, args.maxPollIntervalMs), args.stop);
614
+ if (args.stop.requested)
615
+ break;
616
+ let subscription;
617
+ try {
618
+ subscription = await client.getNotificationSubscription(teamId, subscriptionId, { signal: args.stop.signal });
619
+ networkAttempt = 0;
620
+ }
621
+ catch (e) {
622
+ if (isAbortFromStop(e, args.stop))
623
+ return { kind: "stopped" };
624
+ if (e instanceof SocialHubHttpError && e.status === 404) {
625
+ return {
626
+ kind: "abort",
627
+ reason: "subscription_deleted",
628
+ hint: "订阅已删除或不可见。",
629
+ };
630
+ }
631
+ const c = classifyNotifyError(e, "pull");
632
+ if (!c.retryable)
633
+ return { kind: "fatal", classification: c };
634
+ networkAttempt += 1;
635
+ logErr({
636
+ level: "warn",
637
+ phase: "pull",
638
+ event: "cursor_poll_retry",
639
+ attempt: networkAttempt,
640
+ status: c.status,
641
+ message: c.message,
642
+ });
643
+ continue;
644
+ }
645
+ const decision = evaluateCursorWait({
646
+ subscription,
647
+ pendingSeq: args.pendingSeq,
648
+ receiptEpoch: args.receiptEpoch,
649
+ filterVersion: args.filterVersion,
650
+ });
651
+ if (decision.kind === "advanced")
652
+ return { kind: "advanced" };
653
+ if (decision.kind === "abort")
654
+ return decision;
655
+ attempt += 1;
656
+ const now = Date.now();
657
+ if (now - lastHeartbeat >= HEARTBEAT_INTERVAL_MS) {
658
+ lastHeartbeat = now;
659
+ logErr({
660
+ level: "info",
661
+ phase: "pull",
662
+ event: "awaiting_ack",
663
+ subscriptionId,
664
+ pendingSeq: args.pendingSeq,
665
+ cursorSeq: subscription.cursorSeq,
666
+ waitedSeconds: Math.round((now - startedAt) / 1000),
667
+ hint: "CLI 不会自动 ack:完成副作用后调用 `social-hub notify ack --receipt-file <sidecar>`。",
668
+ });
669
+ }
670
+ }
671
+ return { kind: "stopped" };
672
+ }
673
+ // ---------------------------------------------------------------------------
674
+ // 注册
675
+ // ---------------------------------------------------------------------------
676
+ export function registerNotifyCommands(program) {
677
+ const notify = program
678
+ .command("notify")
679
+ .description("事件订阅通知(Hub 只通知不管执行;agent 拉走后自己干活):subscriptions / pull / ack / source-configs");
680
+ registerSubscriptionCommands(notify);
681
+ registerPullCommand(notify);
682
+ registerAckCommand(notify);
683
+ registerSourceConfigCommands(notify);
684
+ }
685
+ function registerSubscriptionCommands(notify) {
686
+ const subs = notify
687
+ .command("subscriptions")
688
+ .description("订阅管理(每台机器一个订阅、各自独立游标;订阅之间是广播)");
689
+ subs
690
+ .command("list")
691
+ .description("GET .../notification-subscriptions(非 manager/admin 只看得到自己的)")
692
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
693
+ .option("--status <status>", "active|paused|stale|deleted")
694
+ .option("--source-id <sourceId>", "只看订阅了该 source 的")
695
+ .action(async (opts) => {
696
+ await runNotify("admin", async () => {
697
+ const teamId = resolveTeamId(opts.team);
698
+ printJson(await requireClient().listNotificationSubscriptions(teamId, {
699
+ status: opts.status,
700
+ sourceId: opts.sourceId,
701
+ }));
702
+ });
703
+ });
704
+ subs
705
+ .command("get")
706
+ .description("GET .../notification-subscriptions/:id(--subscription 接受 name 或 uuid)")
707
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
708
+ .requiredOption("--subscription <nameOrId>", "订阅名或 uuid")
709
+ .action(async (opts) => {
710
+ await runNotify("admin", async () => {
711
+ const teamId = resolveTeamId(opts.team);
712
+ const client = requireClient();
713
+ printJson(await resolveSubscription(client, teamId, opts.subscription));
714
+ });
715
+ });
716
+ subs
717
+ .command("create")
718
+ .description("POST .../notification-subscriptions — 新建订阅")
719
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
720
+ .option("--name <name>", "订阅名(team 内唯一;建议用机器名,如 my-mac-agent)")
721
+ .option("--source <sourceId>", "订阅的 sourceId,可重复(如 post-performance-node.v1)", collectRepeatable)
722
+ .option("--start <position>", "now|earliest(默认 now:避免新机器一上线被历史事件淹没)")
723
+ .option("--require-fields <csv>", "逗号分隔字段路径:没有这些字段就别发给我(如 subject.permalink,account.externalRef)")
724
+ .option("--filter-brand <csv>", "brandIds 过滤(逗号分隔 uuid)")
725
+ .option("--filter-account <csv>", "socialAccountIds 过滤(逗号分隔 uuid)")
726
+ .option("--filter-subreddit <csv>", "subreddits 过滤(逗号分隔,小写无 r/ 前缀)")
727
+ .option("-j, --json <json>", "整个请求体 JSON(也支持 @<file> 与 - 读 stdin)")
728
+ .option("--json-file <path>", "从文件/`-`(stdin) 读整个请求体 JSON")
729
+ .action(async (opts) => {
730
+ await runNotify("admin", async () => {
731
+ const teamId = resolveTeamId(opts.team);
732
+ const body = buildCreateSubscriptionBody(opts);
733
+ printJson(await requireClient().createNotificationSubscription(teamId, body));
734
+ });
735
+ });
736
+ subs
737
+ .command("update")
738
+ .description("PATCH .../notification-subscriptions/:id — 改匹配面/名字(会 bump filterVersion + receiptEpoch)")
739
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
740
+ .requiredOption("--subscription <nameOrId>", "订阅名或 uuid")
741
+ .option("-j, --json <json>", "patch JSON(也支持 @<file> 与 - 读 stdin)")
742
+ .option("--json-file <path>", "从文件/`-`(stdin) 读 patch JSON")
743
+ .action(async (opts) => {
744
+ await runNotify("admin", async () => {
745
+ const teamId = resolveTeamId(opts.team);
746
+ const client = requireClient();
747
+ const patch = resolveJsonArg({
748
+ inline: opts.json,
749
+ file: opts.jsonFile,
750
+ label: "-j / --json",
751
+ required: true,
752
+ });
753
+ const sub = await resolveSubscription(client, teamId, opts.subscription);
754
+ // 未显式给 expectedRowVersion 时用刚读到的值:CAS 仍然生效(读→写之间被人改过就 409)。
755
+ const body = {
756
+ expectedRowVersion: sub.rowVersion,
757
+ ...patch,
758
+ };
759
+ printJson(await client.updateNotificationSubscription(teamId, sub.id, body));
760
+ });
761
+ });
762
+ const registerStatusToggle = (name, paused, description) => {
763
+ subs
764
+ .command(name)
765
+ .description(description)
766
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
767
+ .requiredOption("--subscription <nameOrId>", "订阅名或 uuid")
768
+ .option("--expected-row-version <n>", "乐观锁;缺省先 GET 一次取当前值")
769
+ .action(async (opts) => {
770
+ await runNotify("admin", async () => {
771
+ const teamId = resolveTeamId(opts.team);
772
+ const client = requireClient();
773
+ const sub = await resolveSubscription(client, teamId, opts.subscription);
774
+ const expected = parseIntOption(opts.expectedRowVersion, "--expected-row-version", 0, Number.MAX_SAFE_INTEGER) ?? sub.rowVersion;
775
+ printJson(await client.setNotificationSubscriptionPaused(teamId, sub.id, paused, { expectedRowVersion: expected }));
776
+ });
777
+ });
778
+ };
779
+ registerStatusToggle("pause", true, "POST .../:id/pause — 暂停消费(暂停期间 ack 不会成功)");
780
+ registerStatusToggle("resume", false, "POST .../:id/resume — 恢复消费(游标不动,从上次 watermark 继续)");
781
+ subs
782
+ .command("delete")
783
+ .description("DELETE .../notification-subscriptions/:id — 软删(保留审计,204 无响应体)")
784
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
785
+ .requiredOption("--subscription <nameOrId>", "订阅名或 uuid")
786
+ .option("--apply", "确认执行")
787
+ .option("--dry-run", "只预览")
788
+ .action(async (opts) => {
789
+ await runNotify("admin", async () => {
790
+ const teamId = resolveTeamId(opts.team);
791
+ const client = requireClient();
792
+ const sub = await resolveSubscription(client, teamId, opts.subscription);
793
+ if (!assertApplyOrDryRun(opts, {
794
+ command: "notify subscriptions delete",
795
+ danger: "软删订阅并作废全部在途 receipt(receiptEpoch bump)",
796
+ summary: { subscriptionId: sub.id, name: sub.name },
797
+ })) {
798
+ return;
799
+ }
800
+ await client.deleteNotificationSubscription(teamId, sub.id);
801
+ // API 返回 204 无响应体;给 agent 一个可解析的确认对象。
802
+ printJson({ deleted: true, subscriptionId: sub.id, name: sub.name });
803
+ });
804
+ });
805
+ subs
806
+ .command("reset")
807
+ .description("POST .../notification-subscriptions/:id/reset — 重设游标起点(stale 恢复的唯一出口)")
808
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
809
+ .requiredOption("--subscription <nameOrId>", "订阅名或 uuid")
810
+ .option("--start <position>", "now|earliest(缺省沿用订阅自身的 startPosition)")
811
+ .option("--apply", "确认执行")
812
+ .option("--dry-run", "只预览")
813
+ .action(async (opts) => {
814
+ await runNotify("admin", async () => {
815
+ const teamId = resolveTeamId(opts.team);
816
+ const start = opts.start;
817
+ if (start !== undefined && start !== "now" && start !== "earliest") {
818
+ fail("--start 只能是 now 或 earliest。");
819
+ }
820
+ const client = requireClient();
821
+ const sub = await resolveSubscription(client, teamId, opts.subscription);
822
+ if (!assertApplyOrDryRun(opts, {
823
+ command: "notify subscriptions reset",
824
+ danger: "重设游标:start=now 会**永久跳过**当前所有未处理事件;start=earliest 会重放全部保留期内事件(agent 须靠 dedupeKey 幂等)。同时 bump receiptEpoch,作废全部在途 receipt。",
825
+ summary: {
826
+ subscriptionId: sub.id,
827
+ name: sub.name,
828
+ currentCursorSeq: sub.cursorSeq,
829
+ startPosition: start ?? sub.startPosition,
830
+ // reset 不会 resume:paused 保持 paused,只有 stale 会被恢复成 active。
831
+ statusAfterReset: sub.status === "stale" ? "active" : sub.status,
832
+ expectedRowVersion: sub.rowVersion,
833
+ },
834
+ })) {
835
+ return;
836
+ }
837
+ printJson(
838
+ // 真 CAS:rowVersion 取自上面刚读到的订阅,若期间被并发改过服务端会 409。
839
+ await client.resetNotificationSubscriptionCursor(teamId, sub.id, {
840
+ expectedRowVersion: sub.rowVersion,
841
+ ...(start ? { startPosition: start } : {}),
842
+ }));
843
+ });
844
+ });
845
+ }
846
+ /** 组装 create 请求体:`-j/--json` 整体覆盖,否则由扁平参数拼(并做升序去重规范化)。 */
847
+ export function buildCreateSubscriptionBody(opts) {
848
+ if (opts.json !== undefined || opts.jsonFile !== undefined) {
849
+ return resolveJsonArg({
850
+ inline: opts.json,
851
+ file: opts.jsonFile,
852
+ label: "-j / --json",
853
+ required: true,
854
+ });
855
+ }
856
+ if (!opts.name)
857
+ fail("缺少 --name(或用 -j/--json/--json-file 给整个请求体)。");
858
+ const sourceIds = [...new Set(opts.source ?? [])].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
859
+ if (sourceIds.length === 0) {
860
+ fail("至少需要一个 --source <sourceId>(如 --source post-performance-node.v1)。");
861
+ }
862
+ const start = opts.start ?? "now";
863
+ if (start !== "now" && start !== "earliest") {
864
+ fail("--start 只能是 now 或 earliest。");
865
+ }
866
+ const filters = {};
867
+ const brandIds = parseSortedList(opts.filterBrand);
868
+ if (brandIds)
869
+ filters.brandIds = brandIds;
870
+ const socialAccountIds = parseSortedList(opts.filterAccount);
871
+ if (socialAccountIds)
872
+ filters.socialAccountIds = socialAccountIds;
873
+ // 逐项、**锚定**去前缀(与契约 canonicalizeNotificationFilters 同则)。
874
+ // 对整串做全局替换会把中间的 `/r/` 也吃掉,把非法输入悄悄改成另一个合法 subreddit。
875
+ const subreddits = parseSortedList(opts.filterSubreddit
876
+ ?.split(",")
877
+ .map((v) => v
878
+ .trim()
879
+ .replace(/^\/?r\//i, "")
880
+ .toLowerCase())
881
+ .join(","));
882
+ if (subreddits)
883
+ filters.subreddits = subreddits;
884
+ const requiredFields = parseSortedList(opts.requireFields);
885
+ return {
886
+ name: opts.name,
887
+ sourceIds,
888
+ ...(Object.keys(filters).length > 0 ? { filters } : {}),
889
+ ...(requiredFields ? { requiredFields } : {}),
890
+ startPosition: start,
891
+ };
892
+ }
893
+ function registerPullCommand(notify) {
894
+ notify
895
+ .command("pull")
896
+ .description("POST .../notification-events/pull — 拉事件(幂等,不推进游标)。默认 --limit 1:游标是连续 watermark")
897
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
898
+ .requiredOption("--subscription <nameOrId>", "订阅名或 uuid")
899
+ .option("--limit <n>", "1..50,默认 1(强烈建议保持 1)", "1")
900
+ .option("--wait <seconds>", `0..${PULL_WAIT_SECONDS_MAX} long-poll 秒数(默认 0;--follow 默认 ${FOLLOW_DEFAULT_WAIT_SECONDS})`)
901
+ .option("--format <fmt>", "json(完整 pull 响应,含 receipt)| jsonl(stdout 每行一个 envelope,需配 --receipt-file)", "json")
902
+ .option("--receipt-file <path>", "把 ack 所需的 receipt 原子写入该文件(jsonl / --follow 必填)")
903
+ .option("--follow", "常驻监听:一次一条,等 cursor 推进后才拉下一条(maxUnacked=1)")
904
+ .option("--fail-if-empty", "无消息时退出 2(不可与 --follow 同用)")
905
+ .option("--poll-interval <seconds>", "--follow 等待 ack 的初始轮询间隔(默认 1)", "1")
906
+ .option("--max-poll-interval <seconds>", "--follow 轮询间隔上限(默认 10)", "10")
907
+ .action(async (opts) => {
908
+ await runNotify("pull", async () => {
909
+ const follow = opts.follow === true;
910
+ const format = opts.format === "jsonl" ? "jsonl" : "json";
911
+ if (opts.format !== undefined &&
912
+ !["json", "jsonl"].includes(opts.format)) {
913
+ fail("--format 只能是 json 或 jsonl。");
914
+ }
915
+ const limit = parseIntOption(opts.limit, "--limit", 1, PULL_LIMIT_MAX) ?? 1;
916
+ const waitSeconds = parseIntOption(opts.wait, "--wait", 0, PULL_WAIT_SECONDS_MAX) ??
917
+ (follow ? FOLLOW_DEFAULT_WAIT_SECONDS : 0);
918
+ const pollIntervalMs = (parseIntOption(opts.pollInterval, "--poll-interval", 1, 3600) ?? 1) *
919
+ 1000;
920
+ const maxPollIntervalMs = (parseIntOption(opts.maxPollInterval, "--max-poll-interval", 1, 3600) ?? 10) * 1000;
921
+ if (maxPollIntervalMs < pollIntervalMs) {
922
+ fail("--max-poll-interval 不能小于 --poll-interval。");
923
+ }
924
+ if (follow && opts.failIfEmpty) {
925
+ fail("--follow 与 --fail-if-empty 互斥:--follow 本来就是等到有消息为止。");
926
+ }
927
+ if (follow && limit !== 1) {
928
+ fail("--follow 只支持 --limit 1:背压语义是 maxUnacked=1,一次多条就无法安全 ack。");
929
+ }
930
+ if (follow && format !== "jsonl") {
931
+ fail("--follow 必须配 --format jsonl(流式消费;stdout 每行一个 envelope)。");
932
+ }
933
+ // jsonl 的 stdout 里没有 receipt,而 ack 要求回传整个 receipt 对象。
934
+ // 没有 sidecar 就等于产出一批永远 ack 不了的死事件,故提前拒。
935
+ if (format === "jsonl" && !opts.receiptFile) {
936
+ fail("--format jsonl 必须配 --receipt-file <path>:envelope 里没有 receipt,而 ack 要回传整个 receipt 对象。");
937
+ }
938
+ const teamId = resolveTeamId(opts.team);
939
+ const client = requireClient();
940
+ if (follow) {
941
+ // pullFollow 自己管信号与已分类的 HTTP 错误并返回退出码;
942
+ // 意外的本地 I/O 异常(sidecar 落盘失败、stdout 关闭)冒泡给外层 runNotify。
943
+ process.exitCode = await pullFollow(client, teamId, opts.subscription, {
944
+ waitSeconds,
945
+ receiptFile: opts.receiptFile,
946
+ pollIntervalMs,
947
+ maxPollIntervalMs,
948
+ });
949
+ return;
950
+ }
951
+ const sub = await resolveSubscription(client, teamId, opts.subscription);
952
+ await pullOneShot(client, teamId, sub, {
953
+ limit,
954
+ waitSeconds,
955
+ format,
956
+ ...(opts.receiptFile ? { receiptFile: opts.receiptFile } : {}),
957
+ failIfEmpty: opts.failIfEmpty === true,
958
+ });
959
+ });
960
+ });
961
+ }
962
+ /**
963
+ * `--receipt` / `--receipt-file` 收两种形状:
964
+ * - sidecar(`{subscriptionId, cursorSeq, throughSeq, receipt}`,`notify pull --receipt-file` 产出)
965
+ * - 裸 receipt(`{subscriptionId, fromCursorSeq, …, signature}`)
966
+ * 两者都接受,agent 不必自己拆包。
967
+ */
968
+ export function extractAckInput(raw) {
969
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
970
+ throw new JsonArgError("--receipt 须为 JSON 对象(sidecar 或裸 receipt)。");
971
+ }
972
+ const obj = raw;
973
+ const inner = obj.receipt;
974
+ if (inner && typeof inner === "object" && !Array.isArray(inner)) {
975
+ return {
976
+ receipt: inner,
977
+ cursorSeq: typeof obj.cursorSeq === "string" ? obj.cursorSeq : undefined,
978
+ throughSeq: typeof obj.throughSeq === "string" ? obj.throughSeq : undefined,
979
+ };
980
+ }
981
+ if (typeof obj.signature !== "string") {
982
+ throw new JsonArgError("--receipt 既不是 sidecar(缺 receipt 字段)也不是裸 receipt(缺 signature 字段)。");
983
+ }
984
+ return {
985
+ receipt: obj,
986
+ cursorSeq: typeof obj.fromCursorSeq === "string" ? obj.fromCursorSeq : undefined,
987
+ throughSeq: typeof obj.maxDeliveredSeq === "string" ? obj.maxDeliveredSeq : undefined,
988
+ };
989
+ }
990
+ function registerAckCommand(notify) {
991
+ notify
992
+ .command("ack")
993
+ .description("POST .../notification-events/ack — 推进连续 watermark(「seq ≤ throughSeq 的全部已完成」,不是执行回执)")
994
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
995
+ .requiredOption("--subscription <nameOrId>", "订阅名或 uuid")
996
+ .option("--expected-cursor-seq <seq>", "CAS:ack 前的 cursorSeq(缺省取 receipt/sidecar 里的 fromCursorSeq)")
997
+ .option("--through-seq <seq>", "推进到哪一条(缺省取 sidecar 的 throughSeq / receipt 的 maxDeliveredSeq)")
998
+ .option("--receipt <json>", "pull 返回的 receipt 或 sidecar JSON(支持 @<file> 与 - 读 stdin)")
999
+ .option("--receipt-file <path>", "从文件/`-`(stdin) 读 receipt / sidecar JSON")
1000
+ .action(async (opts) => {
1001
+ await runNotify("ack", async () => {
1002
+ const teamId = resolveTeamId(opts.team);
1003
+ const parsed = resolveJsonArg({
1004
+ inline: opts.receipt,
1005
+ file: opts.receiptFile,
1006
+ label: "--receipt",
1007
+ required: true,
1008
+ });
1009
+ const { receipt, cursorSeq, throughSeq } = extractAckInput(parsed);
1010
+ const expectedCursorSeq = assertSeq(opts.expectedCursorSeq ??
1011
+ cursorSeq ??
1012
+ fail("缺少 --expected-cursor-seq(receipt/sidecar 里也没有)。"), "--expected-cursor-seq", true);
1013
+ const through = assertSeq(opts.throughSeq ??
1014
+ throughSeq ??
1015
+ fail("缺少 --through-seq(receipt/sidecar 里也没有)。"), "--through-seq", false);
1016
+ const client = requireClient();
1017
+ const sub = await resolveSubscription(client, teamId, opts.subscription);
1018
+ printJson(await client.ackNotificationCursor(teamId, {
1019
+ subscriptionId: sub.id,
1020
+ expectedCursorSeq,
1021
+ throughSeq: through,
1022
+ receipt,
1023
+ }));
1024
+ });
1025
+ });
1026
+ }
1027
+ function registerSourceConfigCommands(notify) {
1028
+ const cfg = notify
1029
+ .command("source-configs")
1030
+ .description("后台侧 source 配置(数据,不发版):节点档位/过滤/字段勾选/保留期 + 实时 kill switch");
1031
+ cfg
1032
+ .command("sources")
1033
+ .description("GET .../notification-sources — registry 声明(可勾字段/默认档位)+ 本 team 的 active config")
1034
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
1035
+ .action(async (opts) => {
1036
+ await runNotify("admin", async () => {
1037
+ const teamId = resolveTeamId(opts.team);
1038
+ printJson(await requireClient().listNotificationSources(teamId));
1039
+ });
1040
+ });
1041
+ cfg
1042
+ .command("list")
1043
+ .description("GET .../notification-sources/:sourceId/configs — 该 source 的历史 version(老版本只读留档)")
1044
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
1045
+ .requiredOption("--source <sourceId>", "sourceId")
1046
+ .option("--limit <n>", "1..200,默认 50")
1047
+ .action(async (opts) => {
1048
+ await runNotify("admin", async () => {
1049
+ const teamId = resolveTeamId(opts.team);
1050
+ const limit = parseIntOption(opts.limit, "--limit", 1, 200);
1051
+ printJson(await requireClient().listNotificationSourceConfigs(teamId, opts.source, limit === undefined ? {} : { limit }));
1052
+ });
1053
+ });
1054
+ cfg
1055
+ .command("get")
1056
+ .description("GET .../notification-sources/:sourceId/configs/:configId")
1057
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
1058
+ .requiredOption("--source <sourceId>", "sourceId")
1059
+ .requiredOption("--id <uuid>", "config version 行 id")
1060
+ .action(async (opts) => {
1061
+ await runNotify("admin", async () => {
1062
+ const teamId = resolveTeamId(opts.team);
1063
+ printJson(await requireClient().getNotificationSourceConfig(teamId, opts.source, opts.id));
1064
+ });
1065
+ });
1066
+ cfg
1067
+ .command("create")
1068
+ .description("POST .../notification-sources/:sourceId/configs — 创建新的不可变 version(默认立即激活;只影响新物化的 schedule)")
1069
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
1070
+ .requiredOption("--source <sourceId>", "sourceId(须与 body.sourceId 一致)")
1071
+ .option("--no-activate", "只建 draft 不激活(默认建完即激活)")
1072
+ .option("-j, --json <json>", "请求体 JSON(也支持 @<file> 与 - 读 stdin)")
1073
+ .option("--json-file <path>", "从文件/`-`(stdin) 读请求体 JSON")
1074
+ .option("--apply", "确认执行")
1075
+ .option("--dry-run", "只预览")
1076
+ .action(async (opts) => {
1077
+ await runNotify("admin", async () => {
1078
+ const teamId = resolveTeamId(opts.team);
1079
+ const body = resolveJsonArg({
1080
+ inline: opts.json,
1081
+ file: opts.jsonFile,
1082
+ label: "-j / --json",
1083
+ required: true,
1084
+ });
1085
+ const activate = opts.activate !== false;
1086
+ if (!assertApplyOrDryRun(opts, {
1087
+ command: "notify source-configs create",
1088
+ danger: activate
1089
+ ? "新建并【立即激活】config version:若其 configuredFields 不含某个 active 订阅的 requiredFields,会被 409 拒绝激活"
1090
+ : "新建 draft config version(不激活)",
1091
+ summary: { sourceId: opts.source, activate, body },
1092
+ })) {
1093
+ return;
1094
+ }
1095
+ printJson(await requireClient().createNotificationSourceConfig(teamId, opts.source, body, { activate }));
1096
+ });
1097
+ });
1098
+ cfg
1099
+ .command("activate")
1100
+ .description("POST .../notification-sources/:sourceId/configs/:configId/activate — 激活某个已建 version")
1101
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
1102
+ .requiredOption("--source <sourceId>", "sourceId")
1103
+ .requiredOption("--id <uuid>", "config version 行 id")
1104
+ .option("--apply", "确认执行")
1105
+ .option("--dry-run", "只预览")
1106
+ .action(async (opts) => {
1107
+ await runNotify("admin", async () => {
1108
+ const teamId = resolveTeamId(opts.team);
1109
+ if (!assertApplyOrDryRun(opts, {
1110
+ command: "notify source-configs activate",
1111
+ danger: "激活会做双向前置检查:打掉某个 active 订阅的 requiredFields 时 409 拒绝(Phase 1 无强制覆盖)",
1112
+ summary: { sourceId: opts.source, configId: opts.id },
1113
+ })) {
1114
+ return;
1115
+ }
1116
+ printJson(await requireClient().activateNotificationSourceConfig(teamId, opts.source, opts.id));
1117
+ });
1118
+ });
1119
+ cfg
1120
+ .command("set-enabled")
1121
+ .description("PUT .../notification-sources/:sourceId/enabled — 实时 kill switch(影响全部在途 schedule;不 bump sourceConfigVersion)")
1122
+ .option("-t, --team <teamId>", "Team UUID(缺省用当前 context)")
1123
+ .requiredOption("--source <sourceId>", "sourceId(身份级开关,改的是该 source 下全部历史 version 行)")
1124
+ .requiredOption("--enabled <bool>", "true|false")
1125
+ .option("--apply", "确认执行")
1126
+ .option("--dry-run", "只预览")
1127
+ .action(async (opts) => {
1128
+ await runNotify("admin", async () => {
1129
+ const teamId = resolveTeamId(opts.team);
1130
+ const raw = String(opts.enabled).toLowerCase();
1131
+ if (raw !== "true" && raw !== "false") {
1132
+ fail("--enabled 只能是 true 或 false。");
1133
+ }
1134
+ const enabled = raw === "true";
1135
+ if (!assertApplyOrDryRun(opts, {
1136
+ command: "notify source-configs set-enabled",
1137
+ danger: enabled
1138
+ ? "重新启用:**不会**补发 disabled 期间已标 missed 的节点"
1139
+ : "停用:disabled 期间到点的 schedule 一律不发事件、直接标 missed(不排队补发)",
1140
+ summary: { sourceId: opts.source, enabled },
1141
+ })) {
1142
+ return;
1143
+ }
1144
+ printJson(await requireClient().setNotificationSourceEnabled(teamId, opts.source, enabled));
1145
+ });
1146
+ });
1147
+ }
1148
+ //# sourceMappingURL=register-notify.js.map