@sleep2agi/commhub-server 0.9.0-preview.102 → 0.9.0-preview.103

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.102",
3
+ "version": "0.9.0-preview.103",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -22,6 +22,7 @@ delete process.env.COMMHUB_DUE_REMINDER_NETWORKS;
22
22
  delete process.env.COMMHUB_DUE_REMINDERS;
23
23
  delete process.env.COMMHUB_DUE_REMINDERS_NETWORKS;
24
24
  delete process.env.COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS;
25
+ delete process.env.COMMHUB_DUE_REMINDERS_OWNERS;
25
26
  // 节点提醒默认关;这个文件的大部分用例要看节点那一路,先打开(「默认关」单独一个用例)。
26
27
  process.env.COMMHUB_DUE_REMINDER_NODES = "1";
27
28
  const PW = "DueRemindPassw0rd!x";
@@ -340,7 +341,7 @@ describe("上线护栏", () => {
340
341
  });
341
342
 
342
343
  // ── #524 按网络开关 ────────────────────────────────────
343
- const SCOPE_VARS = ["COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDER_NETWORKS"];
344
+ const SCOPE_VARS = ["COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDER_NETWORKS", "COMMHUB_DUE_REMINDERS_OWNERS"];
344
345
  async function withEnv<T>(vars: Record<string, string>, fn: () => T | Promise<T>): Promise<T> {
345
346
  const saved = Object.fromEntries(SCOPE_VARS.map((k) => [k, process.env[k]]));
346
347
  for (const k of SCOPE_VARS) delete process.env[k];
@@ -463,6 +464,107 @@ describe("上线护栏", () => {
463
464
  "[due-reminders] scope: all networks except net_placeholder_x",
464
465
  ]);
465
466
  });
