@sleep2agi/commhub-server 0.9.0-preview.137 → 0.9.0-preview.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.137",
3
+ "version": "0.9.0-preview.138",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -286,3 +286,72 @@ describe("GET /api/requirements/stats", () => {
286
286
  expect(r.body.requirement).toBeUndefined();
287
287
  });
288
288
  });
289
+
290
+ describe("tag_status current snapshot #1091", () => {
291
+ let project = "";
292
+ test("current unarchived four states, distinct memberships and separate null bucket", async () => {
293
+ const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "tag-status" });
294
+ expect(p.status).toBe(201); project = p.body.project.id;
295
+ const seed = (name: string, extra: Record<string, unknown>) => card(admin.token, name, { project_id: project, ...extra });
296
+ await seed("tag-pool", { tags: ["a", "b", "a"] });
297
+ await seed("tag-done", { tags: ["a", "b"], column: "done" });
298
+ await seed("tag-doing", { tags: ["b"], column: "doing" });
299
+ await seed("tag-abandoned", { column: "abandoned" });
300
+ await seed("tag-literal", { tags: ["无标签"] });
301
+ const invalid = await seed("tag-invalid-legacy", { column: "done" });
302
+ db.run("UPDATE requirements SET tags_json='invalid legacy json' WHERE requirement_id=?1", [invalid.id]);
303
+ await seed("tag-visible", { tags: ["visible"], column: "doing", owner: { kind: "user", id: alice.id } });
304
+ await seed("tag-secret", { tags: ["secret"], column: "done" });
305
+ const archived = await seed("tag-archived", { tags: ["archived-only"], column: "doing" });
306
+ expect((await send(admin.token, "PATCH", `/api/requirements/${archived.id}?network_id=${NET}`, { archived: true })).status).toBe(200);
307
+ const r = await stats(admin.token, `&project_id=${project}`);
308
+ expect(r.status).toBe(200);
309
+ expect(r.body.tag_status).toMatchObject({ basis: "current", complete: true, includes_archived: false, project_filter: project, total: 8 });
310
+ const buckets = new Map(r.body.tag_status.by_tag.map((x: any) => [x.tag, x]));
311
+ expect(buckets.get("a")).toEqual({ tag: "a", pool: 1, doing: 0, done: 1, abandoned: 0, total: 2 });
312
+ expect(buckets.get("b")).toEqual({ tag: "b", pool: 1, doing: 1, done: 1, abandoned: 0, total: 3 });
313
+ expect(buckets.get(null)).toEqual({ tag: null, pool: 0, doing: 0, done: 1, abandoned: 1, total: 2 });
314
+ expect(buckets.get("无标签")).toMatchObject({ pool: 1, total: 1 });
315
+ expect(buckets.has("archived-only")).toBe(false);
316
+ expect(r.body.tag_status.by_tag[0].tag).toBe("b");
317
+ expect(r.body.tag_status.by_tag.reduce((sum: number, x: any) => sum + x.total, 0)).toBe(10);
318
+ });
319
+ test("snapshot ignores time range; completion totals still follow it", async () => {
320
+ const future = new Date(Date.now() + 86_400_000).toISOString();
321
+ const r = await stats(admin.token, `&project_id=${project}&from=${encodeURIComponent(future)}&to=${encodeURIComponent(new Date(Date.now()+2*86_400_000).toISOString())}`);
322
+ expect(r.body.totals.done).toBe(0); expect(r.body.tag_status.total).toBe(8);
323
+ expect(r.body.tag_status.by_tag.find((x: any) => x.tag === "a").done).toBe(1);
324
+ });
325
+ test("same visibility scope hides inaccessible tags and project results", async () => {
326
+ const r = await stats(alice.token, `&project_id=${project}`);
327
+ expect(r.status).toBe(200); expect(r.body.tag_status.total).toBe(1);
328
+ expect(r.body.tag_status.by_tag).toEqual([{ tag: "visible", pool: 0, doing: 1, done: 0, abandoned: 0, total: 1 }]);
329
+ expect(r.text).not.toContain('"secret"'); expect(r.text).not.toContain('"archived-only"');
330
+ const empty = await stats(alice.token, `&project_id=${PROJ}`);
331
+ expect(empty.body.tag_status.total).toBe(0); expect(empty.body.tag_status.by_tag).toEqual([]);
332
+ });
333
+ test("project none, unknown project and invalid project query", async () => {
334
+ const r = await stats(admin.token, "&project_id=none");
335
+ expect(r.body.tag_status.project_filter).toBe("none");
336
+ expect(r.body.tag_status.by_tag.every((x: any) => x.tag === null)).toBe(true);
337
+ expect((await stats(admin.token, "&project_id=proj_missing")).body.tag_status).toMatchObject({ total: 0, by_tag: [] });
338
+ expect((await stats(admin.token, `&project_id=${"x".repeat(201)}`)).body.error).toBe("invalid_project_id");
339
+ const list = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
340
+ expect(list.body.capabilities).toContain("tag_status_snapshot");
341
+ });
342
+ test("all visible rows counted beyond client 500 limit; another network excluded", async () => {
343
+ const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "large-tag-status" });
344
+ expect(p.status).toBe(201); const large = p.body.project.id;
345
+ const other = `net_tag_foreign_${Date.now()}`;
346
+ db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,'foreign',?2)", [other,admin.id]);
347
+ db.transaction(() => {
348
+ for (let i=0;i<502;i++) db.run(`INSERT INTO requirements(requirement_id,network_id,title,column_name,tags_json,project_id,archived,created_at,updated_at)
349
+ VALUES(?1,?2,'bulk','pool','["bulk"]',?3,0,datetime('now'),datetime('now'))`, [`req_tag_bulk_${large}_${i}`,i===501?other:NET,large]);
350
+ });
351
+ const r = await stats(admin.token, `&project_id=${large}&recent=0`);
352
+ expect(r.status).toBe(200); expect(r.body.tag_status.total).toBe(501);
353
+ expect(r.body.tag_status.by_tag).toEqual([{ tag: "bulk", pool: 501, doing: 0, done: 0, abandoned: 0, total: 501 }]);
354
+ const denied = await send(alice.token,"GET",`/api/requirements/stats?network_id=${other}`);
355
+ expect([403,404]).toContain(denied.status);
356
+ });
357
+ });
@@ -2,6 +2,7 @@
2
2
  // 这里只按时间范围 / 时区分桶、按项目 / 完成者计数。放在 JS 里而不是 SQL:按 IANA 时区切「哪一天」在
