@sleep2agi/commhub-server 0.9.0-preview.93 → 0.9.0-preview.95

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.
@@ -0,0 +1,310 @@
1
+ // #491 / #492 —— 任务到期提醒(Hub 侧)。
2
+ //
3
+ // 每 COMMHUB_DUE_REMINDER_TICK_MS(默认 10 分钟)扫一次「没完成、没归档、有预计完成时间」的卡,按下面三种发提醒:
4
+ // due_soon 即将到期:日期型 due = 明天;时刻型 due 在 24 小时内(且不是今天)。每个 due 值一次。
5
+ // due_today 今天到期:日期型 due = 今天;时刻型 due 落在今天且还没到。每个 due 值一次。
6
+ // overdue 已逾期 N 天:每个自然日最多一次;逾期超过 COMMHUB_DUE_OVERDUE_MAX_DAYS(默认 7)天后不再提醒
7
+ // (上线当天不会把几个月前的旧卡一次性刷一遍,也不会天天提醒一张早被遗忘的卡)。
8
+ // 「今天 / 明天 / 自然日」按 COMMHUB_DUE_REMINDER_TZ(默认 Asia/Shanghai)算。网络没有时区设置;due 的存法不变:
9
+ // 日期型原样「YYYY-MM-DD」(requirements.ts normalizeDue:全天,客户端按查看者本地理解),时刻型是 UTC 秒。
10
+ //
11
+ // 收件人:
12
+ // 人 —— 负责人(owner)+ 参与人里的人,去重。走 #462/#464 的 Hub 通知路(agent-notice.ts:user_inbox +
13
+ // /events/users/me desktop_message)。发信别名 = 负责 Agent 的别名(收件人看得见它时 → 出现在与它的会话里),
14
+ // 否则 DUE_REMINDER_SENDER「任务提醒」。
15
+ // 负责 Agent(agent_owner 节点)—— 一条不需要回复的消息:与 send_message 同一写法(inbox type='message' +
16
+ // new_message 推送),不是任务。节点不在 active 状态(停了 / 删除中)就不发。
17
+ // 停止:卡完成 / 归档 / 清空 due → 扫描查询本身就选不到它;due 往后挪 → 那一档不再成立,而新 due 值重新上膛。
18
+ // 去重:requirement_due_reminders 主键 (requirement_id, kind, due_on, day),先 INSERT … ON CONFLICT DO NOTHING
19
+ // 抢到这一行(changes = 1)才发 —— 重启、多次扫描、并发都不会重发。宁可漏一次(发送中途崩溃),不发两次。
20
+ // 只加表:旧 Hub 回滚后不认识这张表也不碰它。
21
+ //
22
+ // 上线护栏(生产上有别的团队的网络和节点):
23
+ // COMMHUB_DUE_REMINDER_NETWORKS=<id,id,…> 只给这些网络发;不设 = 所有网络(自建 Hub 默认就有这个功能)。
24
+ // COMMHUB_DUE_REMINDER_NODES=1 给负责 Agent 节点发消息;默认关(人照常提醒)。
25
+ // 开通时不补发:每个网络第一次被扫描到时记一行基线(kind='baseline',sent_at = 那一刻)。只有在基线**之后**才变成逾期的卡
26
+ // 会收到「已逾期」提醒;基线之前就已经逾期的卡永远不发逾期提醒(打开功能 / 把网络加进白名单都不会刷一批旧卡)。
27
+ // 选这个而不是「把旧卡逐张记成已发」:不用每卡每天写行,也不怕某张旧卡第一次没被扫到、第二天又冒出来。
28
+ // 即将到期 / 今天到期不受基线影响(它们本来就只在临近时发一次)。
29
+
30
+ import type { DbAdapter } from "./db-adapter.js";
31
+ import { db, uuidv4 } from "./db.js";
32
+ import { sendAgentNotice } from "./agent-notice.js";
33
+ import { canSeeAgent } from "./agent-access.js";
34
+ import { assertNodeActive } from "./lifecycle-guard.js";
35
+ import { pushEvent } from "./push.js";
36
+ import { pendingInboxCount } from "./inbox-count.js";
37
+ import { parseHubTimestamp } from "./hub-timestamp.js";
38
+
39
+ export const DUE_REMINDER_KIND = "task_due";
40
+ export const DUE_REMINDER_SENDER = "任务提醒";
41
+ export type DueKind = "due_soon" | "due_today" | "overdue";
42
+
43
+ const DAY_MS = 86_400_000;
44
+ const RETENTION_DAYS = 60;
45
+
46
+ /** IF NOT EXISTS:重复执行无副作用;SQLite / PostgreSQL 同一段。 */
47
+ export function ensureDueReminders(database: DbAdapter): void {
48
+ database.exec(`
49
+ CREATE TABLE IF NOT EXISTS requirement_due_reminders (
50
+ requirement_id TEXT NOT NULL,
51
+ kind TEXT NOT NULL,
52
+ due_on TEXT NOT NULL,
53
+ day TEXT NOT NULL,
54
+ network_id TEXT NOT NULL,
55
+ sent_at TEXT NOT NULL,
56
+ PRIMARY KEY (requirement_id, kind, due_on, day)
57
+ );
58
+ `);
59
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_due_reminders_sent ON requirement_due_reminders(sent_at)");
60
+ // 扫描查询按 due_on 取一段范围:只给有 due 的行建索引。
61
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_due_on ON requirements(due_on) WHERE due_on IS NOT NULL");
62
+ }
63
+
64
+ function envNumber(name: string, fallback: number, min: number): number {
65
+ const raw = process.env[name];
66
+ if (raw === undefined || raw.trim() === "") return fallback;
67
+ const n = Number(raw);
68
+ return Number.isFinite(n) && n >= min ? n : fallback;
69
+ }
70
+
71
+ export function dueReminderTimezone(): string {
72
+ const tz = process.env.COMMHUB_DUE_REMINDER_TZ?.trim() || "Asia/Shanghai";
73
+ try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); return tz; } catch { return "Asia/Shanghai"; }
74
+ }
75
+ /** COMMHUB_DUE_REMINDER_NETWORKS:逗号分隔的网络 id;没设(或只有空白)→ null = 所有网络。 */
76
+ export function reminderNetworks(): string[] | null {
77
+ const raw = process.env.COMMHUB_DUE_REMINDER_NETWORKS;
78
+ if (raw === undefined) return null;
79
+ const ids = raw.split(",").map((x) => x.trim()).filter(Boolean);
80
+ return ids.length ? ids : null;
81
+ }
82
+ export function nodeRemindersEnabled(): boolean { return process.env.COMMHUB_DUE_REMINDER_NODES === "1"; }
83
+ export const BASELINE_KIND = "baseline";
84
+
85
+ export function overdueMaxDays(): number { return Math.floor(envNumber("COMMHUB_DUE_OVERDUE_MAX_DAYS", 7, 0)); }
86
+
87
+ // ── 时区小工具 ─────────────────────────────────────────────
88
+ /** 某一时刻在 tz 的日期「YYYY-MM-DD」。 */
89
+ export function localDate(ms: number, tz: string): string {
90
+ return new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(ms));
91
+ }
92
+ /** 日期 + n 天(纯日历运算,与时区无关)。 */
93
+ export function addDays(date: string, n: number): string {
94
+ const [y, m, d] = date.split("-").map(Number);
95
+ return new Date(Date.UTC(y, m - 1, d) + n * DAY_MS).toISOString().slice(0, 10);
96
+ }
97
+ /** 两个日期相差几天(a - b)。 */
98
+ export function dayDiff(a: string, b: string): number {
99
+ const ms = (s: string) => { const [y, m, d] = s.split("-").map(Number); return Date.UTC(y, m - 1, d); };
100
+ return Math.round((ms(a) - ms(b)) / DAY_MS);
101
+ }
102
+ function tzOffsetMs(ms: number, tz: string): number {
103
+ const p = Object.fromEntries(new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
104
+ .formatToParts(new Date(ms)).filter((x) => x.type !== "literal").map((x) => [x.type, Number(x.value)]));
105
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000;
106
+ }
107
+ /** tz 里某天 00:00 对应的 UTC 时刻(ms)。 */
108
+ export function localMidnightMs(date: string, tz: string): number {
109
+ const [y, m, d] = date.split("-").map(Number);
110
+ const guess = Date.UTC(y, m - 1, d);
111
+ let t = guess - tzOffsetMs(guess, tz);
112
+ t = guess - tzOffsetMs(t, tz);
113
+ return t;
114
+ }
115
+
116
+ const isDateOnly = (due: string) => due.length === 10;
117
+
118
+ /** 从哪一刻起算逾期:日期型 = tz 里第二天零点;时刻型 = 那个时刻。 */
119
+ export function overdueSinceMs(due: string, tz: string): number {
120
+ return isDateOnly(due) ? localMidnightMs(addDays(due, 1), tz) : Date.parse(due);
121
+ }
122
+
123
+ /** 这张卡此刻该发哪一档(没有 → null)。day = 去重用的那一天。 */
124
+ export function dueReminderFor(due: string, nowMs: number, tz: string, maxOverdue = overdueMaxDays()): { kind: DueKind; day: string; overdueDays: number; dueDay: string } | null {
125
+ const today = localDate(nowMs, tz);
126
+ if (isDateOnly(due)) {
127
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(due)) return null;
128
+ if (due === today) return { kind: "due_today", day: due, overdueDays: 0, dueDay: due };
129
+ if (due === addDays(today, 1)) return { kind: "due_soon", day: due, overdueDays: 0, dueDay: due };
130
+ if (due < today) {
131
+ const n = dayDiff(today, due);
132
+ return n <= maxOverdue ? { kind: "overdue", day: today, overdueDays: n, dueDay: due } : null;
133
+ }
134
+ return null;
135
+ }
136
+ const t = Date.parse(due);
137
+ if (!Number.isFinite(t)) return null;
138
+ const dueDay = localDate(t, tz);
139
+ if (t <= nowMs) {
140
+ const n = dayDiff(today, dueDay);
141
+ return n <= maxOverdue ? { kind: "overdue", day: today, overdueDays: n, dueDay } : null;
142
+ }
143
+ if (dueDay === today) return { kind: "due_today", day: dueDay, overdueDays: 0, dueDay };
144
+ if (t - nowMs <= DAY_MS) return { kind: "due_soon", day: dueDay, overdueDays: 0, dueDay };
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * 列表筛选(requirements.ts listFilters)用的条件:overdue / due_within_days。只看没完成的卡。
150
+ * 日期型(长度 10)按 tz 的「今天」比日期;时刻型(UTC 字符串)按此刻比时刻。字符串比较:两种形状各自同长、同格式。
151
+ */
152
+ export function overdueSql(params: unknown[], nowMs: number, tz: string): string {
153
+ const today = localDate(nowMs, tz);
154
+ const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
155
+ return `(due_on IS NOT NULL AND column_name <> 'done' AND ((length(due_on) = 10 AND due_on < ?${params.push(today)}) OR (length(due_on) > 10 AND due_on <= ?${params.push(nowIso)})))`;
156
+ }
157
+ /** 没逾期、且在 tz 的「今天 + days」那天结束之前到期(0 = 今天内)。 */
158
+ export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz: string): string {
159
+ const today = localDate(nowMs, tz);
160
+ const lastDay = addDays(today, days);
161
+ const endIso = new Date(localMidnightMs(addDays(lastDay, 1), tz)).toISOString().replace(/\.\d{3}Z$/, "Z");
162
+ const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
163
+ return `(due_on IS NOT NULL AND column_name <> 'done' AND ((length(due_on) = 10 AND due_on >= ?${params.push(today)} AND due_on <= ?${params.push(lastDay)}) OR (length(due_on) > 10 AND due_on > ?${params.push(nowIso)} AND due_on < ?${params.push(endIso)})))`;
164
+ }
165
+
166
+ // ── 扫描 ───────────────────────────────────────────────────
167
+ type DueRow = {
168
+ requirement_id: string; network_id: string; seq: number | null; title: string; due_on: string;
169
+ owner_json: string | null; participants_json: string | null; agent_owner_json: string | null;
170
+ };
171
+ const parse = (raw: string | null) => { try { return raw ? JSON.parse(raw) : null; } catch { return null; } };
172
+ const userIdOf = (ref: unknown): string | null => {
173
+ const r = ref as { kind?: unknown; id?: unknown } | null;
174
+ return r && r.kind === "user" && typeof r.id === "string" ? r.id : null;
175
+ };
176
+
177
+ export function humanRecipients(row: Pick<DueRow, "owner_json" | "participants_json">): string[] {
178
+ const ids = new Set<string>();
179
+ const owner = userIdOf(parse(row.owner_json));
180
+ if (owner) ids.add(owner);
181
+ const participants = parse(row.participants_json);
182
+ if (Array.isArray(participants)) for (const p of participants) { const id = userIdOf(p); if (id) ids.add(id); }
183
+ return [...ids];
184
+ }
185
+
186
+ function fmtDue(due: string, tz: string): string {
187
+ if (isDateOnly(due)) return due;
188
+ const t = Date.parse(due);
189
+ const s = new Intl.DateTimeFormat("sv-SE", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(t));
190
+ return s;
191
+ }
192
+
193
+ export function dueReminderText(row: Pick<DueRow, "seq" | "title" | "due_on">, r: { kind: DueKind; overdueDays: number }, tz: string): { title: string; text: string } {
194
+ const card = `${row.seq ? `#${row.seq} ` : ""}「${row.title}」`;
195
+ const when = fmtDue(row.due_on, tz);
196
+ if (r.kind === "due_soon") return { title: "任务即将到期", text: `任务${card}即将到期(预计完成 ${when})。` };
197
+ if (r.kind === "due_today") return { title: "任务今天到期", text: `任务${card}今天到期(预计完成 ${when})。` };
198
+ const late = r.overdueDays >= 1 ? `已逾期 ${r.overdueDays} 天` : "已过预计完成时间";
199
+ return { title: "任务已逾期", text: `任务${card}${late}(预计完成 ${when}),还没有完成。` };
200
+ }
201
+
202
+ export type DueReminderSent = { requirement_id: string; kind: DueKind; users: string[]; node: string | null };
203
+
204
+ /**
205
+ * 跑一次。now 可注入(测试用假时钟)。返回这次真正发出的提醒。
206
+ * 一条按 due_on 范围走索引的查询取候选;每个候选先抢去重行,抢到才发。
207
+ */
208
+ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[] {
209
+ const nowMs = opts.now ?? Date.now();
210
+ const tz = dueReminderTimezone();
211
+ const maxOverdue = overdueMaxDays();
212
+ const today = localDate(nowMs, tz);
213
+ // 范围:最老 = 今天 - maxOverdue - 1(UTC 日期 ≤ 本地日期,多留一天);最新 < 今天 + 2(24 小时内的时刻型 UTC 日期 ≤ 明天)。
214
+ const lo = addDays(today, -maxOverdue - 1);
215
+ const hi = addDays(today, 2);
216
+ const allow = reminderNetworks();
217
+ const sentAt = new Date(nowMs).toISOString();
218
+ // 基线:范围内每个网络第一次被扫到的那一刻(已有就不动)。一条 INSERT … SELECT。
219
+ {
220
+ const bp: unknown[] = [BASELINE_KIND, sentAt];
221
+ // WHERE 必须有:SQLite 里「INSERT … SELECT … FROM t ON CONFLICT」会把 ON 读成 JOIN 约束(语法错),要一个 WHERE 隔开。
222
+ const where = ` WHERE 1=1${allow ? ` AND network_id IN (${allow.map((id) => `?${bp.push(id)}`).join(", ")})` : ""}`;
223
+ db.run(
224
+ `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
225
+ SELECT '__baseline__:' || network_id, ?1, '', '', network_id, ?2 FROM networks${where}
226
+ ON CONFLICT DO NOTHING`,
227
+ bp,
228
+ );
229
+ }
230
+ const baselines = new Map(
231
+ db.all<{ network_id: string; sent_at: string }>("SELECT network_id, sent_at FROM requirement_due_reminders WHERE kind = ?1", BASELINE_KIND)
232
+ .map((b) => [b.network_id, parseHubTimestamp(b.sent_at)] as const)
233
+ .filter((e): e is readonly [string, number] => e[1] !== null),
234
+ );
235
+ const params: unknown[] = [lo, hi];
236
+ const netFilter = allow ? ` AND network_id IN (${allow.map((id) => `?${params.push(id)}`).join(", ")})` : "";
237
+ const rows = db.all<DueRow>(
238
+ `SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
239
+ FROM requirements
240
+ WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name <> 'done' AND COALESCE(archived, 0) = 0${netFilter}`,
241
+ ...params,
242
+ );
243
+ const out: DueReminderSent[] = [];
244
+ for (const row of rows) {
245
+ const r = dueReminderFor(row.due_on, nowMs, tz, maxOverdue);
246
+ if (!r) continue;
247
+ if (r.kind === "overdue") {
248
+ // 基线之前就已经逾期 → 不发(开通时不补发)。没有基线(网络行不存在)= 当作此刻开通。
249
+ const base = baselines.get(row.network_id) ?? nowMs;
250
+ if (overdueSinceMs(row.due_on, tz) < base) continue;
251
+ }
252
+ try {
253
+ const claimed = db.run(
254
+ `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
255
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT DO NOTHING`,
256
+ [row.requirement_id, r.kind, row.due_on, r.day, row.network_id, sentAt],
257
+ );
258
+ if (claimed.changes < 1) continue;
259
+ out.push(deliver(row, r, tz));
260
+ } catch (e: any) {
261
+ console.error(`[due-reminders] ${row.requirement_id} ${r.kind} failed: ${e?.message || e}`);
262
+ }
263
+ }
264
+ // 去重行保留 RETENTION_DAYS 天(比逾期上限长得多,删掉的行不会再被需要)。
265
+ 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 {}
266
+ return out;
267
+ }
268
+
269
+ function deliver(row: DueRow, r: { kind: DueKind; overdueDays: number; dueDay: string }, tz: string): DueReminderSent {
270
+ const { title, text } = dueReminderText(row, r, tz);
271
+ const agent = parse(row.agent_owner_json) as { kind?: unknown; id?: unknown } | null;
272
+ const nodeId = agent?.kind === "node" && typeof agent.id === "string" ? agent.id : null;
273
+ const node = nodeId
274
+ ? db.get<{ alias: string | null }>("SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", nodeId, row.network_id)
275
+ : null;
276
+ const alias = node?.alias || null;
277
+ const meta = { task_notice: { requirement_id: row.requirement_id, seq: row.seq ?? null, network_id: row.network_id, due_reminder: r.kind, due: row.due_on, overdue_days: r.overdueDays } };
278
+ const users: string[] = [];
279
+ for (const userId of humanRecipients(row)) {
280
+ const fromAlias = alias && canSeeAgent(userId, row.network_id, { alias, nodeId }) ? alias : DUE_REMINDER_SENDER;
281
+ const id = sendAgentNotice({
282
+ networkId: row.network_id, userId, fromAlias, kind: DUE_REMINDER_KIND, title, text,
283
+ severity: r.kind === "overdue" ? "warning" : "info", meta, idPrefix: "dm_due_",
284
+ });
285
+ if (id) users.push(userId);
286
+ }
287
+ let notifiedNode: string | null = null;
288
+ if (alias && nodeRemindersEnabled() && assertNodeActive(alias, row.network_id).ok) {
289
+ const id = uuidv4();
290
+ const message = `[${title}] ${text}\n(这是提醒,不需要回复。requirement_id=${row.requirement_id})`;
291
+ db.run(
292
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
293
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, ?5, ?6)`,
294
+ [id, alias, nodeId, message, DUE_REMINDER_SENDER, row.network_id],
295
+ );
296
+ pushEvent(alias, { type: "new_message", inbox_count: pendingInboxCount(alias, row.network_id), from: DUE_REMINDER_SENDER, message_id: id }, row.network_id);
297
+ notifiedNode = alias;
298
+ }
299
+ return { requirement_id: row.requirement_id, kind: r.kind as DueKind, users, node: notifiedNode };
300
+ }
301
+
302
+ /** startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉;bootServer(测试)不调用它。第一次扫描延后一个间隔外的 30 秒,不和启动抢。 */
303
+ export function startDueReminderTimer(): ReturnType<typeof setInterval> | null {
304
+ if (process.env.COMMHUB_DUE_REMINDERS === "0") return null;
305
+ const ms = envNumber("COMMHUB_DUE_REMINDER_TICK_MS", 10 * 60_000, 1_000);
306
+ const tick = () => { try { runDueReminders(); } catch (e: any) { console.error(`[due-reminders] tick failed: ${e?.message || e}`); } };
307
+ const first = setTimeout(tick, Math.min(ms, 30_000));
308
+ (first as any)?.unref?.();
309
+ return setInterval(tick, ms);
310
+ }
@@ -106,6 +106,8 @@ const ENTRIES: Record<string, Entry> = {
106
106
  client_id_taken: { field: 'client_id', message: 'That client_id was already used for another task.', hint: 'Generate a new client_id for each new task.' },
107
107
  // ── 列表 / 事件 ──
108
108
  invalid_view: { field: 'view', message: 'view must be full or summary.' },
109
+ invalid_overdue: { field: 'overdue', message: 'overdue must be true or false (REST: 1 / 0 / true / false).' },
110
+ invalid_due_within_days: { field: 'due_within_days', message: 'due_within_days must be an integer from 0 to 365 (0 = due later today).' },
109
111
  invalid_limit: { field: 'limit', message: 'limit is out of range.' },
110
112
  invalid_cursor: { field: 'cursor', message: 'cursor is not one this hub issued.', hint: 'Pass next_cursor from the previous page unchanged, or start again without cursor.' },
111
113
  invalid_updated_since: { field: 'updated_since', message: 'updated_since must be an ISO 8601 time.', hint: 'Use server_time from the previous changes=true response.' },
@@ -17,6 +17,7 @@ import {
17
17
  } from "./task-access.js";
18
18
  import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-notify.js";
19
19
  import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
20
+ import { dueReminderTimezone, dueWithinSql, ensureDueReminders, overdueSql } from "./requirement-due-reminders.js";
20
21
  import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
21
22
  import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
22
23
 
@@ -31,6 +32,7 @@ ensureRequirementCompletedAt(db);
31
32
  ensureNetworkTags(db);
32
33
  ensureRequirementTombstones(db);
33
34
  ensureRequirementEvents(db);
35
+ ensureDueReminders(db);
34
36
 
35
37
  type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
36
38
 
@@ -1132,6 +1134,18 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
1132
1134
  sql += parts.length ? ` AND (${parts.join(" OR ")})` : " AND 1=0";
1133
1135
  }
1134
1136
  if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
1137
+ // #494 —— 到期筛选(只看没完成的卡;「今天」按到期提醒同一个时区,见 requirement-due-reminders.ts)。
1138
+ const overdue = q.get("overdue");
1139
+ if (overdue !== null) {
1140
+ if (!["1", "true", "0", "false"].includes(overdue)) return jsonError("invalid_overdue", 400);
1141
+ const cond = overdueSql(params, Date.now(), dueReminderTimezone());
1142
+ sql += overdue === "1" || overdue === "true" ? ` AND ${cond}` : ` AND NOT ${cond}`;
1143
+ }
1144
+ const within = q.get("due_within_days");
1145
+ if (within !== null) {
1146
+ if (!/^\d{1,3}$/.test(within) || Number(within) > 365) return jsonError("invalid_due_within_days", 400);
1147
+ sql += ` AND ${dueWithinSql(params, Number(within), Date.now(), dueReminderTimezone())}`;
1148
+ }
1135
1149
  // Explicit archived-only wins over the legacy include-all switch.
1136
1150
  // changes=1 看得见「被归档」这一改动:默认连归档的一起回(行上 archived: true),客户端据此把它移出看板。
1137
1151
  if (q.get("archived") === "true") sql += " AND COALESCE(archived, 0) = 1";
@@ -1480,7 +1494,8 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1480
1494
  // changes 模式:server_time 取在读表之前,下次拿它当 updated_since,读表期间的写入不会漏(>= 会重复回一次,客户端按 id 覆盖)。
1481
1495
  const serverTime = new Date().toISOString();
1482
1496
  nodeReadNote(ctx, "GET /api/requirements", null);
1483
- const cacheKey = changes || url.searchParams.get("q") ? null : listCacheKey(ctx);
1497
+ // overdue / due_within_days 随时间变化而不随写入变化:不进按 generation 失效的缓存。
1498
+ const cacheKey = changes || url.searchParams.get("q") || url.searchParams.has("overdue") || url.searchParams.has("due_within_days") ? null : listCacheKey(ctx);
1484
1499
  const acl = cacheKey ? listCacheAcl(ctx) : null;
1485
1500
  const generation = requirementsGeneration;
1486
1501
  const cached = cacheKey && acl !== null ? listCache.get(cacheKey) : undefined;
package/src/server.ts CHANGED
@@ -6,7 +6,7 @@ import { readNodeHealth } from "./node-health-store.js";
6
6
  import { wantsAllTools } from "./tool-audience.js";
7
7
  import { assertNodeHealthy, degradedLayers } from "./node-health-guard.js";
8
8
  import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setMemberDepartment, updateDepartment } from "./departments.js";
9
- import { addManualMember, canManageGroup, createDepartmentGroup, getDepartmentGroup, groupById, groupViewerCan, isGroupMember, listGroupMembers, listVisibleGroups, readGroup, removeManualMember, renameGroup, syncDepartmentGroups } from "./department-groups.js";
9
+ import { addManualMember, canManageGroup, createDepartmentGroup, getDepartmentGroup, groupById, groupTx, groupViewerCan, isGroupMember, listGroupMembers, listVisibleGroups, readGroup, removeManualMember, renameGroup, syncDepartmentGroups } from "./department-groups.js";
10
10
  import { DEPARTMENT_SCOPE_DENIED, departmentLeaders, departmentSubtree, headScope, listDepartmentProjectGrants, managedDepartmentIds, membersIn, replaceDepartmentProjectGrants } from "./department-heads.js";
11
11
  import { redactMessageRow } from "./redact-tokens.js";
12
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -70,6 +70,7 @@ import { resolveRestFromSession } from "./rest-identity.js";
70
70
  import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
71
71
  import { diagnoseTask } from "./task-diagnostic.js";
72
72
  import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
73
+ import { startDueReminderTimer } from "./requirement-due-reminders.js";
73
74
  import { handleRequirementsRequest } from "./requirements.js";
74
75
  import { labelRoute, mcpRouteLabel, recordRouteTiming, routeStats, setRouteCallerMasterToken, setRouteCallerTokenReader } from "./route-timing.js";
75
76
  import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
@@ -1817,7 +1818,7 @@ return Bun.serve({
1817
1818
  if (req.method === "GET") {
1818
1819
  const linked = getDepartmentGroup(netId, deptId);
1819
1820
  // 兜底对账(RFC-042 §4):读之前先把成员对齐,再判「是不是成员」。
1820
- if (linked) db.transaction(() => { syncDepartmentGroups(netId, linked.id); });
1821
+ if (linked) groupTx(() => { syncDepartmentGroups(netId, linked.id); });
1821
1822
  const group = linked ? getDepartmentGroup(netId, deptId) : null;
1822
1823
  if (!group || (!mayCreate && !isGroupMember(group.id, resolved.user.user_id))) {
1823
1824
  return withCors(req, Response.json({ ok: false, error: "department_group_not_found" }, { status: 404 }));
@@ -4910,6 +4911,8 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
4910
4911
  patrolDeliveredStaleTasks();
4911
4912
  const deliveredStalePatrolTimer = setInterval(patrolDeliveredStaleTasks, deliveredStalePatrolMs);
4912
4913
  const scheduledTaskTimer = startScheduledTaskScheduler();
4914
+ // #492 —— 任务到期提醒(默认每 10 分钟;COMMHUB_DUE_REMINDERS=0 关掉)。bootServer 不起它,测试按需直接调 runDueReminders。
4915
+ const dueReminderTimer = startDueReminderTimer();
4913
4916
 
4914
4917
  // The Bun.serve socket is what keeps the process alive; the periodic
4915
4918
  // jobs must not — otherwise a test (or a future caller) that stops the
@@ -4920,6 +4923,7 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
4920
4923
  (taskPatrolTimer as any)?.unref?.();
4921
4924
  (deliveredStalePatrolTimer as any)?.unref?.();
4922
4925
  (scheduledTaskTimer as any)?.unref?.();
4926
+ (dueReminderTimer as any)?.unref?.();
4923
4927
 
4924
4928
  // ── Graceful shutdown ───────────────────────────────
4925
4929
  function shutdown() {
@@ -4930,6 +4934,7 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
4930
4934
  clearInterval(taskPatrolTimer);
4931
4935
  clearInterval(deliveredStalePatrolTimer);
4932
4936
  clearInterval(scheduledTaskTimer);
4937
+ if (dueReminderTimer) clearInterval(dueReminderTimer);
4933
4938
  db.close();
4934
4939
  process.exit(0);
4935
4940
  }
package/src/tools.ts CHANGED
@@ -1167,7 +1167,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1167
1167
  if (node_id) {
1168
1168
  try {
1169
1169
  const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
1170
- upsertNodeWithSec1Guard({
1170
+ const upserted = upsertNodeWithSec1Guard({
1171
1171
  node_id,
1172
1172
  callerNetworkId: effectiveNetId ?? null,
1173
1173
  callerUserId: enforceUserId ?? null,
@@ -1182,6 +1182,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1182
1182
  hostname: hn ?? null,
1183
1183
  config_snapshot: cfgSnap ?? null,
1184
1184
  });
1185
+ // The session row above already took node_id; a refused identity claim
1186
+ // must not leave this session pointing at another node's row.
1187
+ if (upserted.result === "refused" && upserted.reason === "identity_mismatch") {
1188
+ db.run("UPDATE sessions SET node_id = NULL WHERE resume_id = ?1 AND node_id = ?2", [resume_id, node_id]);
1189
+ }
1185
1190
  } catch {}
1186
1191
  }
1187
1192
 
@@ -5958,6 +5963,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5958
5963
  parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5959
5964
  department_id: z.string().max(64).optional().describe("owner (or its Agent's owner) in this department or below"),
5960
5965
  top_level: z.boolean().optional(), q: z.string().max(200).optional(),
5966
+ overdue: z.boolean().optional().describe("true = open tasks past their due (date-only due: before today, Asia/Shanghai); false = everything else"),
5967
+ due_within_days: z.number().int().min(0).max(365).optional().describe("open tasks not yet overdue, due by the end of today + N days (0 = later today)"),
5961
5968
  limit: z.number().int().min(1).max(1000).optional().describe(`page size, default ${REQ_LIST_MCP_DEFAULT_LIMIT}`),
5962
5969
  cursor: z.string().max(600).optional().describe("next_cursor from the previous page"),
5963
5970
  view: z.enum(["full", "summary"]).optional().describe("default summary (no description / checklist bodies; has_description, checklist_count instead); full = everything"),
@@ -5967,7 +5974,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5967
5974
  server.registerTool(
5968
5975
  "requirements_list",
5969
5976
  {
5970
- description: `List requirement tasks in your network, newest first. Defaults (MCP): view='summary' (no description / checklist bodies; has_description, checklist_count instead; ~0.5 KB per task) and ${REQ_LIST_MCP_DEFAULT_LIMIT} per page; has_more + next_cursor → pass cursor. Full text: requirements_get, or view='full' with a small limit (≤ 1000). Filters: seq (#N), status, project_id ('none'), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), tag (exact), updated_since (ISO), external_ref, parent_id / top_level, department_id (owner in it or below), include_archived; q searches title / description / people / project / tags (terms ANDed). Rows carry children {total, done}. changes=true (with updated_since) returns only tasks changed since then, archived included, plus deleted ids and server_time for the next updated_since. Unknown parameters are rejected (-32602).`,
5977
+ description: `List requirement tasks in your network, newest first. Defaults (MCP): view='summary' (no description / checklist bodies; has_description, checklist_count instead; ~0.5 KB per task) and ${REQ_LIST_MCP_DEFAULT_LIMIT} per page; has_more + next_cursor → pass cursor. Full text: requirements_get, or view='full' with a small limit (≤ 1000). Filters: seq (#N), status, project_id ('none'), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), tag (exact), updated_since (ISO), overdue / due_within_days (open tasks only), external_ref, parent_id / top_level, department_id (owner in it or below), include_archived; q searches title / description / people / project / tags (terms ANDed). Rows carry children {total, done}. changes=true (with updated_since) returns only tasks changed since then, archived included, plus deleted ids and server_time for the next updated_since. Unknown parameters are rejected (-32602).`,
5971
5978
  // Strict: registerTool honours a constructed object's unknownKeys (server.tool() would strip them silently).
5972
5979
  inputSchema: z.strictObject(reqListShape, {
5973
5980
  error: (iss: any) => iss.code === "unrecognized_keys"
@@ -5983,6 +5990,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5983
5990
  if (args.include_archived) q.set("include_archived", "1");
5984
5991
  if (args.top_level) q.set("top_level", "1");
5985
5992
  if (args.changes) q.set("changes", "1");
5993
+ if (args.overdue !== undefined) q.set("overdue", args.overdue ? "1" : "0");
5994
+ if (args.due_within_days !== undefined) q.set("due_within_days", String(args.due_within_days));
5986
5995
  return requirementsCall("GET", `/api/requirements?${q}`, args.network_id);
5987
5996
  },
5988
5997
  );
@@ -6171,7 +6180,7 @@ export interface UpsertNodeWithSec1GuardInput {
6171
6180
  }
6172
6181
  export type UpsertNodeOutcome =
6173
6182
  | { result: "inserted" | "updated"; node_id: string }
6174
- | { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch"; existingNet: string | null; callerNet: string | null }
6183
+ | { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch" | "identity_mismatch"; existingNet: string | null; callerNet: string | null }
6175
6184
  | { result: "skipped"; reason: "missing_node_id" };
6176
6185
 
6177
6186
  const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
@@ -6200,8 +6209,8 @@ export function trustedConfigSnapshotForNode(
6200
6209
 
6201
6210
  export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
6202
6211
  if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
6203
- const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
6204
- "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
6212
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null; alias: string | null; config_path: string | null }>(
6213
+ "SELECT network_id, owner_user_id, alias, config_path FROM nodes WHERE node_id = ?1",
6205
6214
  input.node_id,
6206
6215
  );
6207
6216
  const callerNet = input.callerNetworkId;
@@ -6224,6 +6233,20 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
6224
6233
  if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
6225
6234
  return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
6226
6235
  }
6236
+ // A node_id copied into another node's config (or a hand-backfilled id that
6237
+ // collides) must not relabel the row: the upsert below would COALESCE the
6238
+ // reporter's alias over the owner's. A legit rename moves nodes.alias itself
6239
+ // (rename.ts), so "different alias AND a different config file" is never the
6240
+ // same node. Unowned legacy rows are exactly where this collision lives.
6241
+ if (
6242
+ existing?.alias && input.alias && existing.alias !== input.alias
6243
+ && existing.config_path && input.config_path && existing.config_path !== input.config_path
6244
+ ) {
6245
+ console.warn(
6246
+ `[commhub] 🚫 report_status node upsert refused (identity_mismatch): node_id=${input.node_id} row-alias=${existing.alias} reporter-alias=${input.alias}`,
6247
+ );
6248
+ return { result: "refused", reason: "identity_mismatch", existingNet: existing.network_id, callerNet };
6249
+ }
6227
6250
 
6228
6251
  // Legacy / first-write paths: row missing OR network_id NULL → claim.
6229
6252
  const isLegacy = !existing