467
+
468
+ // ── #524 按负责人开关(两个团队共用一个网络,网络名单分不开)────────
469
+ test("#524 负责人名单:范围解析 / 空白 = 没设 / 只在用到时绑参数", async () => {
470
+ const S = (vars: Record<string, string>) => withEnv(vars, () => due.dueReminderScope());
471
+ // =0 + 负责人名单:与网络白名单同一语义 —— 单独就能打开,范围 = 所有网络里名单里的人负责的卡
472
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: " u_a , u_b,u_a, " })).toEqual({ mode: "all", exclude: [], owners: ["u_a", "u_b"] });
473
+ expect(await S({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_OWNERS: "u_a" })).toEqual({ mode: "all", exclude: [], owners: ["u_a"] });
474
+ // 与网络白名单 / 黑名单叠加(且)
475
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: "net_a", COMMHUB_DUE_REMINDERS_OWNERS: "u_a" })).toEqual({ mode: "only", include: ["net_a"], exclude: [], owners: ["u_a"] });
476
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x", COMMHUB_DUE_REMINDERS_OWNERS: "u_a" })).toEqual({ mode: "all", exclude: ["net_x"], owners: ["u_a"] });
477
+ // 空 / 空白 / 只有逗号 = 没设:=0 时仍然关;不设 =0 时与原来完全一样(没有 owners 键)
478
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: "" })).toEqual({ mode: "off" });
479
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: " , ," })).toEqual({ mode: "off" });
480
+ expect(await S({ COMMHUB_DUE_REMINDERS_OWNERS: " " })).toStrictEqual({ mode: "all", exclude: [] });
481
+ // SQL:没名单不 push;有名单精确匹配存储形状,占位号接在已有参数后面
482
+ const p0: unknown[] = ["lo", "hi"];
483
+ expect(due.dueOwnerSql({ mode: "all", exclude: [] }, p0)).toBe("");
484
+ expect(due.dueOwnerSql({ mode: "off" }, p0)).toBe("");
485
+ expect(p0).toEqual(["lo", "hi"]);
486
+ const p1: unknown[] = ["lo", "hi", "net_x"];
487
+ expect(due.dueOwnerSql({ mode: "all", exclude: ["net_x"], owners: ["u_a", "u_b"] }, p1)).toBe(" AND owner_json IN (?4, ?5)");
488
+ expect(p1).toEqual(["lo", "hi", "net_x", JSON.stringify(userRef("u_a")), JSON.stringify(userRef("u_b"))]);
489
+ // 日志
490
+ expect(due.describeDueReminderScope({ mode: "all", exclude: [], owners: ["u_a"] })).toBe("all networks; only cards owned by u_a");
491
+ expect(due.describeDueReminderScope({ mode: "only", include: ["net_a"], exclude: [], owners: ["u_a", "u_b"] })).toBe("only networks net_a; only cards owned by u_a,u_b");
492
+ });
493
+
494
+ test("#524 COMMHUB_DUE_REMINDERS=0 + 负责人名单:同一网络里只有名单里的人负责的卡发(人 + 负责 Agent);别人负责的一条不发", async () => {
495
+ const listed = await card("dr-524-名单内负责人", "2026-10-11");
496
+ const unlisted = await card("dr-524-名单外负责人", "2026-10-11", { owner: userRef(pia.id), participants: [userRef(pat.id)] });
497
+ const noOwner = await card("dr-524-没有负责人", "2026-10-11", { owner: null, participants: [userRef(owen.id)] });
498
+ const out = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: ` ${owen.id} ,` }, () => due.runDueReminders({ now: T0 }));
499
+ // 名单里的人负责的卡:恰好一档,收件人 = 负责人 + 参与人,负责 Agent 也收到一条
500
+ expect(sentFor(out, listed)).toEqual(["due_soon"]);
501
+ expect(out.find((x) => x.requirement_id === listed)?.users.sort()).toEqual([owen.id, pat.id, pia.id].sort());
502
+ expect(out.find((x) => x.requirement_id === listed)?.node).toBe(ALIAS);
503
+ const n = userNotices(listed);
504
+ expect(Object.keys(n).sort()).toEqual([owen.id, pat.id, pia.id].sort());
505
+ for (const id of [owen.id, pat.id, pia.id]) expect(n[id].map((x) => x.reminder)).toEqual(["due_soon"]);
506
+ expect(nodeMessages(listed)).toHaveLength(1);
507
+ // 名单外的人负责的卡 / 没有负责人的卡:什么都不发,连去重行都不写(以后把人加进来,照样按时发)
508
+ for (const id of [unlisted, noOwner]) {
509
+ expect(sentFor(out, id)).toEqual([]);
510
+ expect(userNotices(id)).toEqual({});
511
+ expect(nodeMessages(id)).toEqual([]);
512
+ expect(rowCount(id)).toBe(0);
513
+ }
514
+ // 返回值里也只有名单里的人负责的卡
515
+ for (const x of out) {
516
+ const row = db.get("SELECT owner_json FROM requirements WHERE requirement_id = ?1", x.requirement_id);
517
+ expect(JSON.parse(row.owner_json)).toEqual(userRef(owen.id));
518
+ }
519
+ // 同一时刻再跑:不重发
520
+ expect(sentFor(await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: owen.id }, () => due.runDueReminders({ now: T0 })), listed)).toEqual([]);
521
+ // 与网络白名单是「且」:人在名单里、网络不在 → 不发
522
+ const other = mkNet("own_and");
523
+ const c = await card("dr-524-人对网络不对", "2026-10-11");
524
+ const out2 = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: other.net, COMMHUB_DUE_REMINDERS_OWNERS: owen.id }, () => due.runDueReminders({ now: T0 }));
525
+ expect(sentFor(out2, c)).toEqual([]);
526
+ expect(rowCount(c)).toBe(0);
527
+ // 名单撤掉(=1、不设名单)→ 名单外负责人的卡照常发:证明上面是名单挡的,不是卡本身不该发
528
+ expect(sentFor(await withEnv({ COMMHUB_DUE_REMINDERS: "1" }, () => due.runDueReminders({ now: T0 })), unlisted)).toEqual(["due_soon"]);
529
+ });
530
+
531
+ test("#524 负责人名单不补发:网络早就开着,人后加进名单 —— 他加进来之前就逾期的卡不发,之后才逾期的照发", async () => {
532
+ const f = mkNet("own_late");
533
+ // 网络基线很早(T0 - 30 天),人还不在任何名单里
534
+ await withEnv({ COMMHUB_DUE_REMINDERS_NETWORKS: f.net }, () => due.runDueReminders({ now: T0 - 30 * DAY }));
535
+ expect(baselineCount(f.net)).toBe(1);
536
+ const old = await cardIn(f, "dr-524-进名单前逾期", "2026-10-07");
537
+ const today = await cardIn(f, "dr-524-进名单当天到期", "2026-10-10");
538
+ const env = { COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: f.id };
539
+ const first = await withEnv(env, () => due.runDueReminders({ now: T0 }));
540
+ // 网络基线比逾期早 —— 只看网络基线就会补发;负责人基线挡住
541
+ expect(sentFor(first, old)).toEqual([]);
542
+ expect(sentFor(first, today)).toEqual(["due_today"]);
543
+ const ob = db.all("SELECT network_id, due_on, sent_at FROM requirement_due_reminders WHERE kind = 'baseline_owner' AND network_id = ?1", f.net);
544
+ expect(ob).toEqual([{ network_id: f.net, due_on: f.id, sent_at: new Date(T0).toISOString() }]);
545
+ const next = await withEnv(env, () => due.runDueReminders({ now: T0 + DAY }));
546
+ expect(sentFor(next, old)).toEqual([]);
547
+ expect(sentFor(next, today)).toEqual(["overdue"]);
548
+ // 负责人基线不被保留期清掉
549
+ await withEnv(env, () => due.runDueReminders({ now: T0 + 90 * DAY }));
550
+ expect(db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE kind = 'baseline_owner' AND network_id = ?1", f.net).n).toBe(1);
551
+ });
552
+
553
+ test("#524 启动:=0 + 负责人名单起定时器,日志只有 id", async () => {
554
+ const logs: string[] = [];
555
+ const g = globalThis as any;
556
+ const orig = { log: console.log, setTimeout: g.setTimeout, setInterval: g.setInterval };
557
+ let timers = 0;
558
+ console.log = (...a: unknown[]) => { logs.push(a.map(String).join(" ")); };
559
+ g.setTimeout = () => { timers++; return { unref() {} }; };
560
+ g.setInterval = () => { timers++; return { fake: true }; };
561
+ try {
562
+ const on = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: "u_placeholder_a" }, () => due.startDueReminderTimer());
563
+ expect(on).toEqual({ fake: true } as any);
564
+ expect(timers).toBe(2);
565
+ } finally { console.log = orig.log; g.setTimeout = orig.setTimeout; g.setInterval = orig.setInterval; }
566
+ expect(logs.filter((l) => l.startsWith("[due-reminders] scope:"))).toEqual(["[due-reminders] scope: all networks; only cards owned by u_placeholder_a"]);
567
+ });
466
568
  });