3
3
  // SQLite 和 PostgreSQL 上写法不同,而一个网络的卡片数是几百到几千,整表轻量列读一次就够。
4
4
  import { isoInstant } from "./requirements-migrate.js";
5
+ import { storedTags } from "./requirement-tags.js";
5
6
 
6
7
  export const STATS_DEFAULT_DAYS = 30;
7
8
  export const STATS_MAX_DAYS = 371; // 53 周:够画一整年的贡献热力图
@@ -21,9 +22,10 @@ export type StatsRow = {
21
22
  completed_at: string | null;
22
23
  completed_at_approx: number | null;
23
24
  completed_by_json: string | null;
25
+ tags_json?: string | null;
24
26
  };
25
27
 
26
- export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number };
28
+ export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number; projectId?: string | null };
27
29
  type Ref = { kind: "user" | "node"; id: string };
28
30
 
29
31
  /** 解析 from / to / tz / days。不合法 → 错误码(400)。to 缺省 = 现在;from 缺省 = 不设下限(全部)。 */
@@ -48,7 +50,10 @@ export function parseStatsQuery(q: URLSearchParams, nowMs: number): StatsQuery |
48
50
  const recentRaw = q.get("recent");
49
51
  const recent = recentRaw === null || recentRaw === "" ? STATS_DEFAULT_RECENT : Number(recentRaw);
50
52
  if (!Number.isInteger(recent) || recent < 0 || recent > STATS_MAX_RECENT) return { error: "invalid_recent" };
51
- return { fromMs: from, toMs, tz, days, recent };
53
+ const project = q.get("project_id");
54
+ if (project && (project.length > 200 || /[\u0000-\u001f\u007f]/u.test(project))) return { error: "invalid_project_id" };
55
+ return { fromMs: from, toMs, tz, days, recent,
56
+ ...(project ? { projectId: project === "none" ? null : project } : {}) };
52
57
  }
