@sleep2agi/commhub-server 0.9.0-preview.100 → 0.9.0-preview.102
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 +1 -1
- package/src/agent-groups-perf.test.ts +48 -15
- package/src/db.ts +19 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/requirement-due-reminders-http.test.ts +128 -0
- package/src/requirement-due-reminders.ts +68 -9
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-tasks.ts +11 -2
- package/src/server.ts +22 -18
- package/src/task-queue-ahead-scale.test.ts +44 -19
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/tools.ts +22 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.102",
|
|
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",
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// 场景:NET 里 300 个节点;50 个组,每组 6 个节点(合起来覆盖全部 300 个)。
|
|
5
5
|
// baseline —— bob 直接授权这 300 个节点(没有组);
|
|
6
6
|
// groups —— alice 授权这 50 个组(没有直接授权),可见集合与 bob 完全相同。
|
|
7
|
-
// 两人看到的 Agent
|
|
7
|
+
// 两人看到的 Agent 一样多,差值就是「按组展开」本身的代价。团队要求 ≈2 ms 以内(本机实测 −0.5~+1.9 ms);
|
|
8
8
|
// 断言放宽到 max(5 ms, 基线的 15%)—— CI 容器比本机慢时两边一起变慢,绝对差也跟着放大。数字打印出来。
|
|
9
|
+
// #517:差值怎么量见 lowDelta*() —— 交替且每轮翻转先后、样本 40 对、比两边的 p20(不是每轮差值的中位数)、
|
|
10
|
+
// 一轮超标再重量最多 2 轮。界限本身(max(5 ms, 15%))没动。
|
|
9
11
|
|
|
10
12
|
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
11
13
|
import { mkdtempSync, rmSync } from "fs";
|
|
@@ -30,10 +32,11 @@ const PER_GROUP = NODES / GROUPS;
|
|
|
30
32
|
const BOUND_MS = 5;
|
|
31
33
|
const bound = (base: number) => Math.max(BOUND_MS, base * 0.15);
|
|
32
34
|
// CI 容器里一次 visibleAgents() 约 65 ms(本机约 28 ms):原来 40 次 × 两边再加预热,顶破了 bun 默认的 5 s 单测超时(#2131 CI 实测)。
|
|
33
|
-
//
|
|
35
|
+
// 性能用例另给显式超时(差值守卫最坏 3 轮 × 40 对,见 lowDelta)。
|
|
34
36
|
const WARMUP = 3;
|
|
35
|
-
const ITER = 15;
|
|
36
37
|
const PERF_TIMEOUT_MS = 60_000;
|
|
38
|
+
// #517 差值守卫:每轮 DELTA_PAIRS 对、比两边的 LOW_P 分位、超标再量最多 RETRIES 轮。
|
|
39
|
+
const DELTA_PAIRS = 40, LOW_P = 0.2, RETRIES = 2;
|
|
37
40
|
|
|
38
41
|
const json = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" });
|
|
39
42
|
async function send(token: string, method: string, path: string, payload?: unknown) {
|
|
@@ -56,15 +59,46 @@ function pairedDelta(a: () => void, b: () => void, n: number): { a: number; b: n
|
|
|
56
59
|
}
|
|
57
60
|
return { a: median(as), b: median(bs), delta: median(ds) };
|
|
58
61
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
const pct = (xs: number[], p: number) => { const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
|
|
63
|
+
type LowDelta = { a: number; b: number; delta: number; aP50: number; bP50: number; ok: boolean };
|
|
64
|
+
/**
|
|
65
|
+
* #517 —— 「组授权比直接授权多出多少」的稳健量法。旧做法(15 对、每轮固定先 A 后 B、取每轮差值中位数)在 test638
|
|
66
|
+
* 两份 aggregate 并发时红过(本地 2 核 + 忙循环邻居实测 paired-delta 7.34 ms > 5 ms,同一轮两边中位数差出 10 ms)——
|
|
67
|
+
* 量的是负载,不是组展开。现在:
|
|
68
|
+
* - 每轮翻转先后(偶数轮 A→B,奇数轮 B→A),「第二个总吃前一个的 GC / 调度」这类顺序偏差两边对半分;
|
|
69
|
+
* - 每边 DELTA_PAIRS 个样本,比两边各自的 LOW_P 分位:调度噪声只会加时间,低尾最接近真实成本;
|
|
70
|
+
* - 一轮超标就整轮重量,最多 RETRIES 次;真回归(组路径每节点多查一次,#2133 那种 N+1)每一轮都超。
|
|
71
|
+
*/
|
|
72
|
+
function lowDelta(a: () => void, b: () => void, bound: (base: number) => number): LowDelta {
|
|
73
|
+
let r: LowDelta | null = null;
|
|
74
|
+
for (let attempt = 0; attempt <= RETRIES; attempt++) {
|
|
75
|
+
for (let i = 0; i < WARMUP; i++) { a(); b(); }
|
|
76
|
+
const as: number[] = [], bs: number[] = [];
|
|
77
|
+
for (let i = 0; i < DELTA_PAIRS; i++) {
|
|
78
|
+
const time = (f: () => void) => { const t = performance.now(); f(); return performance.now() - t; };
|
|
79
|
+
if (i % 2 === 0) { as.push(time(a)); bs.push(time(b)); } else { bs.push(time(b)); as.push(time(a)); }
|
|
80
|
+
}
|
|
81
|
+
const lowA = pct(as, LOW_P), lowB = pct(bs, LOW_P);
|
|
82
|
+
r = { a: lowA, b: lowB, delta: lowB - lowA, aP50: pct(as, 0.5), bP50: pct(bs, 0.5), ok: lowB - lowA <= bound(lowA) };
|
|
83
|
+
if (r.ok) return r;
|
|
66
84
|
}
|
|
67
|
-
return
|
|
85
|
+
return r!;
|
|
86
|
+
}
|
|
87
|
+
async function lowDeltaAsync(a: () => Promise<void>, b: () => Promise<void>, bound: (base: number) => number, label: string): Promise<LowDelta> {
|
|
88
|
+
let r: LowDelta | null = null;
|
|
89
|
+
for (let attempt = 0; attempt <= RETRIES; attempt++) {
|
|
90
|
+
for (let i = 0; i < WARMUP; i++) { await a(); await b(); }
|
|
91
|
+
const as: number[] = [], bs: number[] = [];
|
|
92
|
+
const time = async (f: () => Promise<void>) => { const t = performance.now(); await f(); return performance.now() - t; };
|
|
93
|
+
for (let i = 0; i < DELTA_PAIRS; i++) {
|
|
94
|
+
if (i % 2 === 0) { as.push(await time(a)); bs.push(await time(b)); } else { bs.push(await time(b)); as.push(await time(a)); }
|
|
95
|
+
}
|
|
96
|
+
const lowA = pct(as, LOW_P), lowB = pct(bs, LOW_P);
|
|
97
|
+
r = { a: lowA, b: lowB, delta: lowB - lowA, aP50: pct(as, 0.5), bP50: pct(bs, 0.5), ok: lowB - lowA <= bound(lowA) };
|
|
98
|
+
console.log(`[perf] ${label} pass ${attempt + 1}: p${LOW_P * 100} direct-300=${lowA.toFixed(2)}ms groups-50x6=${lowB.toFixed(2)}ms delta=${r.delta.toFixed(2)}ms bound=${bound(lowA).toFixed(2)}ms (p50 ${r.aP50.toFixed(2)} / ${r.bP50.toFixed(2)}) ${r.ok ? "ok" : "OVER"}`);
|
|
99
|
+
if (r.ok) return r;
|
|
100
|
+
}
|
|
101
|
+
return r!;
|
|
68
102
|
}
|
|
69
103
|
|
|
70
104
|
beforeAll(async () => {
|
|
@@ -123,8 +157,8 @@ describe("Agent 分组:性能守卫(50 组 × 300 节点)", () => {
|
|
|
123
157
|
});
|
|
124
158
|
|
|
125
159
|
test(`visibleAgents():组展开的额外开销 ≤ max(${BOUND_MS} ms, 基线 15%)(目标 ≈2 ms)`, () => {
|
|
126
|
-
const r =
|
|
127
|
-
console.log(`[perf] visibleAgents
|
|
160
|
+
const r = lowDelta(() => { visibleAgents(bob.id, NET); }, () => { visibleAgents(alice.id, NET); }, bound);
|
|
161
|
+
console.log(`[perf] visibleAgents p${LOW_P * 100}: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms (p50 ${r.aP50.toFixed(2)} / ${r.bP50.toFixed(2)})`);
|
|
128
162
|
expect(r.delta).toBeLessThanOrEqual(bound(r.a));
|
|
129
163
|
}, PERF_TIMEOUT_MS);
|
|
130
164
|
|
|
@@ -157,8 +191,7 @@ describe("Agent 分组:性能守卫(50 组 × 300 节点)", () => {
|
|
|
157
191
|
const body = await r.json() as any;
|
|
158
192
|
if ((body.sessions ?? []).length !== NODES) throw new Error(`expected ${NODES} sessions, got ${(body.sessions ?? []).length}`);
|
|
159
193
|
};
|
|
160
|
-
const r = await
|
|
161
|
-
console.log(`[perf] GET /api/status median: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms paired-delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms`);
|
|
194
|
+
const r = await lowDeltaAsync(() => hit(bob.token), () => hit(alice.token), bound, "GET /api/status");
|
|
162
195
|
expect(r.delta).toBeLessThanOrEqual(bound(r.a));
|
|
163
196
|
}, PERF_TIMEOUT_MS);
|
|
164
197
|
|
package/src/db.ts
CHANGED
|
@@ -1596,6 +1596,12 @@ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN misfire_policy TEXT NOT NU
|
|
|
1596
1596
|
// whether the node had ever picked it up: not_received | in_progress.
|
|
1597
1597
|
try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_task_id TEXT"); } catch {}
|
|
1598
1598
|
try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_state TEXT"); } catch {}
|
|
1599
|
+
// #523 — consecutive-failure alert bookkeeping (scheduled-failures.ts). Additive and nullable:
|
|
1600
|
+
// an older Hub never reads them, so rolling back is safe. failure_alert_at = ISO time of the last
|
|
1601
|
+
// alert; failure_alert_key = the last successful run_id at that moment ('' = none yet), so a
|
|
1602
|
+
// success in between re-arms the alert.
|
|
1603
|
+
try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN failure_alert_at TEXT"); } catch {}
|
|
1604
|
+
try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN failure_alert_key TEXT"); } catch {}
|
|
1599
1605
|
|
|
1600
1606
|
// RFC-036 / B4 — owner-authorized edits of node-host managed schedules.
|
|
1601
1607
|
// This is intentionally separate from Hub scheduled_tasks: these rows are
|
|
@@ -1986,6 +1992,18 @@ export function chainReplyToParent(
|
|
|
1986
1992
|
|
|
1987
1993
|
const SCHEDULED_RUN_TERMINAL_STATUSES = new Set(["replied", "failed", "cancelled", "expired"]);
|
|
1988
1994
|
|
|
1995
|
+
// #523 — called after a run row is closed by its task's terminal state. Registered by
|
|
1996
|
+
// scheduled-failures.ts (a hook, not an import: that module depends on db.ts). Never throws.
|
|
1997
|
+
type ScheduledRunTerminalHook = (scheduleId: string, status: string) => void;
|
|
1998
|
+
let scheduledRunTerminalHook: ScheduledRunTerminalHook | null = null;
|
|
1999
|
+
export function setScheduledRunTerminalHook(fn: ScheduledRunTerminalHook | null): void { scheduledRunTerminalHook = fn; }
|
|
2000
|
+
function fireScheduledRunTerminalHook(scheduleId: string, status: string): void {
|
|
2001
|
+
if (!scheduledRunTerminalHook) return;
|
|
2002
|
+
try { scheduledRunTerminalHook(scheduleId, status); } catch (e: any) {
|
|
2003
|
+
console.error(`[scheduled-tasks] run terminal hook failed schedule=${scheduleId}: ${e?.message || e}`);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
1989
2007
|
type ScheduledTaskLifecycleRow = {
|
|
1990
2008
|
task_id: string;
|
|
1991
2009
|
network_id: string | null;
|
|
@@ -2041,6 +2059,7 @@ export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: stri
|
|
|
2041
2059
|
WHERE run_id = ?4 AND task_id = ?5 AND network_id = ?6 AND schedule_id = ?7`,
|
|
2042
2060
|
[task.status, errorCode, task.completed_at, binding.run_id, task.task_id, task.network_id, binding.schedule_id],
|
|
2043
2061
|
);
|
|
2062
|
+
if (updated.changes === 1) fireScheduledRunTerminalHook(binding.schedule_id, task.status);
|
|
2044
2063
|
return { matched: updated.changes === 1, status: task.status };
|
|
2045
2064
|
}
|
|
2046
2065
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// #550 — the sticky session capability flags (rules_file_capable / skills_capable /
|
|
2
|
+
// files_capable / logs_capable) survive a session handover.
|
|
3
|
+
//
|
|
4
|
+
// report_status for an alias that already has a row under ANOTHER resume_id takes
|
|
5
|
+
// the DELETE + INSERT handover path. That path carried registered_at and the
|
|
6
|
+
// descriptive columns, but not the capability flags, so the new row started at
|
|
7
|
+
// the column default 0 — although the flags are meant to be sticky (only ever set
|
|
8
|
+
// to 1). Board #548: a production node's MCP channel reconnected under a new
|
|
9
|
+
// resume_id and the rules-file / skills / files / logs doorbells went dark.
|
|
10
|
+
//
|
|
11
|
+
// Pinned through the real HTTP entrance (POST /mcp with the node's own token):
|
|
12
|
+
// 1. flags set on resume A are still 1 after the same alias reports resume B
|
|
13
|
+
// without them — and after it switches back to A.
|
|
14
|
+
// 2. only flags that were 1 are carried; a 0 is never promoted.
|
|
15
|
+
// 3. a later report omitting the flags on the same resume_id still keeps them.
|
|
16
|
+
// 4. a user token cannot report for the alias, so it cannot hand the flags to a
|
|
17
|
+
// session of its own.
|
|
18
|
+
//
|
|
19
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/report-status-handover-capabilities-http.test.ts
|
|
20
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
22
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
26
|
+
import { db } from "./db.js";
|
|
27
|
+
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-handover-caps-"));
|
|
29
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
30
|
+
if (!activeDbPath) throw new Error("report-status-handover-capabilities requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
31
|
+
|
|
32
|
+
const stamp = Date.now();
|
|
33
|
+
let server: any;
|
|
34
|
+
let base = "";
|
|
35
|
+
let ownerToken = "", ownerId = "", NET = "";
|
|
36
|
+
type Node = { alias: string; nodeId: string; token: string };
|
|
37
|
+
const nodes: Record<string, Node> = {};
|
|
38
|
+
|
|
39
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
40
|
+
const res = await fetch(`${base}/mcp`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
43
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
44
|
+
});
|
|
45
|
+
const raw = await res.text();
|
|
46
|
+
const data = raw.split("\n").filter((x) => x.startsWith("data:"));
|
|
47
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
48
|
+
const text = payload.result.content[0].text;
|
|
49
|
+
if (!text.startsWith("{")) throw new Error(`${name}: ${text}`);
|
|
50
|
+
return JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// resume_id is the sessions primary key — every alias below uses its own (cc-*-<nodeId>).
|
|
54
|
+
const ALL_FLAGS = { rules_file_capable: true, skills_capable: true, files_capable: true, logs_capable: true };
|
|
55
|
+
const report = (token: string, n: Node, resume: string, extra: Record<string, unknown> = {}) =>
|
|
56
|
+
tool(token, "report_status", { resume_id: resume, alias: n.alias, status: "idle", agent: "claude-code", network_id: NET, ...extra });
|
|
57
|
+
|
|
58
|
+
type CapRow = { resume_id: string; rules_file_capable: number; skills_capable: number; files_capable: number; logs_capable: number };
|
|
59
|
+
const capRows = (alias: string) =>
|
|
60
|
+
db.all<CapRow>(
|
|
61
|
+
"SELECT resume_id, rules_file_capable, skills_capable, files_capable, logs_capable FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
62
|
+
alias, NET,
|
|
63
|
+
).map((r) => ({
|
|
64
|
+
resume_id: r.resume_id,
|
|
65
|
+
rules_file_capable: Number(r.rules_file_capable), skills_capable: Number(r.skills_capable),
|
|
66
|
+
files_capable: Number(r.files_capable), logs_capable: Number(r.logs_capable),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
beforeAll(async () => {
|
|
70
|
+
process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
|
|
71
|
+
process.env.HOST = "127.0.0.1";
|
|
72
|
+
const owner = register(`hcap_owner_${stamp}`, "HandoverCaps123!", undefined, "seed");
|
|
73
|
+
ownerToken = owner.token!; ownerId = owner.user!.user_id; NET = owner.network_id!;
|
|
74
|
+
for (const key of ["all", "some", "user"]) {
|
|
75
|
+
const alias = `hcap-${key}-${stamp}`;
|
|
76
|
+
const nodeId = `n_hcap_${key}_${stamp}`;
|
|
77
|
+
const minted = createNetworkTokenForNode(ownerId, NET, alias, nodeId);
|
|
78
|
+
expect(minted.ok).toBe(true);
|
|
79
|
+
nodes[key] = { alias, nodeId, token: minted.token! };
|
|
80
|
+
}
|
|
81
|
+
const mod: any = await import("./server.js");
|
|
82
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
83
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
84
|
+
}, 30_000);
|
|
85
|
+
|
|
86
|
+
afterAll(() => {
|
|
87
|
+
try { server?.stop?.(true); } catch {}
|
|
88
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("#550 sticky capability flags survive a resume_id handover", () => {
|
|
92
|
+
test("all four flags carry to the new resume_id, and back again", async () => {
|
|
93
|
+
const n = nodes.all!;
|
|
94
|
+
expect((await report(n.token, n, `cc-a-${n.nodeId}`, ALL_FLAGS)).ok).toBe(true);
|
|
95
|
+
expect(capRows(n.alias)).toEqual([{ resume_id: `cc-a-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
|
|
96
|
+
|
|
97
|
+
// The channel reconnects under a new resume_id and reports WITHOUT the flags.
|
|
98
|
+
expect((await report(n.token, n, `cc-b-${n.nodeId}`)).ok).toBe(true);
|
|
99
|
+
expect(capRows(n.alias)).toEqual([{ resume_id: `cc-b-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
|
|
100
|
+
|
|
101
|
+
// Same resume_id, flags omitted again (status-only report) — still sticky.
|
|
102
|
+
expect((await report(n.token, n, `cc-b-${n.nodeId}`)).ok).toBe(true);
|
|
103
|
+
expect(capRows(n.alias)[0]).toMatchObject({ rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 });
|
|
104
|
+
|
|
105
|
+
// Two reporters taking turns (agent-node + TUI MCP): switching back keeps them too.
|
|
106
|
+
expect((await report(n.token, n, `cc-a-${n.nodeId}`)).ok).toBe(true);
|
|
107
|
+
expect(capRows(n.alias)).toEqual([{ resume_id: `cc-a-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
|
|
108
|
+
}, 20_000);
|
|
109
|
+
|
|
110
|
+
test("only flags that were 1 are carried — a 0 is never promoted", async () => {
|
|
111
|
+
const n = nodes.some!;
|
|
112
|
+
expect((await report(n.token, n, `cc-a-${n.nodeId}`, { rules_file_capable: true, logs_capable: true })).ok).toBe(true);
|
|
113
|
+
expect((await report(n.token, n, `cc-b-${n.nodeId}`)).ok).toBe(true);
|
|
114
|
+
expect(capRows(n.alias)).toEqual([{ resume_id: `cc-b-${n.nodeId}`, rules_file_capable: 1, skills_capable: 0, files_capable: 0, logs_capable: 1 }]);
|
|
115
|
+
}, 20_000);
|
|
116
|
+
|
|
117
|
+
test("a user token cannot report for the alias, so it cannot take over the flags", async () => {
|
|
118
|
+
const n = nodes.user!;
|
|
119
|
+
expect((await report(n.token, n, `cc-a-${n.nodeId}`, ALL_FLAGS)).ok).toBe(true);
|
|
120
|
+
const r = await report(ownerToken, n, `cc-hijack-${n.nodeId}`);
|
|
121
|
+
expect(r.ok).toBe(false);
|
|
122
|
+
expect(capRows(n.alias)).toEqual([{ resume_id: `cc-a-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
|
|
123
|
+
}, 20_000);
|
|
124
|
+
});
|
|
@@ -19,6 +19,9 @@ process.env.HOST = "127.0.0.1";
|
|
|
19
19
|
delete process.env.COMMHUB_DUE_REMINDER_TZ;
|
|
20
20
|
delete process.env.COMMHUB_DUE_OVERDUE_MAX_DAYS;
|
|
21
21
|
delete process.env.COMMHUB_DUE_REMINDER_NETWORKS;
|
|
22
|
+
delete process.env.COMMHUB_DUE_REMINDERS;
|
|
23
|
+
delete process.env.COMMHUB_DUE_REMINDERS_NETWORKS;
|
|
24
|
+
delete process.env.COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS;
|
|
22
25
|
// 节点提醒默认关;这个文件的大部分用例要看节点那一路,先打开(「默认关」单独一个用例)。
|
|
23
26
|
process.env.COMMHUB_DUE_REMINDER_NODES = "1";
|
|
24
27
|
const PW = "DueRemindPassw0rd!x";
|
|
@@ -335,6 +338,131 @@ describe("上线护栏", () => {
|
|
|
335
338
|
expect(sentFor(due.runDueReminders({ now: T0 + 3600_000 }), old)).toEqual([]);
|
|
336
339
|
} finally { delete process.env.COMMHUB_DUE_REMINDER_NETWORKS; }
|
|
337
340
|
});
|
|
341
|
+
|
|
342
|
+
// ── #524 按网络开关 ────────────────────────────────────
|
|
343
|
+
const SCOPE_VARS = ["COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDER_NETWORKS"];
|
|
344
|
+
async function withEnv<T>(vars: Record<string, string>, fn: () => T | Promise<T>): Promise<T> {
|
|
345
|
+
const saved = Object.fromEntries(SCOPE_VARS.map((k) => [k, process.env[k]]));
|
|
346
|
+
for (const k of SCOPE_VARS) delete process.env[k];
|
|
347
|
+
Object.assign(process.env, vars);
|
|
348
|
+
try { return await fn(); } finally {
|
|
349
|
+
for (const k of SCOPE_VARS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]!; }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const baselineCount = (net: string) => db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE kind = 'baseline' AND network_id = ?1", net).n;
|
|
353
|
+
const rowCount = (id: string) => db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE requirement_id = ?1", id).n;
|
|
354
|
+
|
|
355
|
+
test("#524 范围解析:各种组合 / 空白值 / 只绑本分支用到的参数", async () => {
|
|
356
|
+
const S = (vars: Record<string, string>) => withEnv(vars, () => due.dueReminderScope());
|
|
357
|
+
// 都不设 = 原样:所有网络
|
|
358
|
+
expect(await S({})).toEqual({ mode: "all", exclude: [] });
|
|
359
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "1" })).toEqual({ mode: "all", exclude: [] });
|
|
360
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0" })).toEqual({ mode: "off" });
|
|
361
|
+
// 旧名白名单不能在 =0 时打开(行为不变)
|
|
362
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDER_NETWORKS: "net_a" })).toEqual({ mode: "off" });
|
|
363
|
+
expect(await S({ COMMHUB_DUE_REMINDER_NETWORKS: "net_a" })).toEqual({ mode: "only", include: ["net_a"], exclude: [] });
|
|
364
|
+
// 新名白名单:=0 时也打开,只给名单里的
|
|
365
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: " net_a , net_b,net_a, " })).toEqual({ mode: "only", include: ["net_a", "net_b"], exclude: [] });
|
|
366
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_NETWORKS: "net_a" })).toEqual({ mode: "only", include: ["net_a"], exclude: [] });
|
|
367
|
+
// 黑名单
|
|
368
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x" })).toEqual({ mode: "all", exclude: ["net_x"] });
|
|
369
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x" })).toEqual({ mode: "off" });
|
|
370
|
+
expect(await S({ COMMHUB_DUE_REMINDERS_NETWORKS: "net_a,net_x", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x" })).toEqual({ mode: "only", include: ["net_a"], exclude: ["net_x"] });
|
|
371
|
+
// 空 / 空白 / 只有逗号 = 没设
|
|
372
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: "" })).toEqual({ mode: "off" });
|
|
373
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: " , , " })).toEqual({ mode: "off" });
|
|
374
|
+
expect(await S({ COMMHUB_DUE_REMINDERS_NETWORKS: " ", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: " , " })).toEqual({ mode: "all", exclude: [] });
|
|
375
|
+
// SQL:每个分支只 push 自己用到的参数
|
|
376
|
+
const p1: unknown[] = ["lo", "hi"];
|
|
377
|
+
expect(due.dueScopeSql({ mode: "all", exclude: [] }, p1)).toBe("");
|
|
378
|
+
expect(p1).toEqual(["lo", "hi"]);
|
|
379
|
+
const p2: unknown[] = ["lo", "hi"];
|
|
380
|
+
expect(due.dueScopeSql({ mode: "all", exclude: ["net_x"] }, p2)).toBe(" AND network_id NOT IN (?3)");
|
|
381
|
+
expect(p2).toEqual(["lo", "hi", "net_x"]);
|
|
382
|
+
const p3: unknown[] = ["lo", "hi"];
|
|
383
|
+
expect(due.dueScopeSql({ mode: "only", include: ["net_a", "net_b"], exclude: [] }, p3)).toBe(" AND network_id IN (?3, ?4)");
|
|
384
|
+
expect(p3).toEqual(["lo", "hi", "net_a", "net_b"]);
|
|
385
|
+
const p4: unknown[] = [];
|
|
386
|
+
expect(due.dueScopeSql({ mode: "only", include: [], exclude: ["net_x"] }, p4)).toBeNull();
|
|
387
|
+
expect(due.dueScopeSql({ mode: "off" }, p4)).toBeNull();
|
|
388
|
+
expect(p4).toEqual([]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("#524 COMMHUB_DUE_REMINDERS=0 + 白名单:只有白名单网络收到;别的网络的行连基线都不写", async () => {
|
|
392
|
+
const other = mkNet("allow_other");
|
|
393
|
+
const mine = await card("dr-524-白名单内", "2026-10-11");
|
|
394
|
+
const theirs = await cardIn(other, "dr-524-白名单外", "2026-10-11");
|
|
395
|
+
const out = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: ` ${NET} ,` }, () => due.runDueReminders({ now: T0 }));
|
|
396
|
+
expect(sentFor(out, mine)).toEqual(["due_soon"]);
|
|
397
|
+
expect(userNotices(mine)[owen.id]).toHaveLength(1);
|
|
398
|
+
expect(sentFor(out, theirs)).toEqual([]);
|
|
399
|
+
expect(rowCount(theirs)).toBe(0);
|
|
400
|
+
expect(baselineCount(other.net)).toBe(0);
|
|
401
|
+
// 对照:=0 不带白名单 → 整个关掉,谁都不发
|
|
402
|
+
const fresh = await card("dr-524-全关", "2026-10-11");
|
|
403
|
+
const off = await withEnv({ COMMHUB_DUE_REMINDERS: "0" }, () => due.runDueReminders({ now: T0 }));
|
|
404
|
+
expect(off).toEqual([]);
|
|
405
|
+
expect(rowCount(fresh)).toBe(0);
|
|
406
|
+
// 白名单空白 = 没设 → 仍然关
|
|
407
|
+
expect(await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: " , " }, () => due.runDueReminders({ now: T0 }))).toEqual([]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("#524 黑名单:功能开着时被排除的网络不发、不写基线;其余照发", async () => {
|
|
411
|
+
const other = mkNet("excl_other");
|
|
412
|
+
const mine = await card("dr-524-未排除", "2026-10-11");
|
|
413
|
+
const theirs = await cardIn(other, "dr-524-被排除", "2026-10-11");
|
|
414
|
+
const out = await withEnv({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: other.net }, () => due.runDueReminders({ now: T0 }));
|
|
415
|
+
expect(sentFor(out, mine)).toEqual(["due_soon"]);
|
|
416
|
+
expect(sentFor(out, theirs)).toEqual([]);
|
|
417
|
+
expect(rowCount(theirs)).toBe(0);
|
|
418
|
+
expect(baselineCount(other.net)).toBe(0);
|
|
419
|
+
// 白名单里也点了名 → 排除优先;白名单只剩它 → 什么都不扫
|
|
420
|
+
const both = mkNet("excl_both");
|
|
421
|
+
const c = await cardIn(both, "dr-524-白名单又被排除", "2026-10-11");
|
|
422
|
+
const out2 = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: both.net, COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: both.net }, () => due.runDueReminders({ now: T0 }));
|
|
423
|
+
expect(out2).toEqual([]);
|
|
424
|
+
expect(rowCount(c)).toBe(0);
|
|
425
|
+
expect(baselineCount(both.net)).toBe(0);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("#524 默认不变:两个新变量都不设 → 所有网络照发;黑名单空白 = 没设", async () => {
|
|
429
|
+
const other = mkNet("dflt_other");
|
|
430
|
+
const mine = await card("dr-524-默认", "2026-10-11");
|
|
431
|
+
const theirs = await cardIn(other, "dr-524-默认别的网络", "2026-10-11");
|
|
432
|
+
const out = await withEnv({}, () => due.runDueReminders({ now: T0 }));
|
|
433
|
+
expect(sentFor(out, mine)).toEqual(["due_soon"]);
|
|
434
|
+
expect(sentFor(out, theirs)).toEqual(["due_soon"]);
|
|
435
|
+
const other2 = mkNet("dflt_blank");
|
|
436
|
+
const theirs2 = await cardIn(other2, "dr-524-黑名单空白", "2026-10-11");
|
|
437
|
+
const out2 = await withEnv({ COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: " " }, () => due.runDueReminders({ now: T0 }));
|
|
438
|
+
expect(sentFor(out2, theirs2)).toEqual(["due_soon"]);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("#524 启动:=0 不起定时器;=0 + 白名单起;各打一行范围日志(只有网络 id)", async () => {
|
|
442
|
+
const logs: string[] = [];
|
|
443
|
+
const g = globalThis as any;
|
|
444
|
+
const orig = { log: console.log, setTimeout: g.setTimeout, setInterval: g.setInterval };
|
|
445
|
+
// 不起真定时器(否则 30 秒后会用真时钟扫一遍,干扰后面的用例):只记下调了几次。
|
|
446
|
+
let timers = 0;
|
|
447
|
+
console.log = (...a: unknown[]) => { logs.push(a.map(String).join(" ")); };
|
|
448
|
+
g.setTimeout = () => { timers++; return { unref() {} }; };
|
|
449
|
+
g.setInterval = () => { timers++; return { fake: true }; };
|
|
450
|
+
try {
|
|
451
|
+
const off = await withEnv({ COMMHUB_DUE_REMINDERS: "0" }, () => due.startDueReminderTimer());
|
|
452
|
+
expect(off).toBeNull();
|
|
453
|
+
expect(timers).toBe(0);
|
|
454
|
+
const on = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: "net_placeholder_a" }, () => due.startDueReminderTimer());
|
|
455
|
+
expect(on).toEqual({ fake: true } as any);
|
|
456
|
+
expect(timers).toBe(2);
|
|
457
|
+
await withEnv({ COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_placeholder_x" }, () => due.startDueReminderTimer());
|
|
458
|
+
} finally { console.log = orig.log; g.setTimeout = orig.setTimeout; g.setInterval = orig.setInterval; }
|
|
459
|
+
const scopeLogs = logs.filter((l) => l.startsWith("[due-reminders] scope:"));
|
|
460
|
+
expect(scopeLogs).toEqual([
|
|
461
|
+
"[due-reminders] scope: off",
|
|
462
|
+
"[due-reminders] scope: only networks net_placeholder_a",
|
|
463
|
+
"[due-reminders] scope: all networks except net_placeholder_x",
|
|
464
|
+
]);
|
|
465
|
+
});
|
|
338
466
|
});
|
|
339
467
|
|
|
340
468
|
describe("#494 列表筛选", () => {
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
//
|
|
22
22
|
// 上线护栏(生产上有别的团队的网络和节点):
|
|
23
23
|
// COMMHUB_DUE_REMINDER_NETWORKS=<id,id,…> 只给这些网络发;不设 = 所有网络(自建 Hub 默认就有这个功能)。
|
|
24
|
+
// #524 按网络开关(一个 Hub 上多个团队的网络,只想给自己的网络开):
|
|
25
|
+
// COMMHUB_DUE_REMINDERS_NETWORKS=<id,id,…> 白名单。COMMHUB_DUE_REMINDERS=0 时也生效:只给这些网络发。
|
|
26
|
+
// COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS=<id,id,…> 黑名单。功能开着时这些网络不发(白名单里也排除)。
|
|
27
|
+
// 两个新变量都不设 → 行为与之前完全相同。生效范围在 startHub 时打一行日志(只有网络 id)。
|
|
28
|
+
// 过滤写在扫描 SQL 的 WHERE 里(基线 INSERT 与候选 SELECT 同一个条件),范围外网络的行根本不会被读出来。
|
|
24
29
|
// COMMHUB_DUE_REMINDER_NODES=1 给负责 Agent 节点发消息;默认关(人照常提醒)。
|
|
25
30
|
// 开通时不补发:每个网络第一次被扫描到时记一行基线(kind='baseline',sent_at = 那一刻)。只有在基线**之后**才变成逾期的卡
|
|
26
31
|
// 会收到「已逾期」提醒;基线之前就已经逾期的卡永远不发逾期提醒(打开功能 / 把网络加进白名单都不会刷一批旧卡)。
|
|
@@ -72,13 +77,60 @@ export function dueReminderTimezone(): string {
|
|
|
72
77
|
const tz = process.env.COMMHUB_DUE_REMINDER_TZ?.trim() || "Asia/Shanghai";
|
|
73
78
|
try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); return tz; } catch { return "Asia/Shanghai"; }
|
|
74
79
|
}
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
const raw = process.env
|
|
80
|
+
/** 逗号分隔的网络 id;没设(或只有空白 / 逗号)→ null。去重,保持顺序。 */
|
|
81
|
+
function envIdList(name: string): string[] | null {
|
|
82
|
+
const raw = process.env[name];
|
|
78
83
|
if (raw === undefined) return null;
|
|
79
|
-
const ids = raw.split(",").map((x) => x.trim()).filter(Boolean);
|
|
84
|
+
const ids = [...new Set(raw.split(",").map((x) => x.trim()).filter(Boolean))];
|
|
80
85
|
return ids.length ? ids : null;
|
|
81
86
|
}
|
|
87
|
+
/** COMMHUB_DUE_REMINDER_NETWORKS:逗号分隔的网络 id;没设(或只有空白)→ null = 所有网络。 */
|
|
88
|
+
export function reminderNetworks(): string[] | null {
|
|
89
|
+
return envIdList("COMMHUB_DUE_REMINDER_NETWORKS");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* #524 生效范围。
|
|
94
|
+
* off —— 不扫描(COMMHUB_DUE_REMINDERS=0 且没设 COMMHUB_DUE_REMINDERS_NETWORKS)。
|
|
95
|
+
* all —— 所有网络,减去 exclude。
|
|
96
|
+
* only —— 只扫 include(已减去 exclude;可能为空 = 什么都不扫)。
|
|
97
|
+
* 白名单 = COMMHUB_DUE_REMINDERS_NETWORKS ∪ COMMHUB_DUE_REMINDER_NETWORKS(旧名);但只有新名能在 COMMHUB_DUE_REMINDERS=0 时打开。
|
|
98
|
+
* 白名单一旦设了就收窄(包括 COMMHUB_DUE_REMINDERS=1):宁可少发,不给没点名的网络发。
|
|
99
|
+
*/
|
|
100
|
+
export type DueReminderScope =
|
|
101
|
+
| { mode: "off" }
|
|
102
|
+
| { mode: "all"; exclude: string[] }
|
|
103
|
+
| { mode: "only"; include: string[]; exclude: string[] };
|
|
104
|
+
|
|
105
|
+
export function dueReminderScope(): DueReminderScope {
|
|
106
|
+
const allow = envIdList("COMMHUB_DUE_REMINDERS_NETWORKS");
|
|
107
|
+
const legacy = envIdList("COMMHUB_DUE_REMINDER_NETWORKS");
|
|
108
|
+
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 };
|
|
111
|
+
const include = [...new Set([...(allow ?? []), ...(legacy ?? [])])].filter((id) => !exclude.includes(id));
|
|
112
|
+
return { mode: "only", include, exclude };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function describeDueReminderScope(scope: DueReminderScope = dueReminderScope()): string {
|
|
116
|
+
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)";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 范围条件(拼在 WHERE 后面)。只 push 这个分支真正用到的参数(PG 预编译拒绝绑了没用的参数)。
|
|
123
|
+
* null = 范围为空,调用方不该查询。
|
|
124
|
+
*/
|
|
125
|
+
export function dueScopeSql(scope: DueReminderScope, params: unknown[]): string | null {
|
|
126
|
+
if (scope.mode === "off") return null;
|
|
127
|
+
if (scope.mode === "only") {
|
|
128
|
+
if (!scope.include.length) return null;
|
|
129
|
+
return ` AND network_id IN (${scope.include.map((id) => `?${params.push(id)}`).join(", ")})`;
|
|
130
|
+
}
|
|
131
|
+
if (!scope.exclude.length) return "";
|
|
132
|
+
return ` AND network_id NOT IN (${scope.exclude.map((id) => `?${params.push(id)}`).join(", ")})`;
|
|
133
|
+
}
|
|
82
134
|
export function nodeRemindersEnabled(): boolean { return process.env.COMMHUB_DUE_REMINDER_NODES === "1"; }
|
|
83
135
|
export const BASELINE_KIND = "baseline";
|
|
84
136
|
|
|
@@ -213,13 +265,15 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
|
|
|
213
265
|
// 范围:最老 = 今天 - maxOverdue - 1(UTC 日期 ≤ 本地日期,多留一天);最新 < 今天 + 2(24 小时内的时刻型 UTC 日期 ≤ 明天)。
|
|
214
266
|
const lo = addDays(today, -maxOverdue - 1);
|
|
215
267
|
const hi = addDays(today, 2);
|
|
216
|
-
const
|
|
268
|
+
const scope = dueReminderScope();
|
|
269
|
+
// 范围为空(关闭 / 白名单全被排除)→ 什么都不读、不写基线。
|
|
270
|
+
if (dueScopeSql(scope, []) === null) return [];
|
|
217
271
|
const sentAt = new Date(nowMs).toISOString();
|
|
218
272
|
// 基线:范围内每个网络第一次被扫到的那一刻(已有就不动)。一条 INSERT … SELECT。
|
|
219
273
|
{
|
|
220
274
|
const bp: unknown[] = [BASELINE_KIND, sentAt];
|
|
221
275
|
// WHERE 必须有:SQLite 里「INSERT … SELECT … FROM t ON CONFLICT」会把 ON 读成 JOIN 约束(语法错),要一个 WHERE 隔开。
|
|
222
|
-
const where = ` WHERE 1=1${
|
|
276
|
+
const where = ` WHERE 1=1${dueScopeSql(scope, bp) ?? ""}`;
|
|
223
277
|
db.run(
|
|
224
278
|
`INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
|
|
225
279
|
SELECT '__baseline__:' || network_id, ?1, '', '', network_id, ?2 FROM networks${where}
|
|
@@ -233,7 +287,7 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
|
|
|
233
287
|
.filter((e): e is readonly [string, number] => e[1] !== null),
|
|
234
288
|
);
|
|
235
289
|
const params: unknown[] = [lo, hi];
|
|
236
|
-
const netFilter =
|
|
290
|
+
const netFilter = dueScopeSql(scope, params) ?? "";
|
|
237
291
|
const rows = db.all<DueRow>(
|
|
238
292
|
`SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
|
|
239
293
|
FROM requirements
|
|
@@ -299,9 +353,14 @@ function deliver(row: DueRow, r: { kind: DueKind; overdueDays: number; dueDay: s
|
|
|
299
353
|
return { requirement_id: row.requirement_id, kind: r.kind as DueKind, users, node: notifiedNode };
|
|
300
354
|
}
|
|
301
355
|
|
|
302
|
-
/**
|
|
356
|
+
/**
|
|
357
|
+
* startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉(除非设了 COMMHUB_DUE_REMINDERS_NETWORKS,见 dueReminderScope);
|
|
358
|
+
* bootServer(测试)不调用它。第一次扫描延后一个间隔外的 30 秒,不和启动抢。启动时打一行生效范围(只有网络 id)。
|
|
359
|
+
*/
|
|
303
360
|
export function startDueReminderTimer(): ReturnType<typeof setInterval> | null {
|
|
304
|
-
|
|
361
|
+
const scope = dueReminderScope();
|
|
362
|
+
console.log(`[due-reminders] scope: ${describeDueReminderScope(scope)}`);
|
|
363
|
+
if (scope.mode === "off") return null;
|
|
305
364
|
const ms = envNumber("COMMHUB_DUE_REMINDER_TICK_MS", 10 * 60_000, 1_000);
|
|
306
365
|
const tick = () => { try { runDueReminders(); } catch (e: any) { console.error(`[due-reminders] tick failed: ${e?.message || e}`); } };
|
|
307
366
|
const first = setTimeout(tick, Math.min(ms, 30_000));
|