467
569
 
468
570
  describe("#494 列表筛选", () => {
@@ -24,7 +24,13 @@
24
24
  // #524 按网络开关(一个 Hub 上多个团队的网络,只想给自己的网络开):
25
25
  // COMMHUB_DUE_REMINDERS_NETWORKS=<id,id,…> 白名单。COMMHUB_DUE_REMINDERS=0 时也生效:只给这些网络发。
26
26
  // COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS=<id,id,…> 黑名单。功能开着时这些网络不发(白名单里也排除)。
27
- // 两个新变量都不设 → 行为与之前完全相同。生效范围在 startHub 时打一行日志(只有网络 id)。
27
+ // #524 按人开关(多个团队共用同一个网络,网络名单分不开时):
28
+ // COMMHUB_DUE_REMINDERS_OWNERS=<user id,…> 负责人白名单。设了就只扫负责人(owner)在名单里的卡;
29
+ // 这张卡的人提醒(负责人 + 参与人)和负责 Agent 消息照常发,别人负责的卡一条都不发。与网络白名单 / 黑名单是「且」。
30
+ // 和 COMMHUB_DUE_REMINDERS_NETWORKS 一样,COMMHUB_DUE_REMINDERS=0 时也生效(只给名单里的人负责的卡打开)。
31
+ // 开通不补发按「网络 × 负责人」各记一行基线(kind='baseline_owner',due_on = user id):以后把人加进名单,
32
+ // 从加进来那一刻算起,不会补发他过去几天的逾期卡。
33
+ // 这些新变量都不设 → 行为与之前完全相同。生效范围在 startHub 时打一行日志(只有网络 id)。
28
34
  // 过滤写在扫描 SQL 的 WHERE 里(基线 INSERT 与候选 SELECT 同一个条件),范围外网络的行根本不会被读出来。
29
35
  // COMMHUB_DUE_REMINDER_NODES=1 给负责 Agent 节点发消息;默认关(人照常提醒)。
30
36
  // 开通时不补发:每个网络第一次被扫描到时记一行基线(kind='baseline',sent_at = 那一刻)。只有在基线**之后**才变成逾期的卡
@@ -99,23 +105,39 @@ export function reminderNetworks(): string[] | null {
99
105
  */
100
106
  export type DueReminderScope =
101
107
  | { mode: "off" }
102
- | { mode: "all"; exclude: string[] }
103
- | { mode: "only"; include: string[]; exclude: string[] };
108
+ | { mode: "all"; exclude: string[]; owners?: string[] }
109
+ | { mode: "only"; include: string[]; exclude: string[]; owners?: string[] };
104
110
 
111
+ /**
112
+ * owners(COMMHUB_DUE_REMINDERS_OWNERS)只在设了时出现:只扫负责人在名单里的卡(与网络条件是「且」)。
113
+ * 与 COMMHUB_DUE_REMINDERS_NETWORKS 同一语义:COMMHUB_DUE_REMINDERS=0 时也能单独把功能打开(只对名单里的人)。
114
+ */
105
115
  export function dueReminderScope(): DueReminderScope {
106
116
  const allow = envIdList("COMMHUB_DUE_REMINDERS_NETWORKS");
107
117
  const legacy = envIdList("COMMHUB_DUE_REMINDER_NETWORKS");
108
118
  const exclude = envIdList("COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS") ?? [];
109
- if (process.env.COMMHUB_DUE_REMINDERS === "0" && !allow) return { mode: "off" };
110
- if (!allow && !legacy) return { mode: "all", exclude };
119
+ const owners = envIdList("COMMHUB_DUE_REMINDERS_OWNERS");
120
+ if (process.env.COMMHUB_DUE_REMINDERS === "0" && !allow && !owners) return { mode: "off" };
121
+ const withOwners = owners ? { owners } : {};
122
+ if (!allow && !legacy) return { mode: "all", exclude, ...withOwners };
111
123
  const include = [...new Set([...(allow ?? []), ...(legacy ?? [])])].filter((id) => !exclude.includes(id));
112
- return { mode: "only", include, exclude };
124
+ return { mode: "only", include, exclude, ...withOwners };
113
125
  }
114
126
 
115
127
  export function describeDueReminderScope(scope: DueReminderScope = dueReminderScope()): string {
116
128
  if (scope.mode === "off") return "off";
117
- if (scope.mode === "all") return scope.exclude.length ? `all networks except ${scope.exclude.join(",")}` : "all networks";
118
- return scope.include.length ? `only networks ${scope.include.join(",")}` : "no networks (allowlist empty after exclude)";
129
+ const who = scope.owners ? `; only cards owned by ${scope.owners.join(",")}` : "";
130
+ if (scope.mode === "all") return (scope.exclude.length ? `all networks except ${scope.exclude.join(",")}` : "all networks") + who;
131
+ return scope.include.length ? `only networks ${scope.include.join(",")}${who}` : "no networks (allowlist empty after exclude)";
132
+ }
133
+
134
+ /**
135
+ * 负责人条件(只拼在 requirements 的候选 SELECT 上;networks 表没有负责人)。没设名单 → "",不 push 任何参数。
136
+ * 精确匹配存储形状 {"kind":"user","id":…}(与列表 ?owner=user:<id> 筛选同一写法):对不上就不发,宁可少发。
137
+ */
138
+ export function dueOwnerSql(scope: DueReminderScope, params: unknown[]): string {
139
+ if (scope.mode === "off" || !scope.owners?.length) return "";
140
+ return ` AND owner_json IN (${scope.owners.map((id) => `?${params.push(JSON.stringify({ kind: "user", id }))}`).join(", ")})`;
119
141
  }
120
142
 
121
143
  /**
@@ -133,6 +155,7 @@ export function dueScopeSql(scope: DueReminderScope, params: unknown[]): string
133
155
  }
134
156
  export function nodeRemindersEnabled(): boolean { return process.env.COMMHUB_DUE_REMINDER_NODES === "1"; }
135
157
  export const BASELINE_KIND = "baseline";
158
+ export const OWNER_BASELINE_KIND = "baseline_owner";
136
159
 
137
160
  export function overdueMaxDays(): number { return Math.floor(envNumber("COMMHUB_DUE_OVERDUE_MAX_DAYS", 7, 0)); }
138
161
 
@@ -280,6 +303,23 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
280
303
  ON CONFLICT DO NOTHING`,
281
304
  bp,
282
305
  );
306
+ // 负责人名单:每个(范围内网络 × 名单里的人)第一次被扫到的那一刻。due_on 列存 user id。
307
+ if (scope.mode !== "off") for (const owner of scope.owners ?? []) {
308
+ const op: unknown[] = [OWNER_BASELINE_KIND, sentAt, owner];
309
+ db.run(
310
+ `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
311
+ SELECT '__baseline__:' || network_id, ?1, ?3, '', network_id, ?2 FROM networks WHERE 1=1${dueScopeSql(scope, op) ?? ""}
312
+ ON CONFLICT DO NOTHING`,
313
+ op,
314
+ );
315
+ }
316
+ }
317
+ const ownerBaselines = new Map<string, number>();
318
+ if (scope.mode !== "off" && scope.owners?.length) {
319
+ for (const b of db.all<{ network_id: string; due_on: string; sent_at: string }>("SELECT network_id, due_on, sent_at FROM requirement_due_reminders WHERE kind = ?1", OWNER_BASELINE_KIND)) {
320
+ const t = parseHubTimestamp(b.sent_at);
321
+ if (t !== null) ownerBaselines.set(`${b.network_id}\u0000${b.due_on}`, t);
322
+ }
283
323
  }
284
324
  const baselines = new Map(
285
325
  db.all<{ network_id: string; sent_at: string }>("SELECT network_id, sent_at FROM requirement_due_reminders WHERE kind = ?1", BASELINE_KIND)
@@ -288,10 +328,11 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
288
328
  );
289
329
  const params: unknown[] = [lo, hi];
290
330
  const netFilter = dueScopeSql(scope, params) ?? "";
331
+ const ownerFilter = dueOwnerSql(scope, params);
291
332
  const rows = db.all<DueRow>(
292
333
  `SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
293
334
  FROM requirements
294
- WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name <> 'done' AND COALESCE(archived, 0) = 0${netFilter}`,
335
+ WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name <> 'done' AND COALESCE(archived, 0) = 0${netFilter}${ownerFilter}`,
295
336
  ...params,
296
337
  );
297
338
  const out: DueReminderSent[] = [];
@@ -300,7 +341,13 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
300
341
  if (!r) continue;
301
342
  if (r.kind === "overdue") {
302
343
  // 基线之前就已经逾期 → 不发(开通时不补发)。没有基线(网络行不存在)= 当作此刻开通。
303
- const base = baselines.get(row.network_id) ?? nowMs;
344
+ let base = baselines.get(row.network_id) ?? nowMs;
345
+ // 负责人名单:再取「这个人进名单」的基线,两者取晚的(把人加进名单也不补发)。
346
+ if (scope.mode !== "off" && scope.owners?.length) {
347
+ const owner = userIdOf(parse(row.owner_json));
348
+ const ownerBase = owner ? ownerBaselines.get(`${row.network_id}\u0000${owner}`) : undefined;
349
+ base = Math.max(base, ownerBase ?? nowMs);
350
+ }
304
351
  if (overdueSinceMs(row.due_on, tz) < base) continue;
305
352
  }
306
353
  try {
@@ -316,7 +363,7 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
316
363
  }
317
364
  }
318
365
  // 去重行保留 RETENTION_DAYS 天(比逾期上限长得多,删掉的行不会再被需要)。
319
- try { db.run("DELETE FROM requirement_due_reminders WHERE sent_at < ?1 AND kind <> ?2", [new Date(nowMs - RETENTION_DAYS * DAY_MS).toISOString(), BASELINE_KIND]); } catch {}
366
+ try { db.run("DELETE FROM requirement_due_reminders WHERE sent_at < ?1 AND kind NOT IN (?2, ?3)", [new Date(nowMs - RETENTION_DAYS * DAY_MS).toISOString(), BASELINE_KIND, OWNER_BASELINE_KIND]); } catch {}
320
367
  return out;
321
368
  }
322
369
 
@@ -354,7 +401,7 @@ function deliver(row: DueRow, r: { kind: DueKind; overdueDays: number; dueDay: s
354
401
  }
355
402
 
356
403
  /**
357
- * startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉(除非设了 COMMHUB_DUE_REMINDERS_NETWORKS,见 dueReminderScope);
404
+ * startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉(除非设了 COMMHUB_DUE_REMINDERS_NETWORKS / _OWNERS,见 dueReminderScope);
358
405
  * bootServer(测试)不调用它。第一次扫描延后一个间隔外的 30 秒,不和启动抢。启动时打一行生效范围(只有网络 id)。
359
406
  */
360
407
  export function startDueReminderTimer(): ReturnType<typeof setInterval> | null {