@sleep2agi/commhub-server 0.9.0-preview.71 → 0.9.0-preview.73

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,32 @@
1
+ // requirements-search.ts 的纯函数:任务 ID 匹配(与 App 的 task-short-id.ts matchesTaskId 同一规则)。
2
+ import { describe, expect, test } from "bun:test";
3
+ import { matchesTaskId, parseListQuery, taskIdQuery } from "./requirements-search.js";
4
+
5
+ const row = (id: string, seq: number | null) => ({ requirement_id: id, seq });
6
+
7
+ describe("matchesTaskId", () => {
8
+ test("#N / N match seq exactly (#7 does not hit #70)", () => {
9
+ expect(matchesTaskId(row("req_a", 7), "#7")).toBe(true);
10
+ expect(matchesTaskId(row("req_a", 7), "7")).toBe(true);
11
+ expect(matchesTaskId(row("req_a", 70), "#7")).toBe(false);
12
+ expect(matchesTaskId(row("req_a", null), "#7")).toBe(false);
13
+ });
14
+ test("full id and 8+ char prefix, with or without req_; shorter prefixes do not count", () => {
15
+ const id = "req_0a1b2c3d-4e5f-6789-abcd-ef0123456789";
16
+ expect(matchesTaskId(row(id, 1), id)).toBe(true);
17
+ expect(matchesTaskId(row(id, 1), "0a1b2c3d")).toBe(true);
18
+ expect(matchesTaskId(row(id, 1), "req_0a1b2c3d-4")).toBe(true);
19
+ expect(matchesTaskId(row(id, 1), "0a1b2c3")).toBe(false);
20
+ expect(matchesTaskId(row(id, 1), "")).toBe(false);
21
+ });
22
+ test("taskIdQuery: trim, NFKC (full-width #7 → #7), lowercase", () => {
23
+ expect(taskIdQuery(" #7 ")).toBe("#7");
24
+ expect(taskIdQuery("REQ_0A1B")).toBe("req_0a1b");
25
+ });
26
+ test("parseListQuery: idQuery only when there are terms", () => {
27
+ const q = (s: string) => parseListQuery(new URLSearchParams(s)) as any;
28
+ expect(q("q=%EF%BC%83%EF%BC%97").idQuery).toBe("#7");
29
+ expect(q("q=%20%20").idQuery).toBe("");
30
+ expect(q("").idQuery).toBe("");
31
+ });
32
+ });
@@ -0,0 +1,138 @@
1
+ // GET /api/requirements 的服务端搜索(q=)与分页(limit / cursor)。纯函数,requirements.ts 接线。
2
+ //
3
+ // 为什么要:列表一直是 `ORDER BY created_at DESC LIMIT 500`,App 的任务搜索只在读到的行里搜 ——
4
+ // 生产一个网络已经 ~400 张卡,过了 500 最老的那些就搜不到、也翻不到。
5
+ //
6
+ // 语义与 App 的任务搜索(agent-network-app src/task-search.ts)一致,两边搜同一个词得到同一批卡:
7
+ // · 字段:标题、描述(去掉 markdown 图片 / 链接的地址,链接文字保留)、负责人 / 负责 Agent / 参与人的显示名、
8
+ // 旧 assignee 文本、项目名、标签
9
+ // · NFKC(全角 → 半角)+ 小写;中文按子串;空白隔开的词是「且」,每个词可以落在不同字段,但不跨字段拼
10
+ // · 调用者看不见的节点(受限成员)在卡片上本来就被隐去,它的名字也不参与匹配 —— 否则 q= 就成了
11
+ // 「这张卡是不是某个没授权给我的 Agent 负责」的探测器
12
+ //
13
+ // 分页:按 (created_at DESC, requirement_id DESC) 排,cursor = 上一页最后一行的这两个值(不透明的 base64url)。
14
+ // 不带 limit / cursor / q 的旧请求:同样的 500 行、同样的顺序,响应只多两个字段 has_more / next_cursor。
15
+
16
+ export const DEFAULT_LIST_LIMIT = 500;
17
+ export const MAX_LIST_LIMIT = 1000;
18
+ export const MAX_QUERY_LENGTH = 200;
19
+
20
+ const fold = (s: string): string => s.normalize("NFKC").toLocaleLowerCase();
21
+
22
+ /** 搜索词:归一后按空白切开,去重、去空。 */
23
+ export function searchTerms(q: string): string[] {
24
+ return [...new Set(fold(q).split(/\s+/).filter(Boolean))];
25
+ }
26
+
27
+ /** 描述是 markdown:图片 / 链接的地址不参与搜索,链接文字保留;html 标签去掉。 */
28
+ export function descriptionText(md: string | null | undefined): string {
29
+ if (!md) return "";
30
+ return md
31
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
32
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
33
+ .replace(/<[^>]+>/g, " ");
34
+ }
35
+
36
+ type Ref = { kind?: unknown; id?: unknown } | null | undefined;
37
+ export type SearchableRow = {
38
+ name: string;
39
+ description: string;
40
+ assignee: string;
41
+ tags: readonly string[];
42
+ project_id: string | null;
43
+ owner: Ref;
44
+ agent_owner: Ref;
45
+ participants: readonly Ref[] | unknown;
46
+ };
47
+ export type NameMaps = {
48
+ /** `user:<id>` / `node:<id>` → 显示名(看不见的节点不在里面)。 */
49
+ people: ReadonlyMap<string, string>;
50
+ /** project_id → 项目名。 */
51
+ projects: ReadonlyMap<string, string>;
52
+ };
53
+
54
+ const refName = (ref: Ref, maps: NameMaps): string => {
55
+ if (!ref || typeof ref !== "object" || typeof ref.id !== "string" || (ref.kind !== "user" && ref.kind !== "node")) return "";
56
+ return maps.people.get(`${ref.kind}:${ref.id}`) ?? "";
57
+ };
58
+
59
+ /** 一张卡能被搜到的文字,字段之间用换行隔开(一个词不会跨两个字段拼出来)。 */
60
+ export function searchText(row: SearchableRow, maps: NameMaps): string {
61
+ const participants = Array.isArray(row.participants) ? row.participants as Ref[] : [];
62
+ return fold([
63
+ row.name,
64
+ descriptionText(row.description),
65
+ refName(row.owner, maps),
66
+ refName(row.agent_owner, maps),
67
+ ...participants.map(r => refName(r, maps)),
68
+ row.assignee,
69
+ row.project_id ? maps.projects.get(row.project_id) ?? "" : "",
70
+ ...row.tags,
71
+ ].filter(Boolean).join("\n"));
72
+ }
73
+
74
+ export function matchesTerms(row: SearchableRow, terms: readonly string[], maps: NameMaps): boolean {
75
+ if (!terms.length) return true;
76
+ const hay = searchText(row, maps);
77
+ return terms.every(t => hay.includes(t));
78
+ }
79
+
80
+ export type Cursor = { createdAt: string; id: string };
81
+
82
+ export function encodeCursor(c: Cursor): string {
83
+ return Buffer.from(JSON.stringify([c.createdAt, c.id]), "utf8").toString("base64url");
84
+ }
85
+
86
+ export function decodeCursor(raw: string): Cursor | null {
87
+ if (!raw || raw.length > 600 || !/^[A-Za-z0-9_-]+$/.test(raw)) return null;
88
+ try {
89
+ const v = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
90
+ if (!Array.isArray(v) || v.length !== 2 || typeof v[0] !== "string" || typeof v[1] !== "string" || !v[0] || !v[1]) return null;
91
+ return { createdAt: v[0], id: v[1] };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string };
98
+
99
+ /**
100
+ * 任务 ID(同 App 的 task-short-id.ts matchesTaskId):「#42」「42」精确对短号 seq;完整 id,或 8 位以上的
101
+ * id 前缀(带不带 req_ 都行)对主键。整句当一个 ID —— 和文字匹配是「或」,不拆成「ID 且文字」。
102
+ * 全角「#42」先 NFKC 归一(ID 本身都是 ASCII)。
103
+ */
104
+ export const taskIdQuery = (q: string): string => q.trim().normalize("NFKC").toLowerCase();
105
+ export const seqOfQuery = (idQuery: string): number | null => {
106
+ const m = /^#?(\d{1,15})$/.exec(idQuery);
107
+ return m ? Number(m[1]) : null;
108
+ };
109
+ export function matchesTaskId(row: { requirement_id: string; seq?: number | null }, idQuery: string): boolean {
110
+ if (!idQuery) return false;
111
+ const n = seqOfQuery(idQuery);
112
+ if (n !== null) return typeof row.seq === "number" && row.seq === n;
113
+ const id = row.requirement_id.toLowerCase();
114
+ if (idQuery === id) return true;
115
+ const bare = idQuery.replace(/^req_/, "");
116
+ return bare.length >= 8 && id.replace(/^req_/, "").startsWith(bare);
117
+ }
118
+
119
+ /** 解析 limit / cursor / q。不合法 → 错误码(400)。都没带 = 旧行为(500 行、第一页、不搜)。 */
120
+ export function parseListQuery(params: URLSearchParams): ListQuery | { error: string } {
121
+ let limit = DEFAULT_LIST_LIMIT;
122
+ const rawLimit = params.get("limit");
123
+ if (rawLimit !== null) {
124
+ if (!/^\d{1,5}$/.test(rawLimit)) return { error: "invalid_limit" };
125
+ limit = Number(rawLimit);
126
+ if (limit < 1 || limit > MAX_LIST_LIMIT) return { error: "invalid_limit" };
127
+ }
128
+ let cursor: Cursor | null = null;
129
+ const rawCursor = params.get("cursor");
130
+ if (rawCursor !== null && rawCursor !== "") {
131
+ cursor = decodeCursor(rawCursor);
132
+ if (!cursor) return { error: "invalid_cursor" };
133
+ }
134
+ const q = params.get("q") ?? "";
135
+ if (q.length > MAX_QUERY_LENGTH) return { error: "invalid_q" };
136
+ const terms = searchTerms(q);
137
+ return { limit, cursor, terms, idQuery: terms.length ? taskIdQuery(q) : "" };
138
+ }
@@ -0,0 +1,192 @@
1
+ // 任务短号 seq:新建领号(并发不撞)、按网络各自编号、按 #N 查 / 改、删除和归档不回收号、MCP 工具也收 #N。
2
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-seq-"));
8
+ process.env.COMMHUB_DB = join(dir, "hub.db");
9
+
10
+ let server: { port: number; stop?: (force?: boolean) => void };
11
+ let base = "";
12
+ let ownerToken = "", otherToken = "", nodeToken = "";
13
+ let ownerNet = "", otherNet = "", ownerId = "", otherId = "";
14
+
15
+ async function api(token: string, path: string, init?: RequestInit) {
16
+ const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) } });
17
+ return { status: res.status, body: await res.json() as any };
18
+ }
19
+ const create = (token: string, body: Record<string, unknown>, query = "") => api(token, `/api/requirements${query}`, { method: "POST", body: JSON.stringify(body) });
20
+
21
+ beforeAll(async () => {
22
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
23
+ // 第一个注册的用户是 Hub 管理员、作用域是全部网络;让它先占掉,下面两个是普通用户(各自一个网络)。
24
+ register(`seq_admin_${Date.now()}`, "SeqAdmin123!x", undefined, "seed");
25
+ const o = register(`seq_owner_${Date.now()}`, "SeqOwner123!x", undefined, "seed");
26
+ const x = register(`seq_other_${Date.now()}`, "SeqOther123!x", undefined, "seed");
27
+ ownerToken = o.token!; ownerNet = o.network_id!; ownerId = o.user!.user_id;
28
+ otherToken = x.token!; otherNet = x.network_id!; otherId = x.user!.user_id;
29
+ nodeToken = createNetworkTokenForNode(ownerId, ownerNet, "seq-node").token!;
30
+ const mod: any = await import("./server.js");
31
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
32
+ base = `http://127.0.0.1:${server.port}`;
33
+ }, 30_000);
34
+
35
+ afterAll(() => {
36
+ try { server?.stop?.(true); } catch {}
37
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
38
+ });
39
+
40
+ describe("requirement short numbers (#N)", () => {
41
+ test("capability is advertised and create returns seq", async () => {
42
+ const list = await api(ownerToken, "/api/requirements");
43
+ expect(list.body.capabilities).toContain("requirement_seq");
44
+ const first = await create(ownerToken, { name: "第一张" });
45
+ expect(first.status).toBe(201);
46
+ expect(first.body.requirement.seq).toBe(1);
47
+ expect(first.body.requirement.id).toMatch(/^req_/);
48
+ const got = await api(ownerToken, `/api/requirements/${first.body.requirement.id}`);
49
+ expect(got.body.requirement.seq).toBe(1);
50
+ expect((await api(ownerToken, "/api/requirements")).body.requirements[0].seq).toBe(1);
51
+ });
52
+
53
+ test("concurrent creates get distinct, gap-free seqs", async () => {
54
+ const before = (await create(ownerToken, { name: "before" })).body.requirement.seq as number;
55
+ const results = await Promise.all(Array.from({ length: 40 }, (_, i) => create(i % 2 ? nodeToken : ownerToken, { name: `并发 ${i}` })));
56
+ expect(results.every(r => r.status === 201)).toBe(true);
57
+ const seqs = results.map(r => r.body.requirement.seq as number).sort((a, b) => a - b);
58
+ expect(new Set(seqs).size).toBe(40);
59
+ expect(seqs).toEqual(Array.from({ length: 40 }, (_, i) => before + 1 + i));
60
+ });
61
+
62
+ test("seq is per network", async () => {
63
+ const a = await create(otherToken, { name: "别的网络第一张" });
64
+ expect(a.body.requirement.seq).toBe(1);
65
+ const b = await create(otherToken, { name: "别的网络第二张" });
66
+ expect(b.body.requirement.seq).toBe(2);
67
+ // 同一个 #1 在两个网络各指各的卡
68
+ expect((await api(ownerToken, "/api/requirements/%231")).body.requirement.name).toBe("第一张");
69
+ expect((await api(otherToken, "/api/requirements/%231")).body.requirement.name).toBe("别的网络第一张");
70
+ // 别的网络的号查不到对方的卡
71
+ expect((await api(otherToken, "/api/requirements/%2310")).status).toBe(404);
72
+ });
73
+
74
+ test("lookup and patch by #N; list filter ?seq=; malformed forms rejected", async () => {
75
+ const c = (await create(ownerToken, { name: "按号查", checklist: [{ id: "a", text: "一" }] })).body.requirement;
76
+ const ref = `%23${c.seq}`;
77
+ const got = await api(ownerToken, `/api/requirements/${ref}`);
78
+ expect(got.status).toBe(200);
79
+ expect(got.body.requirement.id).toBe(c.id);
80
+ const patched = await api(nodeToken, `/api/requirements/${ref}`, { method: "PATCH", body: JSON.stringify({ column: "doing" }) });
81
+ expect(patched.status).toBe(200);
82
+ expect(patched.body.requirement.column).toBe("doing");
83
+ expect(patched.body.requirement.seq).toBe(c.seq); // 改卡不改号
84
+ const ticked = await api(ownerToken, `/api/requirements/${ref}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
85
+ expect(ticked.body.requirement.checklist[0].done).toBe(true);
86
+ const listed = await api(ownerToken, `/api/requirements?seq=${c.seq}`);
87
+ expect(listed.body.requirements.map((r: any) => r.id)).toEqual([c.id]);
88
+ expect((await api(ownerToken, "/api/requirements?seq=abc")).status).toBe(400);
89
+ expect((await api(ownerToken, "/api/requirements?seq=0")).status).toBe(400);
90
+ // 旧 id 照常;裸数字、「#」「#0」「#-1」都不是短号,按 id 查 → 404
91
+ expect((await api(ownerToken, `/api/requirements/${c.id}`)).body.requirement.seq).toBe(c.seq);
92
+ for (const bad of [String(c.seq), "%23", "%230", "%23-1", `%23${c.seq}x`]) expect((await api(ownerToken, `/api/requirements/${bad}`)).status).toBe(404);
93
+ });
94
+
95
+ test("deleting or archiving never frees a number", async () => {
96
+ const top = (await create(ownerToken, { name: "最大号" })).body.requirement;
97
+ expect((await api(ownerToken, `/api/requirements/%23${top.seq}`, { method: "DELETE" })).status).toBe(200);
98
+ expect((await api(ownerToken, `/api/requirements/%23${top.seq}`)).status).toBe(404);
99
+ const next = (await create(ownerToken, { name: "删之后" })).body.requirement;
100
+ expect(next.seq).toBe(top.seq + 1);
101
+ await api(ownerToken, `/api/requirements/${next.id}`, { method: "PATCH", body: JSON.stringify({ archived: true }) });
102
+ expect((await create(ownerToken, { name: "归档之后" })).body.requirement.seq).toBe(next.seq + 1);
103
+ // 归档的卡按号照样能查到
104
+ expect((await api(ownerToken, `/api/requirements/%23${next.seq}`)).body.requirement.archived).toBe(true);
105
+ });
106
+
107
+ test("a create that loses on client_id / external_ref does not burn a number", async () => {
108
+ const a = (await create(ownerToken, { name: "幂等", client_id: "seq-client-1" })).body.requirement;
109
+ const again = await create(ownerToken, { name: "幂等", client_id: "seq-client-1" });
110
+ expect(again.body.requirement.seq).toBe(a.seq);
111
+ await create(ownerToken, { name: "外部", external_ref: "github:acme/seq#1" });
112
+ expect((await create(ownerToken, { name: "外部", external_ref: "github:acme/seq#1" })).status).toBe(409);
113
+ const after = (await create(ownerToken, { name: "之后" })).body.requirement;
114
+ expect(after.seq).toBe(a.seq + 2);
115
+ // upsert 新建也领号,改的时候号不变
116
+ const up = await api(ownerToken, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步", external_ref: "github:acme/seq#2" }) });
117
+ expect(up.body.requirement.seq).toBe(after.seq + 1);
118
+ const up2 = await api(ownerToken, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步改名", external_ref: "github:acme/seq#2" }) });
119
+ expect(up2.body.requirement.seq).toBe(after.seq + 1);
120
+ });
121
+
122
+ test("a user whose scope spans two networks gets 409 ambiguous_seq for #N, and network_id resolves it", async () => {
123
+ const { db } = await import("./db.js");
124
+ const { addNetworkMember } = await import("./auth.js");
125
+ addNetworkMember(otherNet, ownerId, "member", otherId);
126
+ const both = await api(ownerToken, "/api/requirements/%231");
127
+ expect(both.status).toBe(409);
128
+ expect(both.body.error).toBe("ambiguous_seq");
129
+ expect(both.body.networks.sort()).toEqual([ownerNet, otherNet].sort());
130
+ expect(both.body.message).toContain("pass network_id");
131
+ // MCP 上用户令牌(跨两个网络)同样:清楚的错误 + 带 network_id 就能查到
132
+ {
133
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
134
+ const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
135
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
136
+ const { registerTools } = await import("./tools.js");
137
+ const mcp = new McpServer({ name: "seq-mcp-user", version: "1" });
138
+ registerTools(mcp, undefined, null, ownerId, "seq-owner", false, "tok_user");
139
+ const client = new Client({ name: "seq-mcp-user-client", version: "1" });
140
+ const [ct, st] = InMemoryTransport.createLinkedPair();
141
+ await mcp.connect(st); await client.connect(ct);
142
+ const call = async (name: string, args: Record<string, unknown>) => JSON.parse(((await client.callTool({ name, arguments: args })) as any).content[0].text);
143
+ try {
144
+ const amb = await call("requirements_get", { id: "#1" });
145
+ expect(amb.ok).toBe(false);
146
+ expect(amb.error).toBe("ambiguous_seq");
147
+ expect(amb.status).toBe(409);
148
+ expect(amb.message).toContain("pass network_id");
149
+ expect((await call("requirements_update", { id: "#1", priority: "low" })).error).toBe("ambiguous_seq");
150
+ expect((await call("requirements_get", { id: "#1", network_id: otherNet })).requirement.name).toBe("别的网络第一张");
151
+ expect((await call("requirements_get", { id: "#1", network_id: ownerNet })).requirement.name).toBe("第一张");
152
+ } finally { await client.close(); await mcp.close(); }
153
+ }
154
+ // 旧 id 不受影响
155
+ const firstId = (await api(ownerToken, `/api/requirements/%231?network_id=${ownerNet}`)).body.requirement.id;
156
+ expect((await api(ownerToken, `/api/requirements/${firstId}`)).status).toBe(200);
157
+ const scoped = await api(ownerToken, `/api/requirements/%231?network_id=${otherNet}`);
158
+ expect(scoped.status).toBe(200);
159
+ expect(scoped.body.requirement.name).toBe("别的网络第一张");
160
+ db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [otherNet, ownerId]);
161
+ });
162
+
163
+ test("MCP tools accept #N and expose seq", async () => {
164
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
165
+ const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
166
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
167
+ const { registerTools } = await import("./tools.js");
168
+ const { db } = await import("./db.js");
169
+ const tokenId = db.get<{ token_id: string }>("SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%' LIMIT 1", ownerNet)?.token_id ?? null;
170
+ const mcp = new McpServer({ name: "seq-mcp", version: "1" });
171
+ registerTools(mcp, undefined, ownerNet, ownerId, "seq-node", true, tokenId);
172
+ const client = new Client({ name: "seq-mcp-client", version: "1" });
173
+ const [ct, st] = InMemoryTransport.createLinkedPair();
174
+ await mcp.connect(st); await client.connect(ct);
175
+ const call = async (name: string, args: Record<string, unknown>) => {
176
+ const r: any = await client.callTool({ name, arguments: args });
177
+ return r.isError ? { mcpError: true } : JSON.parse(r.content[0].text);
178
+ };
179
+ try {
180
+ const made = await call("requirements_create", { name: "MCP 建的", checklist: [{ id: "k", text: "一" }] });
181
+ expect(Number.isInteger(made.requirement.seq)).toBe(true);
182
+ const n = `#${made.requirement.seq}`;
183
+ expect((await call("requirements_get", { id: n })).requirement.id).toBe(made.requirement.id);
184
+ expect((await call("requirements_update", { id: n, priority: "high" })).requirement.priority).toBe("high");
185
+ expect((await call("requirements_checklist_toggle", { id: n, item_id: "k", done: true })).requirement.checklist[0].done).toBe(true);
186
+ const listed = await call("requirements_list", { seq: made.requirement.seq });
187
+ expect(listed.requirements.map((r: any) => r.id)).toEqual([made.requirement.id]);
188
+ expect((await call("requirements_list", { seq: 0 })).mcpError).toBe(true);
189
+ expect((await call("requirements_get", { id: "#1" })).requirement.name).toBe("第一张"); // 节点令牌只看自己网络的 #1
190
+ } finally { await client.close(); await mcp.close(); }
191
+ });
192
+ });
@@ -0,0 +1,63 @@
1
+ // 任务短号 seq 的启动迁移:用没有 seq 列的旧表建库、塞行,再走真实启动路径(import db.ts + requirements.ts)。
2
+ // 补号按网络各自从 1 开始,按 created_at(新旧两种时间格式混在一起)排,同一时刻按 requirement_id。
3
+ import { afterAll, expect, test } from "bun:test";
4
+ import { Database } from "bun:sqlite";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-seq-migrate-"));
10
+ const file = join(dir, "hub.db");
11
+ process.env.COMMHUB_DB = file;
12
+
13
+ const legacy = new Database(file);
14
+ legacy.exec(`CREATE TABLE requirements (
15
+ requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
16
+ column_name TEXT NOT NULL DEFAULT 'pool', priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
17
+ client_id TEXT, issues_json TEXT, created_by TEXT,
18
+ created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
19
+ owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]')`);
20
+ // 插入顺序故意打乱;期望的号写在第三列。
21
+ const rows: [string, string, string, number][] = [
22
+ ["req_c", "net_a", "2026-09-02T00:00:00.000Z", 5],
23
+ ["req_a", "net_a", "2026-09-01 08:00:00", 1], // 旧默认格式(UTC,空格分隔)
24
+ ["req_z", "net_b", "2026-08-01T00:00:00.000Z", 1],
25
+ ["req_b2", "net_a", "2026-09-01T09:00:00.000Z", 3], // 与 req_b1 同一时刻 → 按 id
26
+ ["req_b1", "net_a", "2026-09-01T09:00:00.000Z", 2],
27
+ ["req_y", "net_b", "2026-08-02 00:00:00", 2],
28
+ ["req_d", "net_a", "2026-09-01 23:59:59", 4], // 旧格式,晚于 09:00 的 ISO、早于 09-02
29
+ ];
30
+ for (const [id, net, at] of rows) legacy.run("INSERT INTO requirements (requirement_id, network_id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?4)", [id, net, `卡 ${id}`, at]);
31
+ legacy.close();
32
+
33
+ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
34
+
35
+ test("startup backfills seq per network in created_at order, then id; counters and unique index follow", async () => {
36
+ const { db } = await import("./db.js");
37
+ await import("./requirements.js"); // 真实启动路径
38
+ const got = Object.fromEntries((db.all("SELECT requirement_id, network_id, seq FROM requirements") as any[]).map(r => [r.requirement_id, r.seq]));
39
+ // net_a 按时间:req_a 09-01 08:00 → req_b1 / req_b2 09-01 09:00(按 id)→ req_d 09-01 23:59:59 → req_c 09-02
40
+ expect(got).toEqual({ req_a: 1, req_b1: 2, req_b2: 3, req_d: 4, req_c: 5, req_z: 1, req_y: 2 });
41
+ // 按字符串排会把「2026-09-01 23:59:59」排到所有 ISO 的 09-01 前面 —— 证明排序真的按时刻,不按字面
42
+ expect("2026-09-01 23:59:59" < "2026-09-01T09:00:00.000Z").toBe(true);
43
+ const counters = Object.fromEntries((db.all("SELECT network_id, last_seq FROM requirement_seq_counters") as any[]).map(r => [r.network_id, r.last_seq]));
44
+ expect(counters).toEqual({ net_a: 5, net_b: 2 });
45
+ const idx = (db.all("PRAGMA index_list(requirements)") as { name: string; unique: number }[]).find(i => i.name === "idx_requirements_network_seq");
46
+ expect(idx?.unique).toBe(1);
47
+ expect(() => db.run("UPDATE requirements SET seq = 1 WHERE requirement_id = 'req_c'")).toThrow();
48
+
49
+ // 重复执行:已有号的行一个不动;回滚到旧 Hub 期间建的卡(seq 为空)接在后面补号
50
+ const { ensureRequirementSeq, nextRequirementSeq } = await import("./requirements-migrate.js");
51
+ expect(ensureRequirementSeq(db).backfilled).toBe(0);
52
+ db.run("INSERT INTO requirements (requirement_id, network_id, title, created_at, updated_at) VALUES ('req_old_hub', 'net_a', 'x', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z')");
53
+ expect(ensureRequirementSeq(db).backfilled).toBe(1);
54
+ expect((db.get("SELECT seq FROM requirements WHERE requirement_id = 'req_old_hub'") as any).seq).toBe(6);
55
+ expect(got.req_a).toBe((db.get("SELECT seq FROM requirements WHERE requirement_id = 'req_a'") as any).seq);
56
+ // 删掉最大号的卡,下一个号也不回收
57
+ db.run("DELETE FROM requirements WHERE requirement_id = 'req_old_hub'");
58
+ ensureRequirementSeq(db);
59
+ expect(nextRequirementSeq(db, "net_a")).toBe(7);
60
+ // 从没建过卡的网络从 1 开始
61
+ expect(nextRequirementSeq(db, "net_new")).toBe(1);
62
+ expect(nextRequirementSeq(db, "net_new")).toBe(2);
63
+ });