53
58
 
54
59
  function validTimeZone(tz: string): boolean {
@@ -86,6 +91,7 @@ const parseRef = (json: string | null): Ref | null => {
86
91
  * 但归到 unattributed,不出现在 by_completer 里 —— 不给他留「那个节点做了几张」的探测口。
87
92
  */
88
93
  export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId: string) => boolean) | null) {
94
+ if (q.projectId !== undefined) rows = rows.filter(row => row.project_id === q.projectId);
89
95
  const inRange = (ms: number | null) => ms !== null && ms <= q.toMs && (q.fromMs === null || ms >= q.fromMs);
90
96
  const days = dayRange(localDate(q.toMs, q.tz), q.days);
91
97
  const firstDay = days[0];
@@ -97,8 +103,21 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
97
103
  const byProject = new Map<string | null, number>();
98
104
  const byCompleter = new Map<string, { ref: Ref; n: number; spark: Map<string, number> }>();
99
105
  const recent: { row: StatsRow; ms: number; by: Ref | null }[] = [];
106
+ const byTag = new Map<string | null, { tag: string | null; pool: number; doing: number; done: number; abandoned: number; total: number }>();
107
+ let snapshotTotal = 0;
100
108
 
101
109
  for (const row of rows) {
110
+ if (!row.archived && ["pool", "doing", "done", "abandoned"].includes(row.column_name)) {
111
+ snapshotTotal++;
112
+ const tags: (string | null)[] = storedTags(row.tags_json ?? null);
113
+ if (!tags.length) tags.push(null);
114
+ for (const tag of tags) {
115
+ let bucket = byTag.get(tag);
116
+ if (!bucket) byTag.set(tag, bucket = { tag, pool: 0, doing: 0, done: 0, abandoned: 0, total: 0 });
117
+ bucket[row.column_name as "pool" | "doing" | "done" | "abandoned"]++;
118
+ bucket.total++;
119
+ }
120
+ }
102
121
  // 当前还开着的卡(不看时间范围;归档的不算「进行中」)。
103
122
  if (!row.archived && row.column_name === "doing") doing++;
104
123
  if (!row.archived && row.column_name === "pool") pool++;
@@ -133,6 +152,15 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
133
152
 
134
153
  const spark = days.slice(-STATS_SPARK_DAYS);
135
154
  return {
155
+ tag_status: {
156
+ basis: "current", complete: true, includes_archived: false,
157
+ project_filter: q.projectId === undefined ? null : q.projectId ?? "none",
158
+ total: snapshotTotal,
159
+ // Membership counts: a multi-tag card counts once in each distinct tag.
160
+ by_tag: [...byTag.values()].sort((a, b) =>
161
+ (b.pool + b.doing) - (a.pool + a.doing) || b.total - a.total ||
162
+ String(a.tag ?? "\uffff").localeCompare(String(b.tag ?? "\uffff"))),
163
+ },
136
164
  range: { from: q.fromMs === null ? null : new Date(q.fromMs).toISOString(), to: new Date(q.toMs).toISOString(), tz: q.tz },
137
165
  totals: {
138
166
  done,
@@ -770,7 +770,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
770
770
  // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
771
771
  // (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
772
772
  // events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
773
- export const REQUIREMENT_CAPABILITIES = ["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", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
773
+ export const REQUIREMENT_CAPABILITIES = ["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_status_snapshot", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
774
774
 
775
775
  // ── 子需求 ──
776
776
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -1584,7 +1584,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1584
1584
  if ("error" in sq) return jsonError(sq.error, 400);
1585
1585
  const statsParams: unknown[] = [networkId];
1586
1586
  const statsSql = addTaskVisibilityScope(
1587
- "SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json FROM requirements WHERE network_id = ?1",
1587
+ "SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json, tags_json FROM requirements WHERE network_id = ?1",
1588
1588
  statsParams, taskCaller(ctx),
1589
1589
  );
1590
1590
  const rows = db.all<StatsRow>(statsSql, ...statsParams);