@sleep2agi/commhub-server 0.9.0-preview.72 → 0.9.0-preview.74

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/src/push.ts CHANGED
@@ -158,6 +158,10 @@ function closeClient(client: SSEClient, reason: string): void {
158
158
  // we're done with it.
159
159
  }
160
160
  console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
161
+ if (isUserStreamClient(client)) {
162
+ touchUserSeen(client.subscriberUserId!);
163
+ if (!isUserOnline(client.subscriberUserId!)) notifyPresence(client.subscriberUserId!, false);
164
+ }
161
165
  }
162
166
 
163
167
  /** Best-effort enqueue with backpressure guard. Returns:
@@ -308,8 +312,11 @@ function createStreamForKey(
308
312
  ...(opts.eventFilter ? { eventFilter: opts.eventFilter } : {}),
309
313
  };
310
314
 
315
+ const presenceUser = isUserStreamClient(client) ? client.subscriberUserId! : null;
316
+ const wasOnline = presenceUser ? isUserOnline(presenceUser) : true;
311
317
  if (!clients.has(key)) clients.set(key, []);
312
318
  clients.get(key)!.push(client);
319
+ if (presenceUser) touchUserSeen(presenceUser);
313
320
  console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
314
321
 
315
322
  // Send initial connected frame through the backpressure guard so
@@ -328,6 +335,7 @@ function createStreamForKey(
328
335
  return;
329
336
  }
330
337
  const result = tryEnqueueBytes(client, encoder.encode(`: keepalive\n\n`));
338
+ if (result === "ok" && isUserStreamClient(client)) touchUserSeen(client.subscriberUserId!);
331
339
  if (result === "dead") {
332
340
  // closeClient already cleared the timer; just make sure we
333
341
  // also prune from the map.
@@ -337,6 +345,8 @@ function createStreamForKey(
337
345
  (client.keepaliveTimer as any)?.unref?.();
338
346
 
339
347
  ensureLivenessSweep();
348
+ // 在线状态的广播放在 connected 帧之后:订阅方先拿到自己的 connected,再看到别人的变化。
349
+ if (presenceUser && !wasOnline) notifyPresence(presenceUser, true);
340
350
  },
341
351
  cancel() {
342
352
  // Consumer side hangup — straightforward path, mark closed and
@@ -523,6 +533,75 @@ export function pushUserEvent(
523
533
  if (needPrune) pruneClosed(key);
524
534
  }
525
535
 
536
+ // ── 人类成员在线状态(人员列表的在线点)──────────────────────────────────
537
+ // 判据就是上面这张用户流(/events/users/me)注册表:此刻有活着的用户流 = 在线。按**用户**算,
538
+ // 不按网络 —— 桌面端同一时刻只连当前网络的用户流,按网络算会把正在别的网络里的人画成离线。
539
+ // last_seen 只在内存:连上、每次 keepalive 成功、断开时刷新。Hub 重启后清空(未知 = null,
540
+ // 调用方不要把 null 画成「很久以前」)。
541
+
542
+ const userLastSeen = new Map<string, number>();
543
+ let presenceListener: ((userId: string, online: boolean, lastSeenAt: string | null) => void) | null = null;
544
+
545
+ function isUserStreamClient(client: SSEClient): boolean {
546
+ return !!client.subscriberUserId && client.key.startsWith(USER_KEY_PREFIX);
547
+ }
548
+
549
+ function touchUserSeen(userId: string): void {
550
+ userLastSeen.set(userId, Date.now());
551
+ }
552
+
553
+ function isUserOnline(userId: string): boolean {
554
+ for (const [key, arr] of clients) {
555
+ if (!key.startsWith(USER_KEY_PREFIX)) continue;
556
+ if (arr.some((c) => !c.closed && c.subscriberUserId === userId)) return true;
557
+ }
558
+ return false;
559
+ }
560
+
561
+ function lastSeenIso(userId: string): string | null {
562
+ const ms = userLastSeen.get(userId);
563
+ return ms === undefined ? null : new Date(ms).toISOString();
564
+ }
565
+
566
+ function notifyPresence(userId: string, online: boolean): void {
567
+ if (!presenceListener) return;
568
+ try {
569
+ presenceListener(userId, online, lastSeenIso(userId));
570
+ } catch (e: any) {
571
+ console.log(`[${ts()}] presence listener failed: ${e?.message || e}`);
572
+ }
573
+ }
574
+
575
+ /** 用户的第一条用户流连上 / 最后一条断开时回调(server.ts 用它向同网络成员推 member_presence)。 */
576
+ export function onUserPresenceChange(cb: ((userId: string, online: boolean, lastSeenAt: string | null) => void) | null): void {
577
+ presenceListener = cb;
578
+ }
579
+
580
+ /** 一批用户此刻的在线状态。鉴权由调用方负责。 */
581
+ export function getUserPresence(userIds: readonly string[]): Map<string, { online: boolean; last_seen_at: string | null }> {
582
+ const online = new Set<string>();
583
+ for (const [key, arr] of clients) {
584
+ if (!key.startsWith(USER_KEY_PREFIX)) continue;
585
+ for (const c of arr) if (!c.closed && c.subscriberUserId) online.add(c.subscriberUserId);
586
+ }
587
+ const out = new Map<string, { online: boolean; last_seen_at: string | null }>();
588
+ for (const uid of userIds) out.set(uid, { online: online.has(uid), last_seen_at: lastSeenIso(uid) });
589
+ return out;
590
+ }
591
+
592
+ /** 推给某网络里所有连着用户流的人(可排除一个)。鉴权由调用方负责。 */
593
+ export function pushUserEventToNetwork(networkId: string, event: Record<string, unknown>, exceptUserId?: string): number {
594
+ const prefix = `${USER_KEY_PREFIX}${networkId}:`;
595
+ const recipients: string[] = [];
596
+ for (const key of clients.keys()) {
597
+ if (!key.startsWith(prefix)) continue;
598
+ const uid = key.slice(prefix.length);
599
+ if (uid && uid !== exceptUserId) recipients.push(uid);
600
+ }
601
+ for (const uid of recipients) pushUserEvent(networkId, uid, event);
602
+ return recipients.length;
603
+ }
604
+
526
605
  /**
527
606
  * 多用户 Agent 权限:某用户在某网络的授权 / 成员资格变了,断开他在这个网络里**订阅时带了身份**
528
607
  * 的流(网络观察流、用户流),客户端重连时按新权限重新鉴权。返回断开的条数。
@@ -549,6 +628,7 @@ export function __resetSSEClientsForTest(): void {
549
628
  for (const c of arr) closeClient(c, "reset-for-test");
550
629
  }
551
630
  clients.clear();
631
+ userLastSeen.clear();
552
632
  if (livenessSweepTimer) {
553
633
  clearInterval(livenessSweepTimer);
554
634
  livenessSweepTimer = null;
@@ -0,0 +1,266 @@
1
+ // 标签管理(POST /api/requirements/tags/ops + GET /api/requirements/tags 的 counts / colors / can_manage)
2
+ // —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
3
+ //
4
+ // 场景:Hub 管理员 admin 的网络 NET,另有一个不相干的网络 NET2(同名标签,不能被波及)。成员:
5
+ // alice —— scoped 成员(RFC-038 §9),只授权项目 P1;
6
+ // carol —— task_access='all' 的老成员;
7
+ // vic —— viewer。
8
+ // 正向:改名 / 合并 / 删除改写网络里每张带这个标签的卡(含归档的)、颜色跟着挪、写审计、updated_at 前移;
9
+ // carol 与 owner 能管;GET 带用量与颜色。
10
+ // 反向:scoped / viewer / 节点令牌被挡且一张卡都不改;scoped 的 counts / colors 只含看得见的卡;
11
+ // 不存在的标签 404、坏参数 400;事务中途失败一张都不改;别的网络不受影响。
12
+
13
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ const DIR = mkdtempSync(join(tmpdir(), "anet-tag-ops-"));
19
+ let BASE = "";
20
+ let hub: any = null;
21
+ let db: any = null;
22
+ const PW = "TagOpsPassw0rd!x";
23
+ let NET = "";
24
+ let NET2 = "";
25
+ let admin = { token: "", id: "" };
26
+ let other = { token: "", id: "" };
27
+ let alice = { token: "", id: "" };
28
+ let carol = { token: "", id: "" };
29
+ let vic = { token: "", id: "" };
30
+ let nodeToken = "";
31
+ let P1 = "";
32
+ const C: Record<string, string> = {};
33
+
34
+ type R = { status: number; body: any };
35
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
36
+ const res = await fetch(`${BASE}${path}`, {
37
+ method,
38
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
39
+ body: payload === undefined ? undefined : JSON.stringify(payload),
40
+ });
41
+ const text = await res.text();
42
+ let body: any = null;
43
+ try { body = JSON.parse(text); } catch {}
44
+ return { status: res.status, body };
45
+ }
46
+ const op = (token: string, payload: unknown, net = NET) => send(token, "POST", `/api/requirements/tags/ops?network_id=${net}`, payload);
47
+ const tagsOf = (token: string, net = NET) => send(token, "GET", `/api/requirements/tags?network_id=${net}`);
48
+ const cardTags = (name: string): string[] => JSON.parse(db.get("SELECT tags_json FROM requirements WHERE requirement_id = ?1", C[name]).tags_json);
49
+ const snapshot = () => Object.fromEntries(Object.keys(C).map(name => [name, cardTags(name)]));
50
+
51
+ beforeAll(async () => {
52
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
53
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
54
+ process.env.HOST = "127.0.0.1";
55
+ ({ db } = await import("./db.js"));
56
+ const { createNetworkTokenForNode, register } = await import("./auth.js");
57
+ const stamp = Date.now();
58
+ const a = register(`to_admin_${stamp}`, PW, undefined, "Admin");
59
+ expect(a.ok).toBe(true);
60
+ admin = { token: a.token!, id: a.user!.user_id };
61
+ NET = a.network_id!;
62
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
63
+ const b = register(`to_other_${stamp}`, PW, undefined, "Other");
64
+ expect(b.ok).toBe(true);
65
+ other = { token: b.token!, id: b.user!.user_id };
66
+ NET2 = b.network_id!;
67
+ const ntok = createNetworkTokenForNode(admin.id, NET, "tag-node");
68
+ expect(ntok.ok).toBe(true);
69
+ nodeToken = ntok.token!;
70
+
71
+ const mod: any = await import("./server.js");
72
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
73
+ BASE = `http://127.0.0.1:${hub.port}`;
74
+
75
+ const mk = async (username: string, role: string) => {
76
+ const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role });
77
+ expect(r.status).toBe(200);
78
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
79
+ expect(login.status).toBe(200);
80
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
81
+ };
82
+ alice = await mk(`to_alice_${stamp}`, "member");
83
+ carol = await mk(`to_carol_${stamp}`, "member");
84
+ vic = await mk(`to_vic_${stamp}`, "viewer");
85
+ db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, alice.id]);
86
+ db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, carol.id]);
87
+
88
+ const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "to-P1" });
89
+ expect(p.status).toBe(201);
90
+ P1 = p.body.project.id;
91
+ const g = await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { project_grants: [{ project_id: P1, can_edit: true }] });
92
+ expect(g.status).toBe(200);
93
+
94
+ const card = async (name: string, tags: string[], extra: Record<string, unknown> = {}, token = admin.token, net = NET) => {
95
+ const r = await send(token, "POST", "/api/requirements", { network_id: net, name, tags, ...extra });
96
+ expect(r.status).toBe(201);
97
+ C[name] = r.body.requirement.id;
98
+ };
99
+ await card("visible", ["UI", "交互", "bug"], { project_id: P1 });
100
+ await card("hidden", ["UI", "secret"]);
101
+ await card("archived", ["UI", "旧"], { archived: true });
102
+ await card("both", ["交互", "TMWork", "UI"]);
103
+ await card("plain", []);
104
+ await card("net2", ["UI", "交互"], {}, other.token, NET2);
105
+ }, 30_000);
106
+
107
+ afterAll(() => {
108
+ try { hub?.stop?.(true); } catch {}
109
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
110
+ });
111
+
112
+ describe("GET /api/requirements/tags", () => {
113
+ test("owner:tags 仍是排序后的字符串数组(旧 App 形状不变),多出 counts / colors / can_manage", async () => {
114
+ const r = await tagsOf(admin.token);
115
+ expect(r.status).toBe(200);
116
+ expect(r.body.tags).toEqual(["TMWork", "UI", "bug", "secret", "交互", "旧"]);
117
+ expect(r.body.counts).toEqual({ UI: 4, 交互: 2, bug: 1, secret: 1, 旧: 1, TMWork: 1 });
118
+ expect(r.body.colors).toEqual({});
119
+ expect(r.body.can_manage).toBe(true);
120
+ });
121
+
122
+ test("scoped 成员:counts 只数看得见的卡,不能管;viewer 与节点令牌也不能管;老成员能管", async () => {
123
+ const a = await tagsOf(alice.token);
124
+ expect(a.body.tags).toEqual(["UI", "bug", "交互"]);
125
+ expect(a.body.counts).toEqual({ UI: 1, bug: 1, 交互: 1 });
126
+ expect(a.body.can_manage).toBe(false);
127
+ expect((await tagsOf(vic.token)).body.can_manage).toBe(false);
128
+ expect((await send(nodeToken, "GET", "/api/requirements/tags")).body.can_manage).toBe(false);
129
+ expect((await tagsOf(carol.token)).body.can_manage).toBe(true);
130
+ });
131
+
132
+ test("列表响应的 capabilities 带 tag_ops", async () => {
133
+ const r = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
134
+ expect(r.body.capabilities).toContain("tag_ops");
135
+ });
136
+ });
137
+
138
+ describe("被拒的调用一张卡都不改", () => {
139
+ test("scoped 成员 403(连他看得见的那张也不改,不做半套改名)", async () => {
140
+ const before = snapshot();
141
+ for (const payload of [{ op: "rename", from: "UI", to: "界面" }, { op: "delete", tag: "bug" }, { op: "merge", from: ["UI", "交互"], to: "x" }, { op: "color", tag: "UI", color: "#123456" }]) {
142
+ const r = await op(alice.token, payload);
143
+ expect(r.status).toBe(403);
144
+ expect(r.body.error).toBe("permission_denied");
145
+ }
146
+ expect(snapshot()).toEqual(before);
147
+ expect(db.get("SELECT COUNT(*) AS n FROM network_tags WHERE network_id = ?1", NET).n).toBe(0);
148
+ });
149
+
150
+ test("viewer 403;节点令牌 403 user_token_required;别的网络的 owner 碰不到这个网络", async () => {
151
+ const before = snapshot();
152
+ expect((await op(vic.token, { op: "delete", tag: "UI" })).status).toBe(403);
153
+ const n = await send(nodeToken, "POST", "/api/requirements/tags/ops", { op: "delete", tag: "UI" });
154
+ expect(n.status).toBe(403);
155
+ expect(n.body.error).toBe("user_token_required");
156
+ expect((await op(other.token, { op: "delete", tag: "UI" })).status).toBe(403);
157
+ expect(snapshot()).toEqual(before);
158
+ });
159
+
160
+ test("坏参数 400、不存在的标签 404、GET 到 ops 404", async () => {
161
+ expect((await op(admin.token, { op: "explode" })).body.error).toBe("invalid_tag_op");
162
+ expect((await op(admin.token, { op: "rename", from: "UI", to: "" })).body.error).toBe("invalid_tag");
163
+ expect((await op(admin.token, { op: "rename", from: "UI", to: "x".repeat(21) })).body.error).toBe("invalid_tag");
164
+ expect((await op(admin.token, { op: "rename", from: "UI", to: " UI " })).body.error).toBe("same_tag");
165
+ expect((await op(admin.token, { op: "merge", from: [], to: "UI" })).body.error).toBe("invalid_tag");
166
+ expect((await op(admin.token, { op: "merge", from: ["UI"], to: "UI" })).body.error).toBe("same_tag");
167
+ expect((await op(admin.token, { op: "color", tag: "UI", color: "red" })).body.error).toBe("invalid_tag_color");
168
+ const missing = await op(admin.token, { op: "delete", tag: "没有这个" });
169
+ expect(missing.status).toBe(404);
170
+ expect(missing.body.error).toBe("tag_not_found");
171
+ expect((await send(admin.token, "GET", `/api/requirements/tags/ops?network_id=${NET}`)).status).toBe(404);
172
+ });
173
+
174
+ test("事务:改到一半失败 → 一张都不改,颜色也不挪", async () => {
175
+ expect((await op(admin.token, { op: "color", tag: "UI", color: "#2563EB" })).status).toBe(200);
176
+ // 让其中一张卡的 UPDATE 抛错(SQLite 用 RAISE,PostgreSQL 用 plpgsql 触发器;PG 阶梯会跑这个文件)。
177
+ const pg = db.dialect === "postgres";
178
+ if (pg) {
179
+ db.exec(`CREATE OR REPLACE FUNCTION tag_ops_fail() RETURNS trigger AS $$ BEGIN IF NEW.requirement_id = '${C["both"]}' THEN RAISE EXCEPTION 'boom'; END IF; RETURN NEW; END $$ LANGUAGE plpgsql`);
180
+ db.exec("CREATE TRIGGER tag_ops_fail BEFORE UPDATE OF tags_json ON requirements FOR EACH ROW EXECUTE FUNCTION tag_ops_fail()");
181
+ } else {
182
+ db.exec(`CREATE TRIGGER tag_ops_fail BEFORE UPDATE OF tags_json ON requirements WHEN NEW.requirement_id = '${C["both"]}' BEGIN SELECT RAISE(ABORT, 'boom'); END;`);
183
+ }
184
+ const before = snapshot();
185
+ try {
186
+ const r = await op(admin.token, { op: "rename", from: "UI", to: "界面" });
187
+ expect(r.status).toBeGreaterThanOrEqual(500);
188
+ } finally {
189
+ db.exec(pg ? "DROP TRIGGER tag_ops_fail ON requirements" : "DROP TRIGGER tag_ops_fail");
190
+ if (pg) db.exec("DROP FUNCTION tag_ops_fail()");
191
+ }
192
+ expect(snapshot()).toEqual(before);
193
+ expect(db.get("SELECT color FROM network_tags WHERE network_id = ?1 AND name = 'UI'", NET).color).toBe("#2563eb");
194
+ });
195
+ });
196
+
197
+ describe("改名 / 合并 / 删除 / 颜色", () => {
198
+ test("颜色:设置(存小写)、GET 带出来;scoped 成员看不到他看不见的标签的颜色", async () => {
199
+ expect((await op(admin.token, { op: "color", tag: "secret", color: "#DC2626" })).status).toBe(200);
200
+ const r = await tagsOf(admin.token);
201
+ expect(r.body.colors).toEqual({ UI: "#2563eb", secret: "#dc2626" });
202
+ expect((await tagsOf(alice.token)).body.colors).toEqual({ UI: "#2563eb" });
203
+ expect((await op(admin.token, { op: "color", tag: "secret", color: null })).status).toBe(200);
204
+ expect((await tagsOf(admin.token)).body.colors).toEqual({ UI: "#2563eb" });
205
+ });
206
+
207
+ test("改名:每张卡(含看不见的、归档的)都改,位置不变;颜色跟着走;updated_at 前移;审计;别的网络不动", async () => {
208
+ const t0 = new Date().toISOString();
209
+ const r = await op(admin.token, { op: "rename", from: "UI", to: "界面" });
210
+ expect(r.status).toBe(200);
211
+ expect(r.body).toEqual({ ok: true, op: "rename", affected: 4 });
212
+ expect(cardTags("visible")).toEqual(["界面", "交互", "bug"]);
213
+ expect(cardTags("hidden")).toEqual(["界面", "secret"]);
214
+ expect(cardTags("archived")).toEqual(["界面", "旧"]);
215
+ expect(cardTags("both")).toEqual(["交互", "TMWork", "界面"]);
216
+ expect(cardTags("net2")).toEqual(["UI", "交互"]);
217
+ expect((await tagsOf(admin.token)).body.colors).toEqual({ 界面: "#2563eb" });
218
+ const row = db.get("SELECT updated_at, updated_by_json FROM requirements WHERE requirement_id = ?1", C["hidden"]);
219
+ expect(row.updated_at >= t0).toBe(true);
220
+ expect(JSON.parse(row.updated_by_json)).toEqual({ kind: "user", id: admin.id });
221
+ const audit = db.get("SELECT detail, target_id, network_id FROM audit_log WHERE action = 'requirement_tag_rename' ORDER BY id DESC LIMIT 1");
222
+ expect(audit.target_id).toBe("界面");
223
+ expect(audit.network_id).toBe(NET);
224
+ expect(JSON.parse(audit.detail)).toEqual({ op: "rename", from: "UI", to: "界面", affected: 4 });
225
+ // 这次改动 updated_since 同步得到(包括 GET 列表)。
226
+ const since = await send(admin.token, "GET", `/api/requirements?network_id=${NET}&include_archived=1&updated_since=${encodeURIComponent(t0)}`);
227
+ expect(since.body.requirements.map((x: any) => x.name).sort()).toEqual(["archived", "both", "hidden", "visible"]);
228
+ });
229
+
230
+ test("改名成已存在的标签 = 合并,同一张卡上不出现两次", async () => {
231
+ const r = await op(carol.token, { op: "rename", from: "交互", to: "界面" });
232
+ expect(r.body.affected).toBe(2);
233
+ expect(cardTags("visible")).toEqual(["界面", "bug"]);
234
+ expect(cardTags("both")).toEqual(["界面", "TMWork"]);
235
+ });
236
+
237
+ test("合并:多个来源进一个新标签;目标没颜色就继承来源的;来源颜色行删掉", async () => {
238
+ expect((await op(admin.token, { op: "color", tag: "旧", color: "#16a34a" })).status).toBe(200);
239
+ const r = await op(admin.token, { op: "merge", from: ["bug", "旧", "secret", "不存在"], to: "杂项" });
240
+ expect(r.body).toEqual({ ok: true, op: "merge", affected: 3 });
241
+ expect(cardTags("visible")).toEqual(["界面", "杂项"]);
242
+ expect(cardTags("hidden")).toEqual(["界面", "杂项"]);
243
+ expect(cardTags("archived")).toEqual(["界面", "杂项"]);
244
+ expect((await tagsOf(admin.token)).body.colors).toEqual({ 界面: "#2563eb", 杂项: "#16a34a" });
245
+ expect(db.get("SELECT COUNT(*) AS n FROM network_tags WHERE network_id = ?1 AND name = '旧'", NET).n).toBe(0);
246
+ });
247
+
248
+ test("合并进已有颜色的目标:目标颜色不变", async () => {
249
+ const r = await op(admin.token, { op: "merge", from: ["杂项"], to: "界面" });
250
+ expect(r.body.affected).toBe(3);
251
+ expect((await tagsOf(admin.token)).body.colors).toEqual({ 界面: "#2563eb" });
252
+ });
253
+
254
+ test("删除:从每张卡上拿掉(卡保留),颜色行删掉,审计带 affected", async () => {
255
+ const r = await op(admin.token, { op: "delete", tag: "界面" });
256
+ expect(r.body).toEqual({ ok: true, op: "delete", affected: 4 });
257
+ expect(cardTags("visible")).toEqual([]);
258
+ expect(cardTags("both")).toEqual(["TMWork"]);
259
+ expect(db.get("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET).n).toBe(5);
260
+ expect(db.get("SELECT COUNT(*) AS n FROM network_tags WHERE network_id = ?1", NET).n).toBe(0);
261
+ const audit = db.get("SELECT detail FROM audit_log WHERE action = 'requirement_tag_delete' ORDER BY id DESC LIMIT 1");
262
+ expect(JSON.parse(audit.detail)).toEqual({ op: "delete", tag: "界面", affected: 4 });
263
+ expect((await tagsOf(admin.token)).body.tags).toEqual(["TMWork"]);
264
+ expect(cardTags("net2")).toEqual(["UI", "交互"]);
265
+ });
266
+ });
@@ -14,3 +14,76 @@ export function normalizeTags(value: unknown): string[] | null {
14
14
  export function storedTags(value: string | null): string[] {
15
15
  try { return normalizeTags(JSON.parse(value || "[]")) ?? []; } catch { return []; }
16
16
  }
17
+
18
+ // ── 标签管理(改名 / 合并 / 删除 / 颜色) ──
19
+ // 标签只是卡片 tags 数组里的字符串,没有自己的表;批量操作 = 把网络里每张带这个标签的卡的数组改写一遍。
20
+ // 颜色是可选的,单独存在 network_tags(network_id, name, color);没行 = 客户端用默认灰。
21
+ export const MAX_MERGE_SOURCES = 50;
22
+ export type TagOp =
23
+ | { op: "rename"; from: string; to: string }
24
+ | { op: "merge"; from: string[]; to: string }
25
+ | { op: "delete"; tag: string }
26
+ | { op: "color"; tag: string; color: string | null };
27
+
28
+ /** 单个标签名,规则同 normalizeTags。不合法 = null。 */
29
+ export function tagName(value: unknown): string | null {
30
+ const tags = normalizeTags([value]);
31
+ return tags && tags.length === 1 ? tags[0] : null;
32
+ }
33
+
34
+ const TAG_COLOR = /^#[0-9a-fA-F]{6}$/;
35
+ /** 请求体 → 操作;不合法返回错误码。 */
36
+ export function parseTagOp(body: Record<string, unknown>): TagOp | { error: string } {
37
+ switch (body.op) {
38
+ case "rename": {
39
+ const from = tagName(body.from), to = tagName(body.to);
40
+ if (!from || !to) return { error: "invalid_tag" };
41
+ if (from === to) return { error: "same_tag" };
42
+ return { op: "rename", from, to };
43
+ }
44
+ case "merge": {
45
+ if (!Array.isArray(body.from) || body.from.length === 0 || body.from.length > MAX_MERGE_SOURCES) return { error: "invalid_tag" };
46
+ const from: string[] = [];
47
+ for (const value of body.from) {
48
+ const tag = tagName(value);
49
+ if (!tag) return { error: "invalid_tag" };
50
+ if (!from.includes(tag)) from.push(tag);
51
+ }
52
+ const to = tagName(body.to);
53
+ if (!to) return { error: "invalid_tag" };
54
+ // 目标自己出现在来源里不算来源(合并进自己 = 什么都不做)。
55
+ const sources = from.filter(tag => tag !== to);
56
+ if (!sources.length) return { error: "same_tag" };
57
+ return { op: "merge", from: sources, to };
58
+ }
59
+ case "delete": {
60
+ const tag = tagName(body.tag);
61
+ return tag ? { op: "delete", tag } : { error: "invalid_tag" };
62
+ }
63
+ case "color": {
64
+ const tag = tagName(body.tag);
65
+ if (!tag) return { error: "invalid_tag" };
66
+ if (body.color !== null && (typeof body.color !== "string" || !TAG_COLOR.test(body.color))) return { error: "invalid_tag_color" };
67
+ return { op: "color", tag, color: body.color === null ? null : (body.color as string).toLowerCase() };
68
+ }
69
+ default:
70
+ return { error: "invalid_tag_op" };
71
+ }
72
+ }
73
+
74
+ /** 这张卡的标签被这个操作改写后的样子;没变返回 null。顺序保留(新名字占第一个来源的位置),去重。 */
75
+ export function applyTagOp(tags: string[], op: TagOp): string[] | null {
76
+ let sources: string[];
77
+ let to: string | null;
78
+ if (op.op === "rename") { sources = [op.from]; to = op.to; }
79
+ else if (op.op === "merge") { sources = op.from; to = op.to; }
80
+ else if (op.op === "delete") { sources = [op.tag]; to = null; }
81
+ else return null;
82
+ if (!tags.some(tag => sources.includes(tag))) return null;
83
+ const next: string[] = [];
84
+ for (const tag of tags) {
85
+ const mapped = sources.includes(tag) ? to : tag;
86
+ if (mapped !== null && !next.includes(mapped)) next.push(mapped);
87
+ }
88
+ return next;
89
+ }
@@ -0,0 +1,70 @@
1
+ // 完成时间的启动迁移(requirements-migrate.ts ensureRequirementCompletedAt):用加完成时间之前的表结构建库、塞行,
2
+ // 再 import db.ts + requirements.ts(真实启动路径)。覆盖:两种旧时间形状、没有 updated_at 的行、
3
+ // 回滚到旧 Hub 期间「移出完成」留下的残值被清掉、「移进完成」的卡被补上,以及重复执行不再改任何东西。
4
+ import { afterAll, expect, test } from "bun:test";
5
+ import { Database } from "bun:sqlite";
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-completed-at-"));
11
+ const file = join(dir, "hub.db");
12
+ process.env.COMMHUB_DB = file;
13
+
14
+ const legacy = new Database(file);
15
+ legacy.exec(`CREATE TABLE requirements (
16
+ requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
17
+ column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')), priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
18
+ client_id TEXT, issues_json TEXT, created_by TEXT,
19
+ created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
20
+ owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]', updated_by_json TEXT)`);
21
+ const seed: [string, string, string, string][] = [
22
+ // id, column, created_at, updated_at
23
+ ["r_done_sqlite_ts", "done", "2026-08-01 08:00:00", "2026-09-01 10:00:00"],
24
+ ["r_done_iso", "done", "2026-08-02T00:00:00.000Z", "2026-09-02T03:04:05.678Z"],
25
+ ["r_done_bad_updated", "done", "2026-08-03T00:00:00.000Z", "not a time"],
26
+ ["r_doing", "doing", "2026-08-04T00:00:00.000Z", "2026-09-04T00:00:00.000Z"],
27
+ ["r_pool", "pool", "2026-08-05T00:00:00.000Z", "2026-09-05T00:00:00.000Z"],
28
+ ];
29
+ for (const [id, column, createdAt, updatedAt] of seed) {
30
+ legacy.run("INSERT INTO requirements (requirement_id, network_id, title, column_name, created_at, updated_at, updated_by_json) VALUES (?1, 'net', ?1, ?2, ?3, ?4, ?5)",
31
+ [id, column, createdAt, updatedAt, JSON.stringify({ kind: "user", id: "u_last_editor" })]);
32
+ }
33
+ legacy.close();
34
+
35
+ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
36
+
37
+ const read = (db: any) => Object.fromEntries((db.all("SELECT requirement_id, column_name, updated_at, completed_at, completed_by_json, completed_at_approx FROM requirements") as any[]).map(r => [r.requirement_id, r]));
38
+
39
+ test("backfill: done rows get an approximate ISO completed_at from updated_at; others stay empty; idempotent", async () => {
40
+ const { db } = await import("./db.js");
41
+ await import("./requirements.js");
42
+ const { ensureRequirementCompletedAt } = await import("./requirements-migrate.js");
43
+ const by = read(db);
44
+ expect(by.r_done_sqlite_ts.completed_at).toBe("2026-09-01T10:00:00.000Z");
45
+ expect(by.r_done_iso.completed_at).toBe("2026-09-02T03:04:05.678Z");
46
+ // updated_at 读不懂 → 退到 created_at,而不是留空(留空的完成卡在仪表盘上就「没完成过」)
47
+ expect(by.r_done_bad_updated.completed_at).toBe("2026-08-03T00:00:00.000Z");
48
+ for (const id of ["r_done_sqlite_ts", "r_done_iso", "r_done_bad_updated"]) {
49
+ expect(by[id].completed_at_approx).toBe(1);
50
+ // 最后改的人不是「完成的人」:近似值不猜完成者
51
+ expect(by[id].completed_by_json).toBeNull();
52
+ // 补值不碰 updated_at
53
+ expect(by[id].updated_at).toBe(seed.find(s => s[0] === id)![3]);
54
+ }
55
+ for (const id of ["r_doing", "r_pool"]) expect([by[id].completed_at, by[id].completed_by_json, by[id].completed_at_approx]).toEqual([null, null, 0]);
56
+
57
+ // 下次启动:什么都不动
58
+ expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
59
+ expect(read(db)).toEqual(by);
60
+
61
+ // 回滚到旧 Hub 期间:旧 Hub 不认识这三列 —— 把一张完成的卡移出(列留着旧值)、把一张卡移进完成(列是空的)。
62
+ db.run("UPDATE requirements SET column_name = 'doing' WHERE requirement_id = 'r_done_iso'");
63
+ db.run("UPDATE requirements SET column_name = 'done', updated_at = '2026-09-06T00:00:00.000Z' WHERE requirement_id = 'r_pool'");
64
+ expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 1, cleared: 1 });
65
+ const after = read(db);
66
+ expect([after.r_done_iso.completed_at, after.r_done_iso.completed_by_json, after.r_done_iso.completed_at_approx]).toEqual([null, null, 0]);
67
+ expect([after.r_pool.completed_at, after.r_pool.completed_at_approx]).toEqual(["2026-09-06T00:00:00.000Z", 1]);
68
+ expect(after.r_done_sqlite_ts).toEqual(by.r_done_sqlite_ts);
69
+ expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
70
+ });
@@ -35,7 +35,8 @@ beforeAll(async () => {
35
35
  expect(viewer.ok).toBe(true);
36
36
  viewerToken = viewer.token!;
37
37
  const viewerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", viewer.user!.username)!.user_id;
38
- addNetworkMember(networkId, viewerId, "viewer", ownerId);
38
+ // 这里的 viewer 代表 RFC-038 §9 之前的语义(能读全网、不能写);scoped viewer 在 task-access-http.test.ts 里测。
39
+ addNetworkMember(networkId, viewerId, "viewer", ownerId, { taskAccess: "all" });
39
40
  const ntok = createNetworkTokenForNode(ownerId, networkId, "req-node");
40
41
  expect(ntok.ok).toBe(true);
41
42
  nodeToken = ntok.token!;
@@ -372,7 +373,7 @@ describe("requirements stay on the hub", () => {
372
373
  expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
373
374
  const listed = await api(ownerToken, "/api/requirements");
374
375
  expect(listed.body.capabilities).toContain("due_datetime");
375
- expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging"]);
376
+ expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops"]);
376
377
  });
377
378
 
378
379
  test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
@@ -43,6 +43,22 @@ export function ensureRequirementProjects(database: DbAdapter): void {
43
43
  `);
44
44
  }
45
45
 
46
+ /**
47
+ * 标签颜色(可选)。标签本身仍只是卡片 tags 数组里的字符串;这里只存「这个网络里这个名字用什么颜色」。
48
+ * 没有行 = 默认色。改名 / 合并 / 删除时跟着挪或删(见 requirements.ts 的标签管理)。IF NOT EXISTS:重复执行无副作用。
49
+ */
50
+ export function ensureNetworkTags(database: DbAdapter): void {
51
+ database.exec(`
52
+ CREATE TABLE IF NOT EXISTS network_tags (
53
+ network_id TEXT NOT NULL,
54
+ name TEXT NOT NULL,
55
+ color TEXT NOT NULL,
56
+ updated_at TEXT NOT NULL,
57
+ PRIMARY KEY (network_id, name)
58
+ );
59
+ `);
60
+ }
61
+
46
62
  /** external_ref 在同一网络里唯一(只管有值的行)。IF NOT EXISTS:重复执行无副作用。 */
47
63
  export function ensureRequirementIndexes(database: DbAdapter): void {
48
64
  database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_external_ref ON requirements(network_id, external_ref) WHERE external_ref IS NOT NULL");
@@ -128,6 +144,50 @@ export function ensureRequirementSeq(database: DbAdapter): { backfilled: number
128
144
  return { backfilled: pending.length };
129
145
  }
130
146
 
147
+ /**
148
+ * 完成时间(仪表盘「今天 / 本周完成了多少」用)。三列只加不改:
149
+ * - completed_at:进入「完成」列的时刻(ISO,UTC)。移出「完成」列清空;在「完成」列里改别的字段不动它。
150
+ * - completed_by_json:谁把它移进「完成」的({kind,id},同 updated_by)。
151
+ * - completed_at_approx:1 = 这个时刻是补出来的近似值(见下),界面可以标「近似」。
152
+ * 补值:已经在「完成」列、还没有 completed_at 的卡,按 updated_at(没有就 created_at)补,approx = 1,completed_by 留空
153
+ * —— updated_at 是「最后一次改」,不是「完成那一刻」,只能当近似。旧库里 datetime('now') 的「YYYY-MM-DD HH:MM:SS」(UTC)
154
+ * 统一换成 ISO,按时间范围比较时不和 ISO 值错位。
155
+ * 每次启动都跑,重复执行只处理需要处理的行:回滚到旧 Hub 期间移进「完成」的卡补近似值,移出「完成」的卡清掉残留。
156
+ */
157
+ export function ensureRequirementCompletedAt(database: DbAdapter): { backfilled: number; cleared: number } {
158
+ for (const column of ["completed_at TEXT", "completed_by_json TEXT", "completed_at_approx INTEGER NOT NULL DEFAULT 0"]) {
159
+ try { database.exec(`ALTER TABLE requirements ADD COLUMN ${column}`); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
160
+ }
161
+ const pending = database.all<{ requirement_id: string; updated_at: string | null; created_at: string | null }>(
162
+ "SELECT requirement_id, updated_at, created_at FROM requirements WHERE column_name = 'done' AND completed_at IS NULL",
163
+ );
164
+ const stale = database.all<{ requirement_id: string }>(
165
+ "SELECT requirement_id FROM requirements WHERE column_name <> 'done' AND (completed_at IS NOT NULL OR completed_by_json IS NOT NULL OR completed_at_approx <> 0)",
166
+ );
167
+ if (pending.length || stale.length) {
168
+ database.transaction(() => {
169
+ for (const row of pending) {
170
+ database.run(
171
+ "UPDATE requirements SET completed_at = ?1, completed_by_json = NULL, completed_at_approx = 1 WHERE requirement_id = ?2 AND completed_at IS NULL",
172
+ [isoInstant(row.updated_at) ?? isoInstant(row.created_at) ?? new Date().toISOString(), row.requirement_id],
173
+ );
174
+ }
175
+ for (const row of stale) {
176
+ database.run("UPDATE requirements SET completed_at = NULL, completed_by_json = NULL, completed_at_approx = 0 WHERE requirement_id = ?1", [row.requirement_id]);
177
+ }
178
+ });
179
+ }
180
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_network_completed ON requirements(network_id, completed_at) WHERE completed_at IS NOT NULL");
181
+ return { backfilled: pending.length, cleared: stale.length };
182
+ }
183
+
184
+ /** 库里的时间 → ISO(UTC)。认 ISO 和旧默认值「YYYY-MM-DD HH:MM:SS」(SQLite datetime('now'),UTC);读不懂 → null。 */
185
+ export function isoInstant(value: string | null | undefined): string | null {
186
+ const s = value ?? "";
187
+ const ms = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(" ", "T")}Z` : s);
188
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
189
+ }
190
+
131
191
  /**
132
192
  * 给新卡领下一个号。调用方把它和 INSERT 放在同一个事务里:插入失败(client_id / external_ref 撞了)号也一起回滚。
133
193
  * 一条语句完成「读 + 加一 + 写回」,两个并发的新建拿不到同一个号;(network_id, seq) 唯一索引兜底。
@@ -122,7 +122,8 @@ describe("requirement short numbers (#N)", () => {
122
122
  test("a user whose scope spans two networks gets 409 ambiguous_seq for #N, and network_id resolves it", async () => {
123
123
  const { db } = await import("./db.js");
124
124
  const { addNetworkMember } = await import("./auth.js");
125
- addNetworkMember(otherNet, ownerId, "member", otherId);
125
+ // 看全网的成员(RFC-038 §9 之前的语义):这里测的是跨网络 #N 歧义,不是任务范围。
126
+ addNetworkMember(otherNet, ownerId, "member", otherId, { taskAccess: "all" });
126
127
  const both = await api(ownerToken, "/api/requirements/%231");
127
128
  expect(both.status).toBe(409);
128
129
  expect(both.body.error).toBe("ambiguous_